Files
gongxue-base/apps/server/src/ai-chat/ai-chart.service.ts

127 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> {
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<string, unknown>, allowed: Set<string>, 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<string>();
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<string>): 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;
}
}