refactor(ai-chat): 移除 excel_analyze/office_analyze 工具与 OfficeKit 依赖

- 删除 excel_analyze/office_analyze 两个 A2UI 工具(schema、分发、执行器、测试)
- 附件分析改为依赖上传时自动提取的文本与 preflight_import 报告 errorSamples
- 移除 @officecli/officecli 依赖及 OfficeCliService(PPT 上传不再自动提取文本)
- 保留 AiExcelReaderService(附件文本提取与导入预检仍使用)
This commit is contained in:
2026-08-05 22:26:05 +08:00
parent d1c933f032
commit 6249fefc64
15 changed files with 2 additions and 720 deletions

View File

@@ -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",

View File

@@ -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<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();
});
});

View File

@@ -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<AiAttachment>,
private readonly excelReader: AiExcelReaderService,
private readonly officeCli?: OfficeCliService,
) {}
async upload(userId: number, file: Express.Multer.File): Promise<AiAttachment> {
@@ -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<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 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.

View File

@@ -32,50 +32,6 @@ export const A2UI_TOOL_SCHEMAS = [
},
},
},
{
type: 'function' as const,
function: {
name: 'excel_analyze',
description:
'用 ExcelJS 直接解析上传的 Excel.xlsx/.csvoverview 查看工作表概览表名、行数、前几行样本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业务类型 stepKeystudents 学生 / 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(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导
业务工作流引导(重要):
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。

View File

@@ -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/PPTstats 统计、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 = {

View File

@@ -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,
],

View File

@@ -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<string, unknown> }> = [];
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<string>;
}
).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();

View File

@@ -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<string> {
return executeExcelAnalyze(this, messageId, call, userId, emit);
}
executeGeneration(input: GenerationInput): Promise<void> {
return executeGeneration(this, input);
}

View File

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

View File

@@ -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<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 = '{}';
}
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, 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'];
}

View File

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

View File

@@ -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<AgentToolExecutor['listSkills']>;

View File

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

View File

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

23
package-lock.json generated
View File

@@ -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",