Files
gongxue-base/apps/server/src/ai-chat/ai-chat.tool-office.ts

130 lines
5.1 KiB
TypeScript

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<string> {
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<string, unknown>)
: {};
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<string, unknown> | 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, unknown>,
): 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'];
}