From 6249fefc64eb9564eb505d62cb01d8b1240f3171 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 22:26:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ai-chat):=20=E7=A7=BB=E9=99=A4=20excel?= =?UTF-8?q?=5Fanalyze/office=5Fanalyze=20=E5=B7=A5=E5=85=B7=E4=B8=8E=20Off?= =?UTF-8?q?iceKit=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 excel_analyze/office_analyze 两个 A2UI 工具(schema、分发、执行器、测试) - 附件分析改为依赖上传时自动提取的文本与 preflight_import 报告 errorSamples - 移除 @officecli/officecli 依赖及 OfficeCliService(PPT 上传不再自动提取文本) - 保留 AiExcelReaderService(附件文本提取与导入预检仍使用) --- apps/server/package.json | 1 - .../src/ai-chat/ai-attachment.service.spec.ts | 25 --- .../src/ai-chat/ai-attachment.service.ts | 59 ------ apps/server/src/ai-chat/ai-chat.constants.ts | 48 +---- apps/server/src/ai-chat/ai-chat.generation.ts | 28 --- apps/server/src/ai-chat/ai-chat.module.ts | 2 - .../src/ai-chat/ai-chat.service.spec.ts | 189 ------------------ apps/server/src/ai-chat/ai-chat.service.ts | 12 -- .../src/ai-chat/ai-chat.tool-actions.ts | 76 ------- .../server/src/ai-chat/ai-chat.tool-office.ts | 123 ------------ apps/server/src/ai-chat/ai-chat.tools.ts | 8 - apps/server/src/ai-chat/ai-chat.types.ts | 2 - .../src/ai-chat/office-cli.service.spec.ts | 19 -- apps/server/src/ai-chat/office-cli.service.ts | 107 ---------- package-lock.json | 23 --- 15 files changed, 2 insertions(+), 720 deletions(-) delete mode 100644 apps/server/src/ai-chat/ai-chat.tool-office.ts delete mode 100644 apps/server/src/ai-chat/office-cli.service.spec.ts delete mode 100644 apps/server/src/ai-chat/office-cli.service.ts diff --git a/apps/server/package.json b/apps/server/package.json index beea8ec..c3ba960 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,7 +41,6 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", - "@officecli/officecli": "^1.0.143", "@types/multer": "^2.1.0", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", diff --git a/apps/server/src/ai-chat/ai-attachment.service.spec.ts b/apps/server/src/ai-chat/ai-attachment.service.spec.ts index a13f906..afca1c7 100644 --- a/apps/server/src/ai-chat/ai-attachment.service.spec.ts +++ b/apps/server/src/ai-chat/ai-attachment.service.spec.ts @@ -137,29 +137,4 @@ describe('AiAttachmentService', () => { 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; - } - ).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(); - }); }); diff --git a/apps/server/src/ai-chat/ai-attachment.service.ts b/apps/server/src/ai-chat/ai-attachment.service.ts index db53b51..0f07732 100644 --- a/apps/server/src/ai-chat/ai-attachment.service.ts +++ b/apps/server/src/ai-chat/ai-attachment.service.ts @@ -8,11 +8,9 @@ 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; @@ -42,7 +40,6 @@ export class AiAttachmentService { @InjectRepository(AiAttachment) private readonly attachments: Repository, private readonly excelReader: AiExcelReaderService, - private readonly officeCli?: OfficeCliService, ) {} async upload(userId: number, file: Express.Multer.File): Promise { @@ -213,68 +210,12 @@ 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')) { return this.normalizeExtractedText(await this.excelReader.extractText(buffer)); } return null; } - private async extractWithOfficeCli(buffer: Buffer, mimeType: string): Promise { - 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 }).cells; - const placed = new Map(); - 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 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. diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 8395aba..308ee72 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -32,50 +32,6 @@ export const A2UI_TOOL_SCHEMAS = [ }, }, }, - { - type: 'function' as const, - function: { - name: 'excel_analyze', - description: - '用 ExcelJS 直接解析上传的 Excel(.xlsx/.csv):overview 查看工作表概览(表名、行数、前几行样本),rows 按工作表/行范围读取具体行。适合核对表头、抽查数据行、确认预检报告里的错误原因;Word/PPT 请用 office_analyze。', - parameters: { - type: 'object', - properties: { - attachmentId: { - type: 'integer', - description: '上传的 Excel 附件 ID', - }, - action: { - type: 'string', - description: 'overview 概览 / rows 读取行', - enum: ['overview', 'rows'], - }, - sheet: { - type: 'string', - description: '工作表名称(rows 时可选,默认第一个表)', - maxLength: 200, - }, - startRow: { - type: 'integer', - description: '起始行(含表头,从 1 开始,默认 1)', - minimum: 1, - }, - maxRows: { - type: 'integer', - description: '读取行数(默认 20;传大值可读取更多/全部行)', - minimum: 1, - }, - maxColumns: { - type: 'integer', - description: '读取列数(默认 30;传大值可读取更多/全部列)', - minimum: 1, - }, - }, - required: ['attachmentId', 'action'], - additionalProperties: false, - }, - }, - }, { type: 'function' as const, function: { @@ -305,12 +261,12 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行: 1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。 2. 报告为 blocked 时,向用户说明阻断原因并建议修正文件后重传,不要生成向导;报告为 needs_input 时,按报告中的 questions 向用户确认:选项型问题用 render_form 生成表单(如更新策略、重复策略、校区、未匹配行处理),列映射类问题用聊天文本确认;报告为 ready 时可直接进入下一步,如需列映射确认也可先问。不要替用户默认做出影响数据的决定。 - 报告只给汇总统计时,可用 excel_analyze 读取报告 errorSamples 对应的工作表与行号,向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。 + 报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。 3. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。 -上传的 Office 附件:Excel(.xlsx/.csv)优先用 excel_analyze 查看概览(overview)或按行读取(rows)核对表头与数据;Word/PPT 用 office_analyze 查看结构(stats/outline)。批量导入前如不确定列名,可先预检(preflight_import)再用 excel_analyze 抽查具体行,不要读取整表。 +上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。 业务工作流引导(重要): - 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。 - 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 diff --git a/apps/server/src/ai-chat/ai-chat.generation.ts b/apps/server/src/ai-chat/ai-chat.generation.ts index 272a944..5c87a9b 100644 --- a/apps/server/src/ai-chat/ai-chat.generation.ts +++ b/apps/server/src/ai-chat/ai-chat.generation.ts @@ -78,34 +78,6 @@ export async function executeGeneration( ); } tools.push(...A2UI_TOOL_SCHEMAS); - tools.push({ - type: 'function' as const, - function: { - name: 'office_analyze', - description: - '分析上传的 Office 附件(Excel/Word/PPT):stats 统计、outline 结构、text 文本、get 读取指定区域、query 查询单元格/元素、issues 检查问题。文件较大或需要精确数据时使用。', - parameters: { - type: 'object', - properties: { - attachmentId: { type: 'integer', description: '要分析的附件 ID' }, - action: { - type: 'string', - enum: ['stats', 'outline', 'text', 'get', 'query', 'issues'], - description: '分析动作', - }, - path: { - type: 'string', - description: 'get 动作的路径,如 /Sheet1/A1:C20、/body/p[1]、/slide[1]', - }, - selector: { type: 'string', description: 'query 动作的选择器,如 /Sheet1、row[姓名=张三]' }, - maxLines: { type: 'integer', description: 'text 动作最多返回行数(1-200)' }, - startRow: { type: 'integer', description: 'text 动作起始行(默认 1)' }, - }, - required: ['attachmentId', 'action'], - additionalProperties: false, - }, - }, - }); tools = tools.filter((tool) => tool.function.name !== 'render_review'); const runtimeConfig = await context.configService.getRuntimeConfig(); const config = { diff --git a/apps/server/src/ai-chat/ai-chat.module.ts b/apps/server/src/ai-chat/ai-chat.module.ts index 7bb2408..b2319b3 100644 --- a/apps/server/src/ai-chat/ai-chat.module.ts +++ b/apps/server/src/ai-chat/ai-chat.module.ts @@ -11,7 +11,6 @@ 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 { OfficeCliService } from './office-cli.service'; import { AiAttachment, AiConversation, @@ -42,7 +41,6 @@ import { AiExcelReaderService, AiFormService, AiReviewService, - OfficeCliService, AiChatService, AiModelStreamService, ], diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index eff1cd5..518a54d 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -1517,139 +1517,6 @@ describe('AiChatService', () => { expect(data).toMatchObject({ id: 'review-1', status: 'submitted' }); }); - it('office_analyze 通过 OfficeCli 分析用户自己的附件并返回结果', async () => { - const conversation = { - id: 3, - userId: 7, - title: '测试', - lockedSkillKey: null, - lastMessageAt: null, - }; - const assistant = { - id: 12, - conversationId: 3, - role: 'assistant', - content: '', - reasoningContent: null, - status: 'pending', - errorCode: null, - replyToMessageId: 11, - metadata: {}, - }; - const messageSave = jest.fn(async (value) => value); - const messages = { - exists: jest.fn().mockResolvedValue(false), - find: jest.fn().mockResolvedValue([]), - findOne: jest.fn().mockImplementation((options?: unknown) => { - const opts = options as { select?: { metadata?: boolean } } | undefined; - if (opts?.select?.metadata) return Promise.resolve({ metadata: {} }); - return Promise.resolve(assistant); - }), - save: messageSave, - }; - const manager = { - create: jest.fn((_entity, value) => value), - save: jest - .fn() - .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '分析一下' }) - .mockResolvedValueOnce(assistant), - update: jest.fn(), - }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => value), - find: jest.fn().mockResolvedValue([]), - }; - const modelStream = { - stream: async function* () { - yield { - type: 'complete' as const, - toolCalls: [ - { - id: 'call-1', - name: 'office_analyze', - arguments: JSON.stringify({ attachmentId: 5, action: 'outline' }), - }, - ], - }; - }, - }; - const officeCli = { - run: jest.fn().mockResolvedValue({ - success: true, - data: { sheets: [{ name: '入住名单', rows: 360, cols: 18 }] }, - }), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 5, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - storageKey: '1/test.xlsx', - }, - ]), - storagePathFor: jest.fn().mockReturnValue('/tmp/attachments/1/test.xlsx'), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - toModelParts: jest.fn().mockResolvedValue([]), - serialize: jest.fn((value) => value), - }; - const service = new AiChatService( - { findOne: jest.fn().mockResolvedValue(conversation) } as never, - messages as never, - toolRuns as never, - { transaction: jest.fn(async (callback) => callback(manager)) } as never, - { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never, - { listAvailable: jest.fn().mockReturnValue([]) } as never, - modelStream as never, - attachmentService as never, - { - createForm: jest.fn(), - findOwnedPending: jest.fn(), - validateValues: jest.fn(), - markSubmitted: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { - createReview: jest.fn(), - expirePreviousReviews: jest.fn().mockResolvedValue([]), - findOwnedPending: jest.fn(), - findPendingByAssistantMessage: jest.fn(), - serialize: jest.fn((value) => value), - submit: jest.fn(), - } as never, - { - createChart: jest.fn(), - serialize: jest.fn((value) => value), - } as never, - { createForUser: jest.fn().mockReturnValue({}) } as never, - { assertPermission: jest.fn(), canPermission: jest.fn() } as never, - undefined, - officeCli as never, - ); - const emitted: Array<{ event: string; data: Record }> = []; - - await service.streamMessage( - authenticatedUser as never, - 3, - { - message: '分析一下附件', - attachmentIds: [5], - skillKey: null, - clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194', - }, - new AbortController().signal, - (event, data) => emitted.push({ event, data }), - jest.fn(), - ); - - expect(officeCli.run).toHaveBeenCalledWith([ - 'view', - '/tmp/attachments/1/test.xlsx', - 'outline', - '--json', - ]); - expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true); - }); it('删除用户消息时连同其 AI 回答一起删除并更新会话时间', async () => { const conversation = { id: 3, userId: 7, title: '新对话' }; @@ -2086,62 +1953,6 @@ describe('AiChatService', () => { ); }); - it('excel_analyze 用 ExcelJS 读取概览并返回给模型', async () => { - const { service } = createService(); - const toolRun = { id: 1, status: 'running' }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...toolRun, ...value })), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const excelReader = { - overview: jest.fn().mockResolvedValue({ - sheets: [{ name: '学生', rowCount: 2, columns: ['姓名'] }], - text: '# 学生(共 2 行)\n姓名\n张三', - }), - readRows: jest.fn(), - }; - (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { excelReader: unknown }).excelReader = excelReader; - - const emitted: Array<{ event: string }> = []; - const result = await ( - service as unknown as { - executeExcelAnalyze( - messageId: number, - call: { id: string; name: string; arguments: string }, - userId: number, - emit: (event: string, data?: unknown) => void, - ): Promise; - } - ).executeExcelAnalyze( - 42, - { - id: 'call-1', - name: 'excel_analyze', - arguments: JSON.stringify({ attachmentId: 9, action: 'overview' }), - }, - 7, - (event) => emitted.push({ event }), - ); - - const parsed = JSON.parse(result) as { status: string; data: { sheets: unknown[] } }; - expect(parsed.status).toBe('success'); - expect(parsed.data.sheets).toHaveLength(1); - expect(excelReader.overview).toHaveBeenCalledWith(expect.any(Buffer)); - expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true); - }); it('start_import_wizard 拒绝非法的确认参数', async () => { const { service } = createService(); diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index 55294ee..2a801a1 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -15,7 +15,6 @@ import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; import { AiModelStreamService } from './ai-model-stream.service'; -import { OfficeCliService } from './office-cli.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, @@ -74,7 +73,6 @@ import { } from './ai-chat.submissions'; import { denyWriteTool, executeTool } from './ai-chat.tools'; import { - executeExcelAnalyze, executePreflightImport, executeStartImportWizard, } from './ai-chat.tool-actions'; @@ -111,7 +109,6 @@ export class AiChatService implements AiChatServiceContext { readonly abilityFactory: CaslAbilityFactory, readonly authorization: AuthorizationService, readonly excelReader?: AiExcelReaderService, - readonly officeCli?: OfficeCliService, readonly importsService?: ImportsService, readonly opLog?: OperationLogsService, ) {} @@ -306,15 +303,6 @@ export class AiChatService implements AiChatServiceContext { return executePreflightImport(this, messageId, call, userId, emit); } - executeExcelAnalyze( - messageId: number, - call: ModelToolCall, - userId: number, - emit: AiSseEmitter, - ): Promise { - return executeExcelAnalyze(this, messageId, call, userId, emit); - } - executeGeneration(input: GenerationInput): Promise { return executeGeneration(this, input); } diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 2123165..1fda1c1 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -10,7 +10,6 @@ import { import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { finishToolRun, startToolRun } from './ai-chat.tools'; -export { executeOfficeAnalyze, buildOfficeCliArgs } from './ai-chat.tool-office'; function isExcelAttachment(attachment: { mimeType: string; @@ -93,81 +92,6 @@ export async function executePreflightImport( } } -export async function executeExcelAnalyze( - context: AiChatServiceContext, - messageId: number, - call: ModelToolCall, - userId: number, - emit: AiSseEmitter, -): Promise { - const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { - toolName: 'excel_analyze', - skillKey: null, - argumentsData: null, - }); - - try { - const parsedRecord = - parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) - ? (parsedArgs as Record) - : {}; - const attachmentId = - typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; - if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { - throw new Error('缺少附件 attachmentId'); - } - const action = typeof parsedRecord.action === 'string' ? parsedRecord.action : ''; - if (action !== 'overview' && action !== 'rows') { - throw new Error('action 只能是 overview 或 rows'); - } - const [attachment] = await context.attachmentService.requireReadyOwned(userId, [ - attachmentId as number, - ]); - if (!isExcelAttachment(attachment)) { - throw new Error('附件不是 Excel 文件,无法解析'); - } - if (!context.excelReader) throw new Error('Excel 解析器未配置'); - const buffer = await context.attachmentService.readStoredBuffer(attachment); - - let data: unknown; - let summary: string; - if (action === 'overview') { - const overview = await context.excelReader.overview(buffer); - data = { sheets: overview.sheets, text: overview.text }; - summary = `已解析 ${overview.sheets.length} 个工作表`; - } else { - const sheet = typeof parsedRecord.sheet === 'string' ? parsedRecord.sheet : undefined; - const startRow = Number(parsedRecord.startRow ?? 1); - const maxRows = Number(parsedRecord.maxRows ?? 20); - const maxColumns = Number(parsedRecord.maxColumns ?? 30); - if (!Number.isInteger(startRow) || startRow < 1) throw new Error('startRow 必须是 >=1 的整数'); - if (!Number.isInteger(maxRows) || maxRows < 1) { - throw new Error('maxRows 必须是 >=1 的整数'); - } - if (!Number.isInteger(maxColumns) || maxColumns < 1) { - throw new Error('maxColumns 必须是 >=1 的整数'); - } - data = await context.excelReader.readRows(buffer, sheet, startRow, maxRows, maxColumns); - summary = `已读取工作表「${(data as { sheet: string }).sheet}」${(data as { rows: unknown[] }).rows.length} 行`; - } - - await finishToolRun(context, run, call, startedAt, { - status: 'success', - summary, - }, emit); - return JSON.stringify({ status: 'success', data }); - } catch (error) { - const summary = - error instanceof Error ? error.message.slice(0, 100) : 'Excel 解析失败'; - await finishToolRun(context, run, call, startedAt, { - status: 'failed', - summary, - error: summary, - }, emit); - return JSON.stringify({ status: 'failed', error: run.resultSummary }); - } -} - export async function executeStartImportWizard( context: AiChatServiceContext, messageId: number, diff --git a/apps/server/src/ai-chat/ai-chat.tool-office.ts b/apps/server/src/ai-chat/ai-chat.tool-office.ts deleted file mode 100644 index 16ccf23..0000000 --- a/apps/server/src/ai-chat/ai-chat.tool-office.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { MAX_SUMMARY_CHARS } from './ai-chat.constants'; -import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; -import { finishToolRun, startToolRun } from './ai-chat.tools'; - -export async function executeOfficeAnalyze( - context: AiChatServiceContext, - messageId: number, - call: ModelToolCall, - userId: number, - emit: AiSseEmitter, -): Promise { - if (!context.officeCli) { - return JSON.stringify({ status: 'failed', error: 'OfficeCli 未配置' }); - } - const parsedArgs = context.parseToolArguments(call.arguments); - const args = - parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) - ? (parsedArgs as Record) - : {}; - const action = typeof args.action === 'string' ? args.action : ''; - const validActions = new Set(['stats', 'outline', 'text', 'get', 'query', 'issues']); - if (!validActions.has(action)) { - return JSON.stringify({ status: 'failed', error: 'office_analyze 参数无效' }); - } - - const { run, startedAt } = await startToolRun(context, messageId, call, emit, { - toolName: 'office_analyze', - skillKey: null, - argumentsData: context.safeStructured(args) as Record | null, - parsedArgs, - }); - - try { - let attachmentId = Number(args.attachmentId); - if (!Number.isInteger(attachmentId) || attachmentId <= 0) { - const assistant = await context.messages.findOne({ - where: { id: messageId }, - relations: { replyToMessage: { attachments: true } }, - }); - const officeAttachment = (assistant?.replyToMessage?.attachments ?? []).find( - (item) => - item.mimeType?.includes('spreadsheetml') || - item.mimeType?.includes('wordprocessingml') || - item.mimeType?.includes('presentationml'), - ); - if (!officeAttachment) throw new Error('未指定附件且当前消息没有 Office 附件'); - attachmentId = officeAttachment.id; - } - const [attachment] = await context.attachmentService.requireReadyOwned(userId, [attachmentId]); - if (!attachment) throw new Error('附件不存在'); - const mimeType = attachment.mimeType ?? ''; - const isOffice = - mimeType.includes('spreadsheetml') || - mimeType.includes('wordprocessingml') || - mimeType.includes('presentationml'); - if (!isOffice) throw new Error('该附件不是 Office 文档'); - const filePath = context.attachmentService.storagePathFor(attachment); - - const cliArgs = buildOfficeCliArgs(action, filePath, args); - const result = await context.officeCli.run(cliArgs); - if (!result.success) { - const cliError = context.redactText(String(result.error ?? 'OfficeCli 分析失败')).slice( - 0, - MAX_SUMMARY_CHARS, - ); - await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: cliError, error: cliError }, emit); - return JSON.stringify({ status: 'failed', error: 'OfficeCli 分析失败' }); - } - - let payload: string; - try { - payload = JSON.stringify(result.data); - } catch { - payload = '{}'; - } - let parsedData: unknown; - try { - parsedData = JSON.parse(payload); - } catch { - parsedData = { raw: payload.slice(0, 4000) }; - } - - await finishToolRun(context, run, call, startedAt, { status: 'success', summary: context.summarize(result.data) }, emit); - return JSON.stringify({ status: 'success', data: parsedData }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const failureSummary = context.redactText(errorMessage).slice(0, MAX_SUMMARY_CHARS); - await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: failureSummary, error: failureSummary }, emit); - return JSON.stringify({ status: 'failed', error: run.resultSummary }); - } -} - -export function buildOfficeCliArgs( - action: string, - filePath: string, - args: Record, -): string[] { - if (action === 'get') { - const path = typeof args.path === 'string' ? args.path.slice(0, 200) : ''; - if (!path.startsWith('/') || path.includes('..')) { - throw new Error('office_analyze 路径无效'); - } - return ['get', filePath, path, '--json']; - } - if (action === 'query') { - const selector = typeof args.selector === 'string' ? args.selector.slice(0, 200) : ''; - if (!selector) throw new Error('office_analyze 缺少 selector'); - return ['query', filePath, selector, '--json']; - } - if (action === 'text') { - const extra: string[] = []; - const maxLines = Number(args.maxLines); - if (Number.isInteger(maxLines) && maxLines >= 1 && maxLines <= 200) { - extra.push('--max-lines', String(maxLines)); - } - const startRow = Number(args.startRow); - if (Number.isInteger(startRow) && startRow > 1) { - extra.push('--start', String(startRow)); - } - return ['view', filePath, 'text', '--json', ...extra]; - } - return ['view', filePath, action, '--json']; -} diff --git a/apps/server/src/ai-chat/ai-chat.tools.ts b/apps/server/src/ai-chat/ai-chat.tools.ts index 597b205..f49c8b0 100644 --- a/apps/server/src/ai-chat/ai-chat.tools.ts +++ b/apps/server/src/ai-chat/ai-chat.tools.ts @@ -2,8 +2,6 @@ import { AgentToolContextFactory } from '../agent-tools/agent-tool.types'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AiToolRun } from './entities'; import { - executeOfficeAnalyze, - executeExcelAnalyze, executePreflightImport, executeRenderChart, executeRenderForm, @@ -95,9 +93,6 @@ export async function executeTool( if (call.name === 'preflight_import') { return executePreflightImport(context, messageId, call, userId, emit); } - if (call.name === 'excel_analyze') { - return executeExcelAnalyze(context, messageId, call, userId, emit); - } if (call.name === 'start_import_wizard') { return executeStartImportWizard(context, messageId, call, agentContext, emit); } @@ -110,9 +105,6 @@ export async function executeTool( if (call.name === 'render_chart') { return executeRenderChart(context, messageId, call, emit); } - if (call.name === 'office_analyze') { - return executeOfficeAnalyze(context, messageId, call, userId, emit); - } if ((call.name === 'create_student' || call.name === 'update_students') && !allowWriteTools) { return denyWriteTool(context, messageId, call, emit); } diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index 07b4dc5..b1108b1 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -11,7 +11,6 @@ import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; import { AiModelStreamService } from './ai-model-stream.service'; -import { OfficeCliService } from './office-cli.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, @@ -94,7 +93,6 @@ export interface AiChatServiceContext { readonly abilityFactory: CaslAbilityFactory; readonly authorization: AuthorizationService; readonly excelReader?: AiExcelReaderService; - readonly officeCli?: OfficeCliService; readonly importsService?: ImportsService; readonly opLog?: OperationLogsService; listSkills(user: AuthenticatedUser): ReturnType; diff --git a/apps/server/src/ai-chat/office-cli.service.spec.ts b/apps/server/src/ai-chat/office-cli.service.spec.ts deleted file mode 100644 index 634295c..0000000 --- a/apps/server/src/ai-chat/office-cli.service.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 } - ).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'); - }); -}); diff --git a/apps/server/src/ai-chat/office-cli.service.ts b/apps/server/src/ai-chat/office-cli.service.ts deleted file mode 100644 index 3cebeba..0000000 --- a/apps/server/src/ai-chat/office-cli.service.ts +++ /dev/null @@ -1,107 +0,0 @@ -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; -} - -@Injectable() -export class OfficeCliService { - private resolvedBinary: string | null = null; - - async run( - args: string[], - options: { timeoutMs?: number; maxBuffer?: number } = {}, - ): Promise { - 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 { - return this.run(['view', filePath, mode, '--json', ...extra]); - } - - async get(filePath: string, path: string, depth?: number): Promise { - return this.run([ - 'get', - filePath, - path, - '--json', - ...(depth === undefined ? [] : ['--depth', String(depth)]), - ]); - } - - async query(filePath: string, selector: string): Promise { - return this.run(['query', filePath, selector, '--json']); - } - - private async resolveBinary(): Promise { - 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; - } -} diff --git a/package-lock.json b/package-lock.json index 9bc190e..bfebdb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,6 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", - "@officecli/officecli": "^1.0.143", "@types/multer": "^2.1.0", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", @@ -3847,28 +3846,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@officecli/officecli": { - "version": "1.0.143", - "resolved": "https://registry.npmmirror.com/@officecli/officecli/-/officecli-1.0.143.tgz", - "integrity": "sha512-6fNynmrNso9wiRf2mIs6magdFHKQbsQC6u5qsZtwWwgZrc/kNr5++Wajp220sBnFPPuRiuKx1xy8LW+I2MZEbw==", - "cpu": [ - "x64", - "arm64" - ], - "hasInstallScript": true, - "license": "Apache-2.0", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "officecli": "officecli.js" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@oxc-project/types": { "version": "0.138.0", "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.138.0.tgz",