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 = '{}'; } const MAX_OFFICE_RESULT_CHARS = 96 * 1024; let truncated = false; if (payload.length > MAX_OFFICE_RESULT_CHARS) { truncated = true; payload = `${payload.slice(0, MAX_OFFICE_RESULT_CHARS)}\n\n[结果过大已截断,请缩小读取范围]`; } 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, truncated }); } 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']; }