feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取

This commit is contained in:
2026-08-04 14:41:40 +08:00
parent f07ffdc64c
commit 50c44e4410
51 changed files with 11588 additions and 175 deletions

View File

@@ -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';
}