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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
||||
业务工作流引导(重要):
|
||||
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
|
||||
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']>;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user