feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import JSZip from 'jszip';
|
||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||
import { AiAttachmentService } from './ai-attachment.service';
|
||||
|
||||
describe('AiAttachmentService', () => {
|
||||
const repository = {
|
||||
findByIds: jest.fn(),
|
||||
};
|
||||
const service = new AiAttachmentService(repository as never);
|
||||
const service = new AiAttachmentService(repository as never, new AiExcelReaderService());
|
||||
|
||||
it.each([
|
||||
[Buffer.from([0xff, 0xd8, 0xff, 0x00]), 'image/jpeg', 'image/jpeg'],
|
||||
@@ -48,6 +50,17 @@ describe('AiAttachmentService', () => {
|
||||
expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow();
|
||||
});
|
||||
|
||||
it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => {
|
||||
const decodeFilename = (
|
||||
service as unknown as { decodeFilename(name: string): string }
|
||||
).decodeFilename.bind(service);
|
||||
expect(decodeFilename('26æ\u009a\u0091æ\u009c\u009fæ\u0096\u0087å\u008c\u0096课宿è\u0088\u008d.xlsx')).toBe(
|
||||
'26暑期文化课宿舍.xlsx',
|
||||
);
|
||||
expect(decodeFilename('café.xlsx')).toBe('café.xlsx');
|
||||
expect(decodeFilename('暑期.xlsx')).toBe('暑期.xlsx');
|
||||
});
|
||||
|
||||
it('limits the total image bytes sent to a vision model', async () => {
|
||||
await expect(
|
||||
service.toModelParts(
|
||||
@@ -59,4 +72,94 @@ describe('AiAttachmentService', () => {
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('extracts text from namespace-prefixed (WPS-style) xlsx via fallback', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'[Content_Types].xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||
</Types>`,
|
||||
);
|
||||
zip.file(
|
||||
'_rels/.rels',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/workbook.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<x:workbook xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<x:sheets><x:sheet name="名单" sheetId="1" state="visible" r:id="rId1"/></x:sheets>
|
||||
</x:workbook>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/_rels/workbook.xml.rels',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/sharedStrings.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<x:sst xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><x:si><x:t>张三</x:t></x:si></x:sst>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/worksheets/sheet1.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<x:worksheet xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<x:sheetData>
|
||||
<x:row r="1"><x:c r="A1" t="inlineStr"><x:is><x:t>姓名</x:t></x:is></x:c><x:c r="B1" t="inlineStr"><x:is><x:t>手机号</x:t></x:is></x:c></x:row>
|
||||
<x:row r="2"><x:c r="A2" t="s"><x:v>0</x:v></x:c><x:c r="B2"><x:v>13800138000</x:v></x:c></x:row>
|
||||
</x:sheetData>
|
||||
</x:worksheet>`,
|
||||
);
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const extract = (
|
||||
service as unknown as {
|
||||
extractText(buffer: Buffer, mimeType: string): Promise<string | null>;
|
||||
}
|
||||
).extractText.bind(service);
|
||||
const text = await extract(
|
||||
Buffer.from(buffer),
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
expect(text).toContain('# 名单');
|
||||
expect(text).toContain('张三');
|
||||
expect(text).toContain('13800138000');
|
||||
});
|
||||
|
||||
it('extracts pptx text via OfficeCli', async () => {
|
||||
const officeCli = {
|
||||
view: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { elements: [{ text: '第一页标题' }, { text: '' }, { text: '正文内容' }] },
|
||||
}),
|
||||
};
|
||||
const local = new AiAttachmentService(
|
||||
repository as never,
|
||||
new AiExcelReaderService(),
|
||||
officeCli as never,
|
||||
);
|
||||
const extract = (
|
||||
local as unknown as {
|
||||
extractText(buffer: Buffer, mimeType: string): Promise<string | null>;
|
||||
}
|
||||
).extractText.bind(local);
|
||||
const text = await extract(
|
||||
Buffer.from('fake-pptx'),
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
);
|
||||
expect(text).toContain('第一页标题');
|
||||
expect(text).toContain('正文内容');
|
||||
expect(officeCli.view).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
import { AiAttachment } from './entities';
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
@@ -23,6 +25,7 @@ const ACCEPTED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
]);
|
||||
|
||||
interface MammothResult {
|
||||
@@ -47,6 +50,8 @@ export class AiAttachmentService {
|
||||
constructor(
|
||||
@InjectRepository(AiAttachment)
|
||||
private readonly attachments: Repository<AiAttachment>,
|
||||
private readonly excelReader: AiExcelReaderService,
|
||||
private readonly officeCli?: OfficeCliService,
|
||||
) {}
|
||||
|
||||
async upload(userId: number, file: Express.Multer.File): Promise<AiAttachment> {
|
||||
@@ -72,7 +77,7 @@ export class AiAttachmentService {
|
||||
entity = await this.attachments.save(
|
||||
this.attachments.create({
|
||||
userId,
|
||||
originalName: basename(file.originalname).slice(0, 255),
|
||||
originalName: this.decodeFilename(basename(file.originalname)).slice(0, 255),
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storageKey,
|
||||
@@ -217,37 +222,83 @@ export class AiAttachmentService {
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
return this.normalizeExtractedText(result.value);
|
||||
}
|
||||
if (mimeType.includes('presentationml')) {
|
||||
if (!this.officeCli) return null;
|
||||
const text = await this.extractWithOfficeCli(buffer, mimeType);
|
||||
return this.normalizeExtractedText(text);
|
||||
}
|
||||
if (mimeType.includes('spreadsheetml')) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||
const lines: string[] = [];
|
||||
workbook.eachSheet((sheet) => {
|
||||
lines.push(`# ${sheet.name}`);
|
||||
sheet.eachRow((row) => {
|
||||
const values = Array.isArray(row.values) ? row.values.slice(1) : [];
|
||||
lines.push(values.map((value) => this.stringifyCellValue(value)).join('\t'));
|
||||
});
|
||||
});
|
||||
return this.normalizeExtractedText(lines.join('\n'));
|
||||
return this.normalizeExtractedText(await this.excelReader.extractText(buffer));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeExtractedText(value: string): string {
|
||||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS);
|
||||
private async extractWithOfficeCli(buffer: Buffer, mimeType: string): Promise<string> {
|
||||
if (!this.officeCli) return '';
|
||||
const extension = this.extensionForMime(mimeType);
|
||||
const tempPath = join(tmpdir(), `${randomUUID()}.${extension}`);
|
||||
try {
|
||||
await writeFile(tempPath, buffer, { flag: 'wx' });
|
||||
const result = await this.officeCli.view(tempPath, 'text');
|
||||
if (!result.success || !result.data || typeof result.data !== 'object') return '';
|
||||
const data = result.data as { sheets?: Array<{ name: string; rows: unknown[] }>; elements?: Array<{ text?: string }> };
|
||||
if (Array.isArray(data.sheets)) {
|
||||
return data.sheets
|
||||
.map((sheet) => {
|
||||
const lines: string[] = [];
|
||||
for (const row of sheet.rows ?? []) {
|
||||
if (!row || typeof row !== 'object' || !('cells' in row)) continue;
|
||||
const cells = (row as { cells: Record<string, unknown> }).cells;
|
||||
const placed = new Map<number, string>();
|
||||
let maxColumn = -1;
|
||||
for (const [key, value] of Object.entries(cells)) {
|
||||
const columnIndex = this.officeColumnIndex(key.replace(/\d+/g, ''));
|
||||
placed.set(columnIndex, String(value ?? ''));
|
||||
maxColumn = Math.max(maxColumn, columnIndex);
|
||||
}
|
||||
if (maxColumn < 0) continue;
|
||||
const line = Array.from({ length: maxColumn + 1 }, (_, index) => placed.get(index) ?? '').join('\t');
|
||||
if (line.trim()) lines.push(line);
|
||||
}
|
||||
return `# ${sheet.name}\n${lines.join('\n')}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
if (Array.isArray(data.elements)) {
|
||||
return data.elements
|
||||
.map((element) => element.text ?? '')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
} finally {
|
||||
await unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private stringifyCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value) || '';
|
||||
} catch {
|
||||
return '';
|
||||
private officeColumnIndex(letters: string): number {
|
||||
let index = 0;
|
||||
for (const char of letters.toUpperCase()) {
|
||||
index = index * 26 + (char.charCodeAt(0) - 64);
|
||||
}
|
||||
return index - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored file content of an already-owned attachment so the AI
|
||||
* chat agent can page through large workbooks on demand.
|
||||
*/
|
||||
async readStoredBuffer(attachment: AiAttachment): Promise<Buffer> {
|
||||
return readFile(this.resolveStoragePath(attachment.storageKey));
|
||||
}
|
||||
|
||||
/** Resolved absolute path of a stored attachment (for OfficeCli). */
|
||||
storagePathFor(attachment: AiAttachment): string {
|
||||
return this.resolveStoragePath(attachment.storageKey);
|
||||
}
|
||||
|
||||
private normalizeExtractedText(value: string): string {
|
||||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS);
|
||||
}
|
||||
|
||||
private assertDeclaredType(declared: string, detected: string): void {
|
||||
@@ -264,6 +315,7 @@ export class AiAttachmentService {
|
||||
'application/pdf': ['pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
|
||||
};
|
||||
if (!extension || !expected[mimeType]?.includes(extension)) {
|
||||
throw new BadRequestException('附件扩展名与文件内容不一致');
|
||||
@@ -289,13 +341,32 @@ export class AiAttachmentService {
|
||||
if (
|
||||
isZip &&
|
||||
(declaredMimeType.includes('wordprocessingml') ||
|
||||
declaredMimeType.includes('spreadsheetml'))
|
||||
declaredMimeType.includes('spreadsheetml') ||
|
||||
declaredMimeType.includes('presentationml'))
|
||||
) {
|
||||
return declaredMimeType;
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers send UTF-8 filenames in the multipart header, which multer
|
||||
* decodes as Latin-1 — the stored name then looks like mojibake
|
||||
* (e.g. `26ææ...`). Re-decode when the bytes are valid UTF-8 and
|
||||
* contain CJK; otherwise keep the original name untouched.
|
||||
*/
|
||||
private decodeFilename(name: string): string {
|
||||
if (!/[\u00c0-\u00ff]/.test(name)) return name;
|
||||
try {
|
||||
const decoded = Buffer.from(name, 'latin1').toString('utf8');
|
||||
if (decoded.includes('\uFFFD')) return name;
|
||||
if (!/[\u4e00-\u9fff]/.test(decoded)) return name;
|
||||
return decoded;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private extensionForMime(mimeType: string): string {
|
||||
const extensions: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -304,6 +375,7 @@ export class AiAttachmentService {
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
};
|
||||
return extensions[mimeType] || 'bin';
|
||||
}
|
||||
|
||||
86
apps/server/src/ai-chat/ai-chart.service.spec.ts
Normal file
86
apps/server/src/ai-chat/ai-chart.service.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AiChartService } from './ai-chart.service';
|
||||
|
||||
const service = new AiChartService();
|
||||
|
||||
const validSchema = {
|
||||
title: '各班级人数',
|
||||
chartType: 'bar',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'count', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ className: '一班', count: 20 },
|
||||
{ className: '二班', count: 15 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('AiChartService', () => {
|
||||
it('校验通过的图表保留 id、列与行', () => {
|
||||
const chart = service.createChart(validSchema);
|
||||
expect(chart.id).toBeTruthy();
|
||||
expect(chart.chartType).toBe('bar');
|
||||
expect(chart.columns).toEqual(validSchema.columns);
|
||||
expect(chart.rows).toEqual(validSchema.rows);
|
||||
});
|
||||
|
||||
it.each(['line', 'bar', 'pie', 'area', 'radar', 'gauge', 'funnel'])(
|
||||
'支持 %s 图表类型',
|
||||
(chartType) => {
|
||||
const chart = service.createChart({ ...validSchema, chartType });
|
||||
expect(chart.chartType).toBe(chartType);
|
||||
},
|
||||
);
|
||||
|
||||
it('支持散点图并要求至少 3 列', () => {
|
||||
const chart = service.createChart({
|
||||
...validSchema,
|
||||
chartType: 'scatter',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'capacity', title: '容量' },
|
||||
{ key: 'occupied', title: '入住人数' },
|
||||
],
|
||||
});
|
||||
expect(chart.chartType).toBe('scatter');
|
||||
expect(() =>
|
||||
service.createChart({ ...validSchema, chartType: 'scatter' }),
|
||||
).toThrow('散点图需要 3 列');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['标题缺失', { chartType: 'bar', columns: validSchema.columns, rows: [] }, '标题'],
|
||||
['类型不支持', { ...validSchema, chartType: 'hack' }, '图表类型不支持'],
|
||||
['列不足', { ...validSchema, columns: [{ key: 'x', title: 'X' }] }, '至少需要 2 列'],
|
||||
['列过多', {
|
||||
...validSchema,
|
||||
columns: Array.from({ length: 11 }, (_, i) => ({ key: `c${i}`, title: `列${i}` })),
|
||||
}, '不能超过 10'],
|
||||
['列名非法', { ...validSchema, columns: [{ key: '类 别', title: 'X' }, { key: 'n', title: 'N' }] }, '只能包含'],
|
||||
['列名重复', {
|
||||
...validSchema,
|
||||
columns: [{ key: 'x', title: 'A' }, { key: 'x', title: 'B' }],
|
||||
}, '列名重复'],
|
||||
['行数超限', {
|
||||
...validSchema,
|
||||
rows: Array.from({ length: 501 }, (_, i) => ({ className: `班${i}`, count: 1 })),
|
||||
}, '不能超过 500'],
|
||||
['单元格类型非法', {
|
||||
...validSchema,
|
||||
rows: [{ className: '一班', count: { hack: true } }],
|
||||
}, '类型不支持'],
|
||||
['未知顶层字段', { ...validSchema, extra: 1 }, '未知属性'],
|
||||
])('非法图表被拒绝:%s', async (_name, schema, messagePart) => {
|
||||
expect(() => service.createChart(schema)).toThrow(BadRequestException);
|
||||
expect(() => service.createChart(schema)).toThrow(messagePart);
|
||||
});
|
||||
|
||||
it('行内未知列被剔除', () => {
|
||||
const chart = service.createChart({
|
||||
...validSchema,
|
||||
rows: [{ className: '一班', count: 20, token: 'secret' }],
|
||||
});
|
||||
expect(chart.rows[0]).toEqual({ className: '一班', count: 20 });
|
||||
});
|
||||
});
|
||||
126
apps/server/src/ai-chat/ai-chart.service.ts
Normal file
126
apps/server/src/ai-chat/ai-chart.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,15 @@ import type { AuthenticatedUser } from '../authorization';
|
||||
import { AiAttachmentService } from './ai-attachment.service';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import type { AiSseEventName } from './ai-chat.types';
|
||||
import type { AiReviewSection, AiReviewSectionType } from './entities';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
MessageFeedbackDto,
|
||||
MessagePageQueryDto,
|
||||
RegenerateMessageDto,
|
||||
SendMessageDto,
|
||||
SubmitFormDto,
|
||||
SubmitReviewDto,
|
||||
UpdateConversationDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
|
||||
@@ -79,6 +82,14 @@ export class AiChatController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('conversations')
|
||||
async removeAll(@Req() req: AuthenticatedRequest) {
|
||||
return {
|
||||
success: true,
|
||||
data: { deleted: await this.service.deleteAllConversations(req.user.id) },
|
||||
};
|
||||
}
|
||||
|
||||
@Post('attachments')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||
async uploadAttachment(
|
||||
@@ -156,6 +167,7 @@ export class AiChatController {
|
||||
id,
|
||||
messageId,
|
||||
dto.clientRequestId,
|
||||
dto.reasoningEffort,
|
||||
signal,
|
||||
emit,
|
||||
onReady,
|
||||
@@ -163,6 +175,66 @@ export class AiChatController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('forms/:formId/submit/stream')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
async submitForm(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Param('formId') formId: string,
|
||||
@Body() dto: SubmitFormDto,
|
||||
): Promise<void> {
|
||||
const conversationId = await this.service.resolveFormConversationId(req.user.id, formId);
|
||||
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
||||
this.service.submitForm(req.user, formId, dto, signal, emit, onReady),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/submit/stream')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
async submitReview(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Param('reviewId') reviewId: string,
|
||||
@Body() dto: SubmitReviewDto,
|
||||
): Promise<void> {
|
||||
const conversationId = await this.service.resolveReviewConversationId(req.user.id, reviewId);
|
||||
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
||||
this.service.submitReview(req.user, reviewId, dto, signal, emit, onReady),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
||||
async confirmReviewStep(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('reviewId') reviewId: string,
|
||||
@Param('sectionKey') sectionKey: string,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.service.confirmReviewStep(
|
||||
req.user,
|
||||
reviewId,
|
||||
sectionKey as AiReviewSection['key'],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/types/:type/confirm')
|
||||
async confirmReviewGroup(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('reviewId') reviewId: string,
|
||||
@Param('type') type: string,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.service.confirmReviewGroup(
|
||||
req.user,
|
||||
reviewId,
|
||||
type as AiReviewSectionType,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/feedback')
|
||||
async feedback(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@@ -215,6 +287,7 @@ export class AiChatController {
|
||||
}
|
||||
};
|
||||
const onReady = () => {
|
||||
if (res.headersSent) return;
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
@@ -247,6 +320,8 @@ export class AiChatController {
|
||||
if (status === 409) return { code: 'CONVERSATION_BUSY', message: '该会话正在生成回答' };
|
||||
if (status === 408) return { code: 'UPSTREAM_TIMEOUT', message: 'AI 服务响应超时' };
|
||||
if (status === 400) return { code: 'BAD_REQUEST', message: error.message };
|
||||
if (status === 429) return { code: 'RATE_LIMITED', message: 'AI 服务请求过于频繁' };
|
||||
if (status >= 500) return { code: 'UPSTREAM_ERROR', message: error.message };
|
||||
}
|
||||
return { code: 'UPSTREAM_ERROR', message: 'AI 服务暂时不可用' };
|
||||
}
|
||||
|
||||
@@ -4,18 +4,46 @@ import { AgentToolsModule } from '../agent-tools';
|
||||
import { AiConfigModule } from '../ai-config/ai-config.module';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { AiAttachmentService } from './ai-attachment.service';
|
||||
import { AiChartService } from './ai-chart.service';
|
||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||
import { AiFormService } from './ai-form.service';
|
||||
import { AiReviewService } from './ai-review.service';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { AiModelStreamService } from './ai-model-stream.service';
|
||||
import { AiAttachment, AiConversation, AiMessage, AiToolRun } from './entities';
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
import {
|
||||
AiAttachment,
|
||||
AiConversation,
|
||||
AiForm,
|
||||
AiMessage,
|
||||
AiReview,
|
||||
AiToolRun,
|
||||
} from './entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AiAttachment, AiConversation, AiMessage, AiToolRun]),
|
||||
TypeOrmModule.forFeature([
|
||||
AiAttachment,
|
||||
AiConversation,
|
||||
AiForm,
|
||||
AiMessage,
|
||||
AiReview,
|
||||
AiToolRun,
|
||||
]),
|
||||
AiConfigModule,
|
||||
AgentToolsModule,
|
||||
],
|
||||
controllers: [AiChatController],
|
||||
providers: [AiAttachmentService, AiChatService, AiModelStreamService],
|
||||
providers: [
|
||||
AiAttachmentService,
|
||||
AiChartService,
|
||||
AiExcelReaderService,
|
||||
AiFormService,
|
||||
AiReviewService,
|
||||
OfficeCliService,
|
||||
AiChatService,
|
||||
AiModelStreamService,
|
||||
],
|
||||
exports: [AiChatService],
|
||||
})
|
||||
export class AiChatModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,13 @@ export type AiSseEventName =
|
||||
| 'message.created'
|
||||
| 'reasoning.delta'
|
||||
| 'content.delta'
|
||||
| 'model.retrying'
|
||||
| 'tool.started'
|
||||
| 'tool.completed'
|
||||
| 'tool.failed'
|
||||
| 'ui.form'
|
||||
| 'ui.review'
|
||||
| 'ui.chart'
|
||||
| 'attachment.processed'
|
||||
| 'message.completed'
|
||||
| 'message.cancelled'
|
||||
@@ -40,4 +44,11 @@ export type ModelMessage =
|
||||
export type ModelStreamEvent =
|
||||
| { type: 'reasoning'; delta: string }
|
||||
| { type: 'content'; delta: string }
|
||||
| {
|
||||
type: 'retrying';
|
||||
attempt: number;
|
||||
maxRetries: number;
|
||||
delayMs: number;
|
||||
reason: string;
|
||||
}
|
||||
| { type: 'complete'; toolCalls: ModelToolCall[] };
|
||||
|
||||
235
apps/server/src/ai-chat/ai-excel-reader.service.ts
Normal file
235
apps/server/src/ai-chat/ai-excel-reader.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export interface ExcelSheetInfo {
|
||||
name: string;
|
||||
rowCount: number;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface ExcelSheetRows {
|
||||
name: string;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured Excel reader used by the AI chat. ExcelJS handles standard
|
||||
* files; a direct OOXML fallback handles WPS-style files that prefix
|
||||
* every element with a namespace. The agent reads sheets on demand
|
||||
* (`list_excel_sheets` / `read_excel_rows`) instead of receiving one
|
||||
* fixed text dump.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiExcelReaderService {
|
||||
async loadSheets(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
try {
|
||||
return await this.loadWithExcelJs(buffer);
|
||||
} catch {
|
||||
return this.loadWithFallback(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer): Promise<string> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
return sheets
|
||||
.map((sheet) => `# ${sheet.name}\n${sheet.rows.map((row) => row.join('\t')).join('\n')}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Sheet list + row counts + a short sample, small enough for prompts. */
|
||||
async overview(
|
||||
buffer: Buffer,
|
||||
sampleRows = 12,
|
||||
): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
const info = sheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
rowCount: sheet.rows.length,
|
||||
columns: sheet.rows[0] ?? [],
|
||||
}));
|
||||
const lines: string[] = [];
|
||||
for (const sheet of sheets) {
|
||||
lines.push(`# ${sheet.name}(共 ${sheet.rows.length} 行)`);
|
||||
for (const row of sheet.rows.slice(0, sampleRows)) {
|
||||
lines.push(row.join('\t'));
|
||||
}
|
||||
if (sheet.rows.length > sampleRows) {
|
||||
lines.push(`…(其余 ${sheet.rows.length - sampleRows} 行未显示)`);
|
||||
}
|
||||
}
|
||||
return { sheets: info, text: lines.join('\n') };
|
||||
}
|
||||
|
||||
async readRows(
|
||||
buffer: Buffer,
|
||||
sheetName: string | undefined,
|
||||
startRow: number,
|
||||
rowCount: number,
|
||||
maxColumns: number,
|
||||
): Promise<{
|
||||
sheet: string;
|
||||
rowCount: number;
|
||||
startRow: number;
|
||||
rows: string[][];
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];
|
||||
if (!sheet) {
|
||||
return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false };
|
||||
}
|
||||
const from = Math.max(0, startRow - 1);
|
||||
const limit = Math.min(rowCount, 200);
|
||||
const slice = sheet.rows.slice(from, from + limit);
|
||||
const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50)));
|
||||
return {
|
||||
sheet: sheet.name,
|
||||
rowCount: sheet.rows.length,
|
||||
startRow: from + 1,
|
||||
rows,
|
||||
truncated: slice.length < limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadWithExcelJs(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||
const sheets: ExcelSheetRows[] = [];
|
||||
workbook.eachSheet((sheet) => {
|
||||
const rows: string[][] = [];
|
||||
sheet.eachRow((row) => {
|
||||
const values = Array.isArray(row.values) ? row.values.slice(1) : [];
|
||||
rows.push(values.map((value) => this.stringifyCellValue(value)));
|
||||
});
|
||||
sheets.push({ name: sheet.name, rows });
|
||||
});
|
||||
return sheets;
|
||||
}
|
||||
|
||||
private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const readEntry = async (name: string): Promise<string | null> => {
|
||||
const entry = zip.file(name);
|
||||
return entry ? entry.async('string') : null;
|
||||
};
|
||||
const workbookXml = await readEntry('xl/workbook.xml');
|
||||
if (!workbookXml) throw new Error('workbook.xml missing');
|
||||
const stripPrefixes = (value: string): string =>
|
||||
value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? '');
|
||||
const relTargets = new Map<string, string>();
|
||||
for (const match of relsXml.matchAll(
|
||||
/<Relationship[^>]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g,
|
||||
)) {
|
||||
const target = match[2].replace(/^\/+/, '');
|
||||
relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`);
|
||||
}
|
||||
|
||||
const sharedStrings = await this.parseSharedStringsFallback(readEntry);
|
||||
const sheets: ExcelSheetRows[] = [];
|
||||
const cleanWorkbook = stripPrefixes(workbookXml);
|
||||
for (const match of cleanWorkbook.matchAll(/<sheet\b[^>]*\/?>/g)) {
|
||||
const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');
|
||||
const name = tag.match(/\bname="([^"]+)"/)?.[1];
|
||||
const rid = tag.match(/\br:id="([^"]+)"/)?.[1];
|
||||
if (!name || !rid) continue;
|
||||
const target = relTargets.get(rid);
|
||||
const sheetXml = target ? await readEntry(target) : null;
|
||||
if (!sheetXml) continue;
|
||||
sheets.push({
|
||||
name: this.unescapeXml(name),
|
||||
rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings),
|
||||
});
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
|
||||
private async parseSharedStringsFallback(
|
||||
readEntry: (name: string) => Promise<string | null>,
|
||||
): Promise<string[]> {
|
||||
const xml = await readEntry('xl/sharedStrings.xml');
|
||||
if (!xml) return [];
|
||||
const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
const strings: string[] = [];
|
||||
for (const match of clean.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
|
||||
const texts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
||||
this.unescapeXml(part[1]),
|
||||
);
|
||||
strings.push(texts.join(''));
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
|
||||
const cells = new Map<number, string>();
|
||||
let maxColumn = -1;
|
||||
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {
|
||||
const attrs = cellMatch[1];
|
||||
const refMatch = attrs.match(/\br="([A-Z]+)\d+"/);
|
||||
const column = refMatch ? this.columnIndex(refMatch[1]) : -1;
|
||||
const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n';
|
||||
const body = cellMatch[2] ?? '';
|
||||
let value = '';
|
||||
if (type === 's') {
|
||||
const index = Number(body.match(/<v>([^<]*)<\/v>/)?.[1] ?? '');
|
||||
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
||||
} else if (type === 'inlineStr') {
|
||||
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
||||
this.unescapeXml(part[1]),
|
||||
);
|
||||
value = texts.join('');
|
||||
} else {
|
||||
value = this.unescapeXml(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '');
|
||||
if (type === 'b') value = value === '1' ? 'true' : 'false';
|
||||
}
|
||||
if (column >= 0) {
|
||||
cells.set(column, value);
|
||||
maxColumn = Math.max(maxColumn, column);
|
||||
}
|
||||
}
|
||||
if (maxColumn < 0) continue;
|
||||
const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? '');
|
||||
if (values.every((value) => value === '')) continue;
|
||||
rows.push(values);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private columnIndex(letters: string): number {
|
||||
let index = 0;
|
||||
for (const char of letters.toUpperCase()) {
|
||||
index = index * 26 + (char.charCodeAt(0) - 64);
|
||||
}
|
||||
return index - 1;
|
||||
}
|
||||
|
||||
private unescapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
|
||||
String.fromCodePoint(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec)));
|
||||
}
|
||||
|
||||
private stringifyCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
150
apps/server/src/ai-chat/ai-form.service.spec.ts
Normal file
150
apps/server/src/ai-chat/ai-form.service.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { AiFormService } from './ai-form.service';
|
||||
|
||||
function createService(overrides: Record<string, unknown> = {}) {
|
||||
const forms = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(async (value) => value),
|
||||
create: jest.fn((value) => value),
|
||||
...overrides,
|
||||
};
|
||||
const service = new AiFormService(forms as never);
|
||||
return { service, forms };
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
userId: 7,
|
||||
conversationId: 3,
|
||||
assistantMessageId: 12,
|
||||
};
|
||||
|
||||
const validSchema = {
|
||||
title: '新增学生',
|
||||
description: '填写学生基本信息',
|
||||
submitLabel: '确认新增',
|
||||
fields: [
|
||||
{ name: 'name', label: '姓名', type: 'input', required: true },
|
||||
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: '男' }, { label: '女', value: '女' }] },
|
||||
{ name: 'age', label: '年龄', type: 'number' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('AiFormService', () => {
|
||||
describe('createForm', () => {
|
||||
it('校验通过的 schema 落库并保留完整字段', async () => {
|
||||
const { service, forms } = createService();
|
||||
const form = await service.createForm(baseArgs, validSchema);
|
||||
expect(forms.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: 3,
|
||||
assistantMessageId: 12,
|
||||
title: '新增学生',
|
||||
submitLabel: '确认新增',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
expect(form.id).toBeTruthy();
|
||||
const fields = JSON.parse(form.fieldsJson) as unknown[];
|
||||
expect(fields).toHaveLength(3);
|
||||
expect(fields[1]).toEqual({
|
||||
name: 'gender',
|
||||
label: '性别',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [{ label: '男', value: '男' }, { label: '女', value: '女' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('默认提交按钮文案为「提交」', async () => {
|
||||
const { service, forms } = createService();
|
||||
const { submitLabel, ...rest } = validSchema;
|
||||
await service.createForm(baseArgs, rest);
|
||||
expect(forms.create).toHaveBeenCalledWith(expect.objectContaining({ submitLabel: '提交' }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['标题缺失', { fields: validSchema.fields }, '表单标题'],
|
||||
['字段为空', { ...validSchema, fields: [] }, '至少需要一个字段'],
|
||||
['字段过多', { ...validSchema, fields: Array.from({ length: 13 }, (_, i) => ({ name: `f${i}`, label: `字段${i}`, type: 'input' })) }, '不能超过'],
|
||||
['类型非法', { ...validSchema, fields: [{ name: 'x', label: 'X', type: 'checkbox' }] }, '类型不支持'],
|
||||
['字段名非法', { ...validSchema, fields: [{ name: '姓 名', label: 'X', type: 'input' }] }, '只能包含'],
|
||||
['字段名重复', { ...validSchema, fields: [{ name: 'x', label: 'A', type: 'input' }, { name: 'x', label: 'B', type: 'input' }] }, '字段名重复'],
|
||||
['select 缺选项', { ...validSchema, fields: [{ name: 's', label: 'S', type: 'select' }] }, '选项数量'],
|
||||
['未知字段', { ...validSchema, extra: 1 }, '未知字段'],
|
||||
])('非法 schema 被拒绝:%s', async (_name, schema, messagePart) => {
|
||||
const { service } = createService();
|
||||
await expect(service.createForm(baseArgs, schema)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.createForm(baseArgs, schema)).rejects.toThrow(messagePart);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOwnedPending', () => {
|
||||
it('只返回本人 pending 表单', async () => {
|
||||
const form = { id: 'form-1', userId: 7, status: 'pending' };
|
||||
const { service, forms } = createService({ findOne: jest.fn().mockResolvedValue(form) });
|
||||
await expect(service.findOwnedPending('form-1', 7)).resolves.toBe(form);
|
||||
expect(forms.findOne).toHaveBeenCalledWith({ where: { id: 'form-1', userId: 7, status: 'pending' } });
|
||||
});
|
||||
|
||||
it('已提交或不存在时抛 NotFound', async () => {
|
||||
const { service } = createService({ findOne: jest.fn().mockResolvedValue(null) });
|
||||
await expect(service.findOwnedPending('form-1', 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateValues', () => {
|
||||
const form = {
|
||||
fieldsJson: JSON.stringify(validSchema.fields),
|
||||
} as never;
|
||||
|
||||
it('通过合法值并丢弃空的可选字段', () => {
|
||||
const { service } = createService();
|
||||
const values = service.validateValues(form, { name: '张三', age: 18 });
|
||||
expect(values).toEqual({ name: '张三', age: 18 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['必填缺失', { age: 18 }, '「姓名」为必填项'],
|
||||
['未知字段', { name: '张三', hacker: 1 }, '未知字段'],
|
||||
['数字类型错误', { name: '张三', age: '18' }, '必须是数字'],
|
||||
['日期格式错误', { name: '张三', birthday: '18-01-2026' }, 'YYYY-MM-DD'],
|
||||
['选项越界', { name: '张三', gender: '未知' }, '选项无效'],
|
||||
])('非法值被拒绝:%s', async (_name, values, messagePart) => {
|
||||
const { service } = createService();
|
||||
const formWithDate = { fieldsJson: JSON.stringify([
|
||||
...validSchema.fields,
|
||||
{ name: 'birthday', label: '生日', type: 'date' },
|
||||
]) } as never;
|
||||
await expect(() => service.validateValues(formWithDate, values)).toThrow(messagePart);
|
||||
});
|
||||
|
||||
it('非对象提交被拒绝', () => {
|
||||
const { service } = createService();
|
||||
expect(() => service.validateValues(form, 'hacker')).toThrow(BadRequestException);
|
||||
expect(() => service.validateValues(form, ['hacker'])).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('回传前端所需结构', () => {
|
||||
const { service } = createService();
|
||||
const serialized = service.serialize({
|
||||
id: 'form-1',
|
||||
title: '新增学生',
|
||||
description: null,
|
||||
submitLabel: '提交',
|
||||
fieldsJson: JSON.stringify(validSchema.fields),
|
||||
status: 'submitted',
|
||||
} as never);
|
||||
expect(serialized).toEqual({
|
||||
id: 'form-1',
|
||||
title: '新增学生',
|
||||
description: null,
|
||||
submitLabel: '提交',
|
||||
fields: validSchema.fields,
|
||||
status: 'submitted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
290
apps/server/src/ai-chat/ai-form.service.ts
Normal file
290
apps/server/src/ai-chat/ai-form.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
import { AiForm, type AiFormField } from './entities/ai-form.entity';
|
||||
|
||||
export const A2UI_FIELD_TYPES = ['input', 'textarea', 'number', 'select', 'date'] as const;
|
||||
|
||||
const MAX_TITLE = 50;
|
||||
const MAX_DESCRIPTION = 200;
|
||||
const MAX_SUBMIT_LABEL = 20;
|
||||
const MAX_FIELDS = 12;
|
||||
const MAX_NAME = 50;
|
||||
const MAX_LABEL = 50;
|
||||
const MAX_PLACEHOLDER = 100;
|
||||
const MAX_DEFAULT = 200;
|
||||
const MAX_OPTIONS = 20;
|
||||
const MAX_OPTION_TEXT = 50;
|
||||
const MAX_VALUE_LENGTH = 200;
|
||||
const MAX_VALUES_BYTES = 64 * 1024;
|
||||
|
||||
const FIELD_NAME_RE = /^[a-zA-Z0-9_]{1,50}$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const FIELD_KEYS = new Set([
|
||||
'name',
|
||||
'label',
|
||||
'type',
|
||||
'required',
|
||||
'placeholder',
|
||||
'defaultValue',
|
||||
'options',
|
||||
]);
|
||||
const SCHEMA_KEYS = new Set(['title', 'description', 'submitLabel', 'fields']);
|
||||
|
||||
interface ValidatedFormSchema {
|
||||
title: string;
|
||||
description: string | null;
|
||||
submitLabel: string;
|
||||
fields: AiFormField[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isShortString(value: unknown, max: number): value is string {
|
||||
return typeof value === 'string' && value.length <= max;
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
label: string,
|
||||
max: number,
|
||||
optional = false,
|
||||
): string {
|
||||
if (value === undefined || value === null) {
|
||||
if (optional) return '';
|
||||
throw new BadRequestException(`${label}不能为空`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side A2UI form lifecycle:
|
||||
* schema validation + persistence, owned lookup, submitted-value
|
||||
* validation, and serialization for SSE / message metadata.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiFormService {
|
||||
constructor(
|
||||
@InjectRepository(AiForm)
|
||||
private readonly forms: Repository<AiForm>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate `render_form` arguments and persist a pending form.
|
||||
* Throws BadRequestException when the schema is unsafe/invalid.
|
||||
*/
|
||||
async createForm(
|
||||
input: { userId: number; conversationId: number; assistantMessageId: number },
|
||||
rawArgs: unknown,
|
||||
): Promise<AiForm> {
|
||||
const schema = this.validateSchema(rawArgs);
|
||||
return this.forms.save(
|
||||
this.forms.create({
|
||||
id: uuidV7(),
|
||||
userId: input.userId,
|
||||
conversationId: input.conversationId,
|
||||
assistantMessageId: input.assistantMessageId,
|
||||
title: schema.title,
|
||||
description: schema.description,
|
||||
submitLabel: schema.submitLabel,
|
||||
fieldsJson: JSON.stringify(schema.fields),
|
||||
status: 'pending',
|
||||
submittedValuesJson: null,
|
||||
submittedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findOwnedPending(formId: string, userId: number): Promise<AiForm> {
|
||||
const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } });
|
||||
if (!form) throw new NotFoundException('表单不存在或已提交');
|
||||
return form;
|
||||
}
|
||||
|
||||
async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
|
||||
form.status = 'submitted';
|
||||
form.submittedValuesJson = JSON.stringify(values);
|
||||
form.submittedAt = new Date();
|
||||
return this.forms.save(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted values against the stored schema.
|
||||
* Returns a sanitized record containing only known field names.
|
||||
* Throws BadRequestException on invalid input.
|
||||
*/
|
||||
validateValues(form: AiForm, rawValues: unknown): Record<string, unknown> {
|
||||
if (!isPlainRecord(rawValues)) throw new BadRequestException('提交内容格式无效');
|
||||
|
||||
const fields = this.parseFields(form.fieldsJson);
|
||||
const known = new Set(fields.map((field) => field.name));
|
||||
for (const key of Object.keys(rawValues)) {
|
||||
if (!known.has(key)) throw new BadRequestException(`包含未知字段: ${key}`);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = rawValues[field.name];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (field.required) throw new BadRequestException(`「${field.label}」为必填项`);
|
||||
continue;
|
||||
}
|
||||
result[field.name] = this.normalizeValue(field, value);
|
||||
}
|
||||
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(result);
|
||||
} catch {
|
||||
throw new BadRequestException('提交内容无法序列化');
|
||||
}
|
||||
if (serialized.length > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Public shape sent via `ui.form` SSE and mirrored into message metadata. */
|
||||
serialize(form: AiForm): Record<string, unknown> {
|
||||
return {
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
submitLabel: form.submitLabel,
|
||||
fields: this.parseFields(form.fieldsJson),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
parseFields(fieldsJson: string): AiFormField[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(fieldsJson);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item): item is AiFormField => isPlainRecord(item));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private validateSchema(rawArgs: unknown): ValidatedFormSchema {
|
||||
if (!isPlainRecord(rawArgs)) throw new BadRequestException('表单参数必须是对象');
|
||||
for (const key of Object.keys(rawArgs)) {
|
||||
if (!SCHEMA_KEYS.has(key)) throw new BadRequestException(`表单包含未知字段: ${key}`);
|
||||
}
|
||||
|
||||
const title = requireString(rawArgs.title, '表单标题', MAX_TITLE);
|
||||
const description = requireString(rawArgs.description, '表单说明', MAX_DESCRIPTION, true) || null;
|
||||
const submitLabel = requireString(rawArgs.submitLabel, '提交按钮文案', MAX_SUBMIT_LABEL, true);
|
||||
|
||||
if (!Array.isArray(rawArgs.fields) || rawArgs.fields.length === 0) {
|
||||
throw new BadRequestException('表单至少需要一个字段');
|
||||
}
|
||||
if (rawArgs.fields.length > MAX_FIELDS) {
|
||||
throw new BadRequestException(`表单字段不能超过 ${MAX_FIELDS} 个`);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fields = rawArgs.fields.map((item, index) => this.validateField(item, index, seen));
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
submitLabel: submitLabel || '提交',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
private validateField(raw: unknown, index: number, seen: Set<string>): AiFormField {
|
||||
if (!isPlainRecord(raw)) throw new BadRequestException(`第 ${index + 1} 个字段格式无效`);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!FIELD_KEYS.has(key)) throw new BadRequestException(`字段包含未知属性: ${key}`);
|
||||
}
|
||||
|
||||
const name = requireString(raw.name, '字段名', MAX_NAME);
|
||||
if (!FIELD_NAME_RE.test(name)) {
|
||||
throw new BadRequestException(`字段名 ${name} 只能包含字母、数字、下划线`);
|
||||
}
|
||||
if (seen.has(name)) throw new BadRequestException(`字段名重复: ${name}`);
|
||||
seen.add(name);
|
||||
|
||||
const label = requireString(raw.label, '字段标签', MAX_LABEL);
|
||||
const type = raw.type;
|
||||
if (typeof type !== 'string' || !(A2UI_FIELD_TYPES as readonly string[]).includes(type)) {
|
||||
throw new BadRequestException(`字段 ${name} 的类型不支持`);
|
||||
}
|
||||
const fieldType = type as AiFormField['type'];
|
||||
|
||||
if (raw.required !== undefined && typeof raw.required !== 'boolean') {
|
||||
throw new BadRequestException(`字段 ${name} 的 required 必须是布尔值`);
|
||||
}
|
||||
const placeholder = requireString(raw.placeholder, `字段 ${name} 的 placeholder`, MAX_PLACEHOLDER, true);
|
||||
let defaultValue: string | number | undefined;
|
||||
if (raw.defaultValue !== undefined && raw.defaultValue !== null) {
|
||||
if (typeof raw.defaultValue === 'number') {
|
||||
if (!Number.isFinite(raw.defaultValue)) {
|
||||
throw new BadRequestException(`字段 ${name} 的 defaultValue 必须是有限数字`);
|
||||
}
|
||||
defaultValue = raw.defaultValue;
|
||||
} else if (isShortString(raw.defaultValue, MAX_DEFAULT)) {
|
||||
defaultValue = raw.defaultValue;
|
||||
} else {
|
||||
throw new BadRequestException(`字段 ${name} 的 defaultValue 无效`);
|
||||
}
|
||||
}
|
||||
|
||||
let options: Array<{ label: string; value: string }> | undefined;
|
||||
if (fieldType === 'select') {
|
||||
if (!Array.isArray(raw.options) || raw.options.length === 0 || raw.options.length > MAX_OPTIONS) {
|
||||
throw new BadRequestException(`字段 ${name} 的 select 选项数量必须在 1 到 ${MAX_OPTIONS} 之间`);
|
||||
}
|
||||
options = raw.options.map((option, optionIndex) => {
|
||||
if (!isPlainRecord(option)) {
|
||||
throw new BadRequestException(`字段 ${name} 第 ${optionIndex + 1} 个选项格式无效`);
|
||||
}
|
||||
const optionLabel = requireString(option.label, `字段 ${name} 的选项标签`, MAX_OPTION_TEXT);
|
||||
const optionValue = requireString(option.value, `字段 ${name} 的选项值`, MAX_OPTION_TEXT);
|
||||
return { label: optionLabel, value: optionValue };
|
||||
});
|
||||
} else if (raw.options !== undefined) {
|
||||
throw new BadRequestException(`字段 ${name} 只有 select 类型可以带 options`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: fieldType,
|
||||
required: raw.required === true,
|
||||
placeholder: placeholder || undefined,
|
||||
defaultValue,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeValue(field: AiFormField, value: unknown): unknown {
|
||||
if (field.type === 'number') {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestException(`「${field.label}」必须是数字`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== 'string' || value.length > MAX_VALUE_LENGTH) {
|
||||
throw new BadRequestException(`「${field.label}」格式无效`);
|
||||
}
|
||||
if (field.type === 'date' && !DATE_RE.test(value)) {
|
||||
throw new BadRequestException(`「${field.label}」必须是 YYYY-MM-DD 格式`);
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
const valid = field.options?.some((option) => option.value === value);
|
||||
if (!valid) throw new BadRequestException(`「${field.label}」选项无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const config: AiRuntimeConfig = {
|
||||
defaultModel: 'deepseek-reasoner',
|
||||
timeoutMs: 1000,
|
||||
enabled: true,
|
||||
supportsVision: false,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
|
||||
describe('AiModelStreamService', () => {
|
||||
@@ -55,6 +57,7 @@ describe('AiModelStreamService', () => {
|
||||
contentType: 'text/plain',
|
||||
body: body(),
|
||||
} as never);
|
||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
||||
const consume = async () => {
|
||||
for await (const _ of service.stream(
|
||||
config,
|
||||
@@ -65,4 +68,97 @@ describe('AiModelStreamService', () => {
|
||||
};
|
||||
await expect(consume()).rejects.toThrow('AI 服务暂时不可用');
|
||||
});
|
||||
|
||||
it('上游 503 时自动重试并发出重试事件', async () => {
|
||||
async function* successBody() {
|
||||
yield Buffer.from('data: [DONE]\n\n');
|
||||
}
|
||||
const service = new AiModelStreamService();
|
||||
let calls = 0;
|
||||
jest.spyOn(service as never, 'pinnedPost' as never).mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'text/plain',
|
||||
body: { resume: jest.fn() },
|
||||
} as never;
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body: successBody(),
|
||||
} as never;
|
||||
});
|
||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
||||
const events: Array<{ type: string; attempt?: number; maxRetries?: number; reason?: string }> = [];
|
||||
for await (const event of service.stream(
|
||||
config,
|
||||
[{ role: 'user', content: '查询' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(calls).toBe(2);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: 'retrying', attempt: 1, maxRetries: 3, reason: '上游返回 503' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('上游 503 时提示服务繁忙', async () => {
|
||||
async function* body() {
|
||||
yield Buffer.from('{"error":{"message":"Service is too busy"}}');
|
||||
}
|
||||
const service = new AiModelStreamService();
|
||||
jest.spyOn(service as never, 'pinnedPost' as never).mockResolvedValue({
|
||||
status: 503,
|
||||
contentType: 'text/plain',
|
||||
body: body(),
|
||||
} as never);
|
||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
||||
const consume = async () => {
|
||||
for await (const _ of service.stream(
|
||||
config,
|
||||
[{ role: 'user', content: '查询' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) void _;
|
||||
};
|
||||
await expect(consume()).rejects.toThrow('AI 服务繁忙,请稍后重试');
|
||||
});
|
||||
|
||||
it('配置 reasoningEffort 时仅对非 DeepSeek 服务商发送该参数', async () => {
|
||||
const bodies: string[] = [];
|
||||
async function* body() {
|
||||
yield Buffer.from('data: [DONE]\n\n');
|
||||
}
|
||||
const service = new AiModelStreamService();
|
||||
jest.spyOn(service as never, 'pinnedPost' as never).mockImplementation(
|
||||
async (_url: string, _headers: Record<string, string>, payload: string) => {
|
||||
bodies.push(payload);
|
||||
return { status: 200, contentType: 'text/event-stream', body: body() } as never;
|
||||
},
|
||||
);
|
||||
|
||||
for await (const _ of service.stream(
|
||||
{ ...config, reasoningEffort: 'high' },
|
||||
[{ role: 'user', content: 'x' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) void _;
|
||||
expect(JSON.parse(bodies[0])).not.toHaveProperty('reasoning_effort');
|
||||
|
||||
for await (const _ of service.stream(
|
||||
{
|
||||
...config,
|
||||
provider: 'OPENAI' as AiRuntimeConfig['provider'],
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
[{ role: 'user', content: 'x' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) void _;
|
||||
expect(JSON.parse(bodies[1]).reasoning_effort).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
|
||||
import { AiProvider } from '../ai-config/ai-config.entity';
|
||||
import type { ModelMessage, ModelStreamEvent } from './ai-chat.types';
|
||||
|
||||
interface ChatTool {
|
||||
@@ -26,6 +27,17 @@ interface StreamChoiceDelta {
|
||||
}
|
||||
|
||||
const MAX_UPSTREAM_EVENT_BYTES = 1024 * 1024;
|
||||
const MAX_UPSTREAM_RETRIES = 3;
|
||||
const UPSTREAM_RETRY_DELAYS_MS = [500, 1000, 2000];
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
const RETRYABLE_TRANSPORT_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'EPIPE',
|
||||
]);
|
||||
|
||||
// Known public provider hosts — trusted even if CDN resolves to private-range IPs
|
||||
const DNS_TRUSTED_HOSTS = new Set(['api.openai.com', 'api.deepseek.com']);
|
||||
@@ -54,31 +66,76 @@ export class AiModelStreamService {
|
||||
tools: ChatTool[],
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ModelStreamEvent> {
|
||||
const timeout = AbortSignal.timeout(config.timeoutMs);
|
||||
const combinedSignal = AbortSignal.any([signal, timeout]);
|
||||
let response: PinnedResponse;
|
||||
const requestBody = JSON.stringify({
|
||||
model: config.defaultModel,
|
||||
messages,
|
||||
stream: true,
|
||||
...(tools.length ? { tools, tool_choice: 'auto' } : {}),
|
||||
// reasoning_effort 仅对支持该参数的 OpenAI 兼容服务生效;
|
||||
// DeepSeek 官方接口不接受该参数,避免请求被拒。
|
||||
...(config.reasoningEffort &&
|
||||
config.reasoningEffort !== 'none' &&
|
||||
config.provider !== AiProvider.DEEPSEEK
|
||||
? { reasoning_effort: config.reasoningEffort }
|
||||
: {}),
|
||||
});
|
||||
const url = `${config.baseUrl.replace(/\/$/, '')}/chat/completions`;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
};
|
||||
let response: PinnedResponse | null = null;
|
||||
let activeTimeout: AbortSignal | undefined;
|
||||
|
||||
try {
|
||||
response = await this.pinnedPost(
|
||||
`${config.baseUrl.replace(/\/$/, '')}/chat/completions`,
|
||||
{
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
JSON.stringify({
|
||||
model: config.defaultModel,
|
||||
messages,
|
||||
stream: true,
|
||||
...(tools.length ? { tools, tool_choice: 'auto' } : {}),
|
||||
}),
|
||||
combinedSignal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (timeout.aborted && !signal.aborted) throw new RequestTimeoutException('AI 服务响应超时');
|
||||
throw error;
|
||||
for (let attempt = 0; attempt <= MAX_UPSTREAM_RETRIES; attempt += 1) {
|
||||
activeTimeout = AbortSignal.timeout(config.timeoutMs);
|
||||
const combinedSignal = AbortSignal.any([signal, activeTimeout]);
|
||||
try {
|
||||
response = await this.pinnedPost(url, headers, requestBody, combinedSignal);
|
||||
} catch (error) {
|
||||
if (activeTimeout.aborted && !signal.aborted) {
|
||||
throw new RequestTimeoutException('AI 服务响应超时');
|
||||
}
|
||||
if (
|
||||
attempt < MAX_UPSTREAM_RETRIES &&
|
||||
!signal.aborted &&
|
||||
this.isRetryableTransportError(error)
|
||||
) {
|
||||
const delayMs = UPSTREAM_RETRY_DELAYS_MS[attempt];
|
||||
yield {
|
||||
type: 'retrying',
|
||||
attempt: attempt + 1,
|
||||
maxRetries: MAX_UPSTREAM_RETRIES,
|
||||
delayMs,
|
||||
reason: error instanceof Error ? error.message : '网络连接失败',
|
||||
};
|
||||
await this.sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status >= 200 && response.status < 300) break;
|
||||
if (attempt < MAX_UPSTREAM_RETRIES && RETRYABLE_STATUS_CODES.has(response.status)) {
|
||||
response.body.resume?.();
|
||||
const delayMs = UPSTREAM_RETRY_DELAYS_MS[attempt];
|
||||
yield {
|
||||
type: 'retrying',
|
||||
attempt: attempt + 1,
|
||||
maxRetries: MAX_UPSTREAM_RETRIES,
|
||||
delayMs,
|
||||
reason: `上游返回 ${response.status}`,
|
||||
};
|
||||
await this.sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
const body = await this.readLimitedBody(response.body);
|
||||
throw new BadGatewayException(this.safeUpstreamMessage(response.status, body));
|
||||
}
|
||||
|
||||
if (!response) throw new BadGatewayException('AI 服务暂时不可用');
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const body = await this.readLimitedBody(response.body);
|
||||
throw new BadGatewayException(this.safeUpstreamMessage(response.status, body));
|
||||
@@ -128,7 +185,9 @@ export class AiModelStreamService {
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) for (const parsed of consumeEvent(buffer)) yield parsed;
|
||||
} catch (error) {
|
||||
if (timeout.aborted && !signal.aborted) throw new RequestTimeoutException('AI 服务响应超时');
|
||||
if (activeTimeout?.aborted && !signal.aborted) {
|
||||
throw new RequestTimeoutException('AI 服务响应超时');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -154,9 +213,21 @@ export class AiModelStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
private isRetryableTransportError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && RETRYABLE_TRANSPORT_CODES.has(code)) return true;
|
||||
return /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(error.message);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private safeUpstreamMessage(status: number, body: string): string {
|
||||
if (status === 401 || status === 403) return 'AI 服务认证失败';
|
||||
if (status === 429) return 'AI 服务请求过于频繁';
|
||||
if (status === 503) return 'AI 服务繁忙,请稍后重试';
|
||||
if (status >= 500) return 'AI 服务暂时不可用';
|
||||
const message = this.extractErrorMessage(body);
|
||||
return message ? `AI 服务请求失败:${message}` : `AI 服务请求失败(${status})`;
|
||||
|
||||
63
apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts
Normal file
63
apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AddA2UiReviews1784880000000 } from '../migrations/1784880000000-AddA2UiReviews';
|
||||
import { EnlargeAiReviewSections1784900000000 } from '../migrations/1784900000000-EnlargeAiReviewSections';
|
||||
|
||||
describe('EnlargeAiReviewSections1784900000000', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
migrations: [AddA2UiReviews1784880000000, EnlargeAiReviewSections1784900000000],
|
||||
});
|
||||
await dataSource.initialize();
|
||||
await dataSource.query(`
|
||||
CREATE TABLE ai_messages (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id integer NOT NULL,
|
||||
role varchar(20) NOT NULL,
|
||||
content text,
|
||||
reasoning_content text,
|
||||
status varchar(20) NOT NULL,
|
||||
error_code varchar(50),
|
||||
reply_to_message_id integer,
|
||||
feedback varchar(10),
|
||||
feedback_reason varchar(500),
|
||||
metadata text,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dataSource.isInitialized) await dataSource.destroy();
|
||||
});
|
||||
|
||||
it('迁移后可保存远超 256KB 的预览数据,且再次执行幂等', async () => {
|
||||
await dataSource.runMigrations();
|
||||
await dataSource.runMigrations();
|
||||
|
||||
await dataSource.query(
|
||||
`INSERT INTO ai_messages (conversation_id, role, content, status)
|
||||
VALUES (1, 'assistant', '', 'completed')`,
|
||||
);
|
||||
const big = '中'.repeat(300 * 1024);
|
||||
await dataSource.query(
|
||||
`INSERT INTO ai_reviews
|
||||
(id, conversation_id, user_id, assistant_message_id, title, sections_json, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
['review-1', 1, 1, 1, '大体积导入', big, 'pending'],
|
||||
);
|
||||
const rows: Array<{ sections_json: string }> = await dataSource.query(
|
||||
'SELECT sections_json FROM ai_reviews WHERE id = ?',
|
||||
['review-1'],
|
||||
);
|
||||
expect(rows[0].sections_json.length).toBe(big.length);
|
||||
|
||||
const runner = dataSource.createQueryRunner();
|
||||
expect(await runner.hasColumn('ai_reviews', 'sections_json')).toBe(true);
|
||||
await runner.release();
|
||||
});
|
||||
});
|
||||
1310
apps/server/src/ai-chat/ai-review.service.spec.ts
Normal file
1310
apps/server/src/ai-chat/ai-review.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
1838
apps/server/src/ai-chat/ai-review.service.ts
Normal file
1838
apps/server/src/ai-chat/ai-review.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsOptional()
|
||||
@@ -58,11 +60,40 @@ export class SendMessageDto {
|
||||
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class RegenerateMessageDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class SubmitFormDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsObject()
|
||||
values: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class SubmitReviewDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class MessageFeedbackDto {
|
||||
|
||||
79
apps/server/src/ai-chat/entities/ai-form.entity.ts
Normal file
79
apps/server/src/ai-chat/entities/ai-form.entity.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AiMessage } from './ai-message.entity';
|
||||
|
||||
export type AiFormStatus = 'pending' | 'submitted';
|
||||
|
||||
export interface AiFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'input' | 'textarea' | 'number' | 'select' | 'date';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI dynamic form rendered inside an AI assistant message.
|
||||
*
|
||||
* The schema is validated server-side before persistence; submitted
|
||||
* values are validated again at submit time. Full schema copy is also
|
||||
* mirrored into the assistant message metadata (`a2uiForm`) so history
|
||||
* can render the form without a join.
|
||||
*/
|
||||
@Entity('ai_forms')
|
||||
@Index('idx_ai_forms_message', ['assistantMessageId'])
|
||||
@Index('idx_ai_forms_user_status', ['userId', 'status'])
|
||||
export class AiForm {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'integer' })
|
||||
conversationId: number;
|
||||
|
||||
@Column({ name: 'user_id', type: 'integer' })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: 'assistant_message_id', type: 'integer' })
|
||||
assistantMessageId: number;
|
||||
|
||||
@ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'assistant_message_id' })
|
||||
assistantMessage: AiMessage | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'submit_label', type: 'varchar', length: 20, default: '提交' })
|
||||
submitLabel: string;
|
||||
|
||||
@Column({ name: 'fields_json', type: 'text' })
|
||||
fieldsJson: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: AiFormStatus;
|
||||
|
||||
@Column({ name: 'submitted_values_json', type: 'text', nullable: true })
|
||||
submittedValuesJson: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'datetime', nullable: true })
|
||||
submittedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
95
apps/server/src/ai-chat/entities/ai-review.entity.ts
Normal file
95
apps/server/src/ai-chat/entities/ai-review.entity.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AiMessage } from './ai-message.entity';
|
||||
|
||||
export type AiReviewStatus = 'pending' | 'submitted' | 'expired';
|
||||
export type AiReviewSectionStatus = 'pending' | 'submitted' | 'failed' | 'skipped';
|
||||
export type AiReviewSectionType = 'students' | 'rooms' | 'transfers' | 'checkins';
|
||||
|
||||
export interface AiReviewColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AiReviewRow {
|
||||
[key: string]: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface AiReviewSection {
|
||||
/** 唯一实例 ID(同一业务类型可有多张 sheet,每个 key 唯一) */
|
||||
key: string;
|
||||
/** 业务类型:students 学生 / rooms 宿舍 / transfers 换宿 / checkins 入住记录 */
|
||||
type: AiReviewSectionType;
|
||||
title: string;
|
||||
kind: 'table';
|
||||
/** 来源工作表名(可选) */
|
||||
sheet?: string;
|
||||
columns: AiReviewColumn[];
|
||||
rows: AiReviewRow[];
|
||||
issues: string[];
|
||||
status?: AiReviewSectionStatus;
|
||||
resultSummary?: string | null;
|
||||
submittedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI batch-import review rendered inside an AI assistant message.
|
||||
*
|
||||
* Holds the parsed & validated Excel rows grouped by business type; the same
|
||||
* type may appear in multiple sheets, each with a unique instance key. The
|
||||
* user reviews and confirms each sheet independently, or by type group, or all
|
||||
* at once. Sheet imports run in dependency order (students → rooms →
|
||||
* transfers → checkins), each in its own transaction.
|
||||
*/
|
||||
@Entity('ai_reviews')
|
||||
@Index('idx_ai_reviews_message', ['assistantMessageId'])
|
||||
@Index('idx_ai_reviews_user_status', ['userId', 'status'])
|
||||
export class AiReview {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'integer' })
|
||||
conversationId: number;
|
||||
|
||||
@Column({ name: 'user_id', type: 'integer' })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: 'assistant_message_id', type: 'integer' })
|
||||
assistantMessageId: number;
|
||||
|
||||
@ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'assistant_message_id' })
|
||||
assistantMessage: AiMessage | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
summary: string | null;
|
||||
|
||||
@Column({ name: 'sections_json', type: 'text' })
|
||||
sectionsJson: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: AiReviewStatus;
|
||||
|
||||
@Column({ name: 'result_summary', type: 'text', nullable: true })
|
||||
resultSummary: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'datetime', nullable: true })
|
||||
submittedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from './ai-conversation.entity';
|
||||
export * from './ai-message.entity';
|
||||
export * from './ai-tool-run.entity';
|
||||
export * from './ai-attachment.entity';
|
||||
export * from './ai-form.entity';
|
||||
export * from './ai-review.entity';
|
||||
|
||||
19
apps/server/src/ai-chat/office-cli.service.spec.ts
Normal file
19
apps/server/src/ai-chat/office-cli.service.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
|
||||
describe('OfficeCliService', () => {
|
||||
it('prefers the npm-bundled binary when @officecli/officecli is installed', async () => {
|
||||
const service = new OfficeCliService();
|
||||
const resolveBinary = (
|
||||
service as unknown as { resolveBinary(): Promise<string> }
|
||||
).resolveBinary.bind(service);
|
||||
const resolved = await resolveBinary();
|
||||
expect(resolved).toContain('@officecli/officecli');
|
||||
});
|
||||
|
||||
it('returns structured results from a real view call', async () => {
|
||||
const service = new OfficeCliService();
|
||||
const result = await service.view(process.execPath, 'outline');
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
});
|
||||
});
|
||||
114
apps/server/src/ai-chat/office-cli.service.ts
Normal file
114
apps/server/src/ai-chat/office-cli.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface OfficeCliResult {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around the OfficeCli binary
|
||||
* (https://github.com/iOfficeAI/OfficeCli) used by the AI chat to
|
||||
* analyze uploaded Office documents (.xlsx / .docx / .pptx) on demand.
|
||||
* Arguments are passed as an argv array (no shell), with a hard timeout
|
||||
* and a generous output cap.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OfficeCliService {
|
||||
private resolvedBinary: string | null = null;
|
||||
|
||||
async run(
|
||||
args: string[],
|
||||
options: { timeoutMs?: number; maxBuffer?: number } = {},
|
||||
): Promise<OfficeCliResult> {
|
||||
const binary = await this.resolveBinary();
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binary, args, {
|
||||
timeout: options.timeoutMs ?? 60_000,
|
||||
maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
|
||||
});
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(stdout);
|
||||
if (parsed && typeof parsed === 'object' && 'success' in parsed) {
|
||||
return parsed as OfficeCliResult;
|
||||
}
|
||||
return { success: true, data: parsed };
|
||||
} catch {
|
||||
return { success: false, error: 'OfficeCli 输出解析失败' };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `OfficeCli 执行失败:${message.slice(0, 200)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async view(
|
||||
filePath: string,
|
||||
mode: 'stats' | 'outline' | 'text' | 'issues',
|
||||
extra: string[] = [],
|
||||
): Promise<OfficeCliResult> {
|
||||
return this.run(['view', filePath, mode, '--json', ...extra]);
|
||||
}
|
||||
|
||||
async get(filePath: string, path: string, depth?: number): Promise<OfficeCliResult> {
|
||||
return this.run([
|
||||
'get',
|
||||
filePath,
|
||||
path,
|
||||
'--json',
|
||||
...(depth === undefined ? [] : ['--depth', String(depth)]),
|
||||
]);
|
||||
}
|
||||
|
||||
async query(filePath: string, selector: string): Promise<OfficeCliResult> {
|
||||
return this.run(['query', filePath, selector, '--json']);
|
||||
}
|
||||
|
||||
private async resolveBinary(): Promise<string> {
|
||||
if (this.resolvedBinary) return this.resolvedBinary;
|
||||
const candidates = [process.env.OFFICECLI_BIN, this.bundledBinary()].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await execFileAsync(candidate, ['--version'], { timeout: 5000 });
|
||||
this.resolvedBinary = candidate;
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
throw new ServiceUnavailableException(
|
||||
'OfficeCli 未安装:请运行 npm install(@officecli/officecli),或通过 OFFICECLI_BIN 指定二进制路径',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the `@officecli/officecli` npm package (binary fetched by its
|
||||
* postinstall) so a fresh machine only needs `npm install`.
|
||||
*/
|
||||
private bundledBinary(): string | null {
|
||||
try {
|
||||
const mainPath = require.resolve('@officecli/officecli');
|
||||
const candidate = join(dirname(mainPath), '..', 'officecli.js');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// package not installed — fall through
|
||||
}
|
||||
for (const base of [process.cwd(), join(__dirname, '..', '..')]) {
|
||||
const candidate = join(base, 'node_modules', '@officecli', 'officecli', 'officecli.js');
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user