import { BadRequestException, Injectable } from '@nestjs/common'; import { uuidV7 } from '../common/uuid-v7'; import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity'; const MAX_TITLE = 50; const MAX_COLUMNS = 10; const MIN_COLUMNS = 2; const MAX_ROWS = 500; const MAX_CELL_LENGTH = 200; const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/; const CHART_TYPES = new Set(['line', 'bar', 'pie', 'area', 'scatter', 'radar', 'gauge', 'funnel']); const SCHEMA_KEYS = new Set(['title', 'chartType', 'columns', 'rows']); const COLUMN_KEYS_ALLOWED = new Set(['key', 'title']); export interface AiChart { id: string; title: string; chartType: 'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'gauge' | 'funnel'; columns: AiReviewColumn[]; rows: AiReviewRow[]; } function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function requireString(value: unknown, label: string, max: number): string { if (typeof value !== 'string' || !value.trim()) { throw new BadRequestException(`${label}必须是字符串`); } const trimmed = value.trim(); if (trimmed.length > max) { throw new BadRequestException(`${label}长度不能超过 ${max}`); } return trimmed; } function assertKeys(raw: Record, allowed: Set, label: string): void { for (const key of Object.keys(raw)) { if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`); } } /** * Validates the `render_chart` tool arguments. The model sends a * whitelisted tabular shape (columns + rows); the frontend converts it * into an ECharts option, so no arbitrary option objects reach the client. */ @Injectable() export class AiChartService { createChart(rawArgs: unknown): AiChart { if (!isPlainRecord(rawArgs)) throw new BadRequestException('图表参数必须是对象'); assertKeys(rawArgs, SCHEMA_KEYS, '图表'); const title = requireString(rawArgs.title, '图表标题', MAX_TITLE); if (typeof rawArgs.chartType !== 'string' || !CHART_TYPES.has(rawArgs.chartType)) { throw new BadRequestException('图表类型不支持'); } const chartType = rawArgs.chartType as AiChart['chartType']; if (!Array.isArray(rawArgs.columns) || rawArgs.columns.length < MIN_COLUMNS) { throw new BadRequestException('图表至少需要 2 列(类别/名称 + 数值)'); } if (rawArgs.chartType === 'scatter' && rawArgs.columns.length < 3) { throw new BadRequestException('散点图需要 3 列:名称、X 数值、Y 数值'); } if (rawArgs.columns.length > MAX_COLUMNS) { throw new BadRequestException(`图表列数不能超过 ${MAX_COLUMNS}`); } const seenColumns = new Set(); const columns = rawArgs.columns.map((column, index) => { if (!isPlainRecord(column)) { throw new BadRequestException(`图表第 ${index + 1} 列格式无效`); } assertKeys(column, COLUMN_KEYS_ALLOWED, `图表第 ${index + 1} 列`); const key = requireString(column.key, `图表第 ${index + 1} 列名`, 50); if (!COLUMN_KEY_RE.test(key)) { throw new BadRequestException(`图表列名 ${key} 只能包含字母、数字、下划线`); } if (seenColumns.has(key)) throw new BadRequestException(`图表列名重复: ${key}`); seenColumns.add(key); const columnTitle = requireString(column.title, `图表列「${key}」标题`, 50); return { key, title: columnTitle }; }); if (!Array.isArray(rawArgs.rows) || rawArgs.rows.length > MAX_ROWS) { throw new BadRequestException(`图表行数不能超过 ${MAX_ROWS}`); } const rows = rawArgs.rows.map((row, index) => this.validateRow(row, index, seenColumns)); return { id: uuidV7(), title, chartType, columns, rows }; } serialize(chart: AiChart): AiChart { return chart; } private validateRow(raw: unknown, index: number, knownColumns: Set): AiReviewRow { if (!isPlainRecord(raw)) throw new BadRequestException(`图表第 ${index + 1} 行格式无效`); const row: AiReviewRow = {}; for (const [key, value] of Object.entries(raw)) { if (!knownColumns.has(key)) continue; if (value === null || typeof value === 'boolean') { row[key] = value; continue; } if (typeof value === 'number') { if (!Number.isFinite(value)) { throw new BadRequestException(`图表第 ${index + 1} 行 ${key} 必须是有效数字`); } row[key] = value; continue; } if (typeof value === 'string') { if (value.length > MAX_CELL_LENGTH) { throw new BadRequestException( `图表第 ${index + 1} 行 ${key} 长度超过 ${MAX_CELL_LENGTH}`, ); } row[key] = value; continue; } throw new BadRequestException(`图表第 ${index + 1} 行 ${key} 类型不支持`); } return row; } }