feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
197
apps/server/src/ai-chat/ai-chat.tools.ts
Normal file
197
apps/server/src/ai-chat/ai-chat.tools.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import type { AiToolRun } from './entities';
|
||||
import {
|
||||
executeOfficeAnalyze,
|
||||
executeRenderChart,
|
||||
executeRenderForm,
|
||||
executeRenderReview,
|
||||
executeStartImportWizard,
|
||||
} from './ai-chat.tool-actions';
|
||||
|
||||
export type AgentToolContext = ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>;
|
||||
|
||||
export async function startToolRun(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
emit: AiSseEmitter,
|
||||
options: {
|
||||
toolName: string;
|
||||
skillKey: string | null;
|
||||
argumentsData?: Record<string, unknown> | null;
|
||||
parsedArgs?: unknown;
|
||||
},
|
||||
): Promise<{ run: AiToolRun; parsedArgs: unknown; startedAt: number }> {
|
||||
const startedAt = Date.now();
|
||||
const parsedArgs = options.parsedArgs ?? context.parseToolArguments(call.arguments);
|
||||
const run = await context.toolRuns.save(
|
||||
context.toolRuns.create({
|
||||
messageId,
|
||||
toolCallId: call.id.slice(0, 100),
|
||||
toolName: options.toolName,
|
||||
skillKey: options.skillKey,
|
||||
argumentsSummary: context.summarize(parsedArgs),
|
||||
resultSummary: null,
|
||||
argumentsData:
|
||||
options.argumentsData ??
|
||||
(context.safeStructured(parsedArgs) as Record<string, unknown> | null),
|
||||
resultData: null,
|
||||
status: 'running',
|
||||
durationMs: null,
|
||||
}),
|
||||
);
|
||||
emit('tool.started', {
|
||||
messageId,
|
||||
toolCallId: call.id,
|
||||
toolName: run.toolName,
|
||||
skillKey: run.skillKey,
|
||||
status: 'running',
|
||||
summary: run.argumentsSummary,
|
||||
});
|
||||
return { run, parsedArgs, startedAt };
|
||||
}
|
||||
|
||||
export async function finishToolRun(
|
||||
context: AiChatServiceContext,
|
||||
run: AiToolRun,
|
||||
call: ModelToolCall,
|
||||
startedAt: number,
|
||||
outcome: { status: 'success' | 'failed'; summary: string | null; error?: string },
|
||||
emit: AiSseEmitter,
|
||||
): Promise<void> {
|
||||
run.status = outcome.status;
|
||||
run.resultSummary = outcome.summary;
|
||||
run.durationMs = Date.now() - startedAt;
|
||||
await context.toolRuns.save(run);
|
||||
emit(outcome.status === 'success' ? 'tool.completed' : 'tool.failed', {
|
||||
messageId: run.messageId,
|
||||
toolCallId: call.id,
|
||||
toolName: run.toolName,
|
||||
skillKey: run.skillKey,
|
||||
status: outcome.status,
|
||||
summary: outcome.summary,
|
||||
...(outcome.error ? { error: outcome.error } : {}),
|
||||
durationMs: run.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeTool(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
agentContext: AgentToolContext,
|
||||
allowedSkillKey: string | null,
|
||||
allowWriteTools: boolean,
|
||||
reviewSubmitted: boolean,
|
||||
userId: number,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
if (call.name === 'render_form') {
|
||||
return executeRenderForm(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'start_import_wizard') {
|
||||
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
||||
}
|
||||
if (call.name === 'render_review') {
|
||||
if (reviewSubmitted) {
|
||||
return denyTool(context, messageId, call, 'render_review', '导入已确认,无需再次生成预览', '导入已确认', emit);
|
||||
}
|
||||
return executeRenderReview(context, messageId, call, userId, emit);
|
||||
}
|
||||
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);
|
||||
}
|
||||
const toolSkillKey =
|
||||
context.toolExecutor.listAvailable(agentContext).find((tool) => tool.name === call.name)
|
||||
?.skillKey ?? allowedSkillKey;
|
||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: context.safeToolName(call.name),
|
||||
skillKey: toolSkillKey,
|
||||
});
|
||||
|
||||
const result = await context.toolExecutor.execute(call.name, parsedArgs, agentContext, allowedSkillKey);
|
||||
run.status = result.status;
|
||||
run.skillKey = result.skillKey ?? run.skillKey;
|
||||
run.resultSummary = context.summarize(result.result ?? result.error ?? null);
|
||||
run.resultData = context.safeStructured(result.result) as
|
||||
| Record<string, unknown>
|
||||
| unknown[]
|
||||
| null;
|
||||
run.durationMs = Date.now() - startedAt;
|
||||
await context.toolRuns.save(run);
|
||||
|
||||
emit(result.status === 'success' ? 'tool.completed' : 'tool.failed', {
|
||||
messageId,
|
||||
toolCallId: call.id,
|
||||
toolName: run.toolName,
|
||||
skillKey: run.skillKey,
|
||||
status: result.status,
|
||||
summary: run.resultSummary,
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
durationMs: run.durationMs,
|
||||
});
|
||||
const modelPayload = JSON.stringify(
|
||||
result.status === 'success'
|
||||
? { status: result.status, data: result.result }
|
||||
: { status: result.status, error: result.error },
|
||||
);
|
||||
if (modelPayload.length <= 32 * 1024) return modelPayload;
|
||||
return JSON.stringify({
|
||||
status: result.status,
|
||||
truncated: true,
|
||||
summary: context.summarize(result.result ?? result.error ?? null),
|
||||
});
|
||||
}
|
||||
|
||||
export async function denyWriteTool(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
const toolName =
|
||||
typeof call.name === 'string' && call.name.trim() ? call.name : 'create_student';
|
||||
return denyTool(context, messageId, call, toolName, '该操作需要表单确认', '该操作需要表单确认', emit);
|
||||
}
|
||||
|
||||
export async function denyTool(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
toolName: string,
|
||||
summary: string,
|
||||
error: string,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
await context.toolRuns.save(
|
||||
context.toolRuns.create({
|
||||
messageId,
|
||||
toolCallId: call.id.slice(0, 100),
|
||||
toolName: context.safeToolName(toolName),
|
||||
skillKey: null,
|
||||
argumentsSummary: context.summarize(context.parseToolArguments(call.arguments)),
|
||||
resultSummary: summary,
|
||||
argumentsData: null,
|
||||
resultData: null,
|
||||
status: 'failed',
|
||||
durationMs: 0,
|
||||
}),
|
||||
);
|
||||
emit('tool.failed', {
|
||||
messageId,
|
||||
toolCallId: call.id,
|
||||
toolName: context.safeToolName(toolName),
|
||||
status: 'failed',
|
||||
summary,
|
||||
error,
|
||||
durationMs: 0,
|
||||
});
|
||||
return JSON.stringify({ status: 'failed', error });
|
||||
}
|
||||
Reference in New Issue
Block a user