feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -1,14 +1,132 @@
|
||||
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
|
||||
import type {
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineStatusItem,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyAgentSseEnvelope,
|
||||
parseAgentSseMessage,
|
||||
recordsToTimelineItems,
|
||||
} from './agentTimelineAdapter';
|
||||
import { recordsToTimelineItems } from './agentTimelineAdapter';
|
||||
|
||||
describe('agentTimelineAdapter', () => {
|
||||
it('restores successful turn duration from persisted message timestamps', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
created: '2026-08-15T10:00:00Z',
|
||||
id: 'timer-user',
|
||||
roundId: 'round-timer',
|
||||
senderRole: 'user',
|
||||
contentText: '开始计时',
|
||||
},
|
||||
{
|
||||
created: '2026-08-15T10:00:18Z',
|
||||
id: 'timer-assistant',
|
||||
roundId: 'round-timer',
|
||||
senderRole: 'assistant',
|
||||
contentText: '计时完成',
|
||||
contentPayload: {
|
||||
agentResult: { text: '计时完成' },
|
||||
terminalStatus: 'COMPLETED',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const turnItems = items.filter((item) => item.roundId === 'round-timer');
|
||||
expect(turnItems).not.toHaveLength(0);
|
||||
expect(
|
||||
turnItems.every(
|
||||
(item) =>
|
||||
item.turnStartedAt === Date.parse('2026-08-15T10:00:00Z') &&
|
||||
item.turnFinishedAt === Date.parse('2026-08-15T10:00:18Z'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('restores available and expired Artifacts with the shared safe projection', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
id: 'artifact-history',
|
||||
senderRole: 'assistant',
|
||||
contentText: '报告已生成',
|
||||
roundId: 'round-artifact-history',
|
||||
contentPayload: {
|
||||
agentResult: { text: '报告已生成' },
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: '01JAVAILABLE',
|
||||
bucket: 'easyflow-agent-artifacts',
|
||||
fileName: '报告.pdf',
|
||||
objectKey: 'formal/private',
|
||||
sha256: 'b'.repeat(64),
|
||||
size: 4096,
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
{
|
||||
artifactId: '01JEXPIRED',
|
||||
fileName: '旧报告.xlsx',
|
||||
status: 'EXPIRED',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const artifacts = items.filter((item) => item.type === 'artifact');
|
||||
expect(artifacts).toHaveLength(2);
|
||||
expect(artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
downloadUrl: '/api/v1/agent/artifacts/01JAVAILABLE/content',
|
||||
status: 'available',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
downloadUrl: undefined,
|
||||
status: 'expired',
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(artifacts)).not.toMatch(
|
||||
/easyflow-agent-artifacts|objectKey|formal\/private/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses refreshed direct Artifact status instead of stale runtime events', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
id: 'artifact-status-conflict',
|
||||
senderRole: 'assistant',
|
||||
contentText: '产物已过期',
|
||||
roundId: 'round-artifact-status-conflict',
|
||||
contentPayload: {
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: '01JSTATUSCONFLICT',
|
||||
fileName: '账本报告.pdf',
|
||||
status: 'EXPIRED',
|
||||
},
|
||||
],
|
||||
runtimeEvents: [
|
||||
{
|
||||
name: 'easyflow.artifact.published',
|
||||
value: {
|
||||
artifactId: '01JSTATUSCONFLICT',
|
||||
fileName: '旧事件报告.pdf',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const artifacts = items.filter((item) => item.type === 'artifact');
|
||||
expect(artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
artifactId: '01JSTATUSCONFLICT',
|
||||
downloadUrl: undefined,
|
||||
fileName: '账本报告.pdf',
|
||||
status: 'expired',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects history records to chat timeline items', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
@@ -82,6 +200,36 @@ describe('agentTimelineAdapter', () => {
|
||||
expect(assistant?.parts.some((part) => part.type === 'text')).toBe(true);
|
||||
expect(items.some((item) => item.type === 'tool')).toBe(true);
|
||||
expect(assistant?.knowledgeItems?.[0]?.documentName).toBe('手册');
|
||||
expect(
|
||||
items
|
||||
.filter((item) => item.type !== 'message' || item.role !== 'user')
|
||||
.every((item) => item.roundId === 'r1' && item.turnSucceeded === true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a cancelled partial assistant turn expanded after history restore', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
id: 'cancelled-assistant',
|
||||
senderRole: 'assistant',
|
||||
contentText: '取消前的部分输出',
|
||||
created: '2026-08-15T10:00:00Z',
|
||||
roundId: 'cancelled-round',
|
||||
contentPayload: {
|
||||
agentResult: { text: '取消前的部分输出' },
|
||||
terminalStatus: 'CANCELLED',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
items
|
||||
.filter((item) => item.roundId === 'cancelled-round')
|
||||
.every(
|
||||
(item) =>
|
||||
item.turnSucceeded === false && item.turnFinishedAt !== undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps stable ids when history has reasoning, tools and final text', () => {
|
||||
@@ -310,335 +458,63 @@ describe('agentTimelineAdapter', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parses raw SSE text as message delta', () => {
|
||||
const envelope = parseAgentSseMessage({
|
||||
data: 'hello',
|
||||
event: '',
|
||||
id: '',
|
||||
retry: undefined,
|
||||
});
|
||||
|
||||
expect(envelope).toMatchObject({
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles streamed text with the canonical final answer', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
for (const delta of ['http://127.0.0.1:39', '0', '/easyflow/file.docx']) {
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta },
|
||||
});
|
||||
}
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'SYSTEM',
|
||||
type: 'DONE',
|
||||
payload: {
|
||||
finalText: 'http://127.0.0.1:39000/easyflow/file.docx',
|
||||
},
|
||||
});
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
expect(assistant?.parts[0]?.content).toBe(
|
||||
'http://127.0.0.1:39000/easyflow/file.docx',
|
||||
);
|
||||
expect(assistant?.status).toBe('done');
|
||||
});
|
||||
|
||||
it('applies streaming text, HITL approval and error envelopes', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: '你好' },
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REQUEST',
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
resumeToken: 'token-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'workflow',
|
||||
input: { name: 'demo' },
|
||||
},
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'ERROR',
|
||||
type: 'ERROR',
|
||||
payload: { message: '失败' },
|
||||
});
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
const tool = items.find((item) => item.type === 'tool');
|
||||
const error = items.find((item) => item.type === 'error');
|
||||
|
||||
expect(assistant?.parts[0]?.content).toBe('你好');
|
||||
expect(tool?.status).toBe('pending_approval');
|
||||
expect(tool?.approval?.resumeToken).toBe('token-1');
|
||||
expect(error?.message).toBe('失败');
|
||||
});
|
||||
|
||||
it('keeps async workflow polling events in the original approval card', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REQUEST',
|
||||
payload: {
|
||||
input: { user_input: '写一篇小作文' },
|
||||
requestId: 'req-async',
|
||||
resumeToken: 'token-async',
|
||||
toolCallId: 'submit-call-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'TOOL_RESULT',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
phase: 'submit',
|
||||
sourceToolCallId: 'submit-call-1',
|
||||
status: 'RUNNING',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) {
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'TOOL_CALL',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
input: { taskId: 'task-1' },
|
||||
phase: 'observe',
|
||||
sourceToolCallId,
|
||||
status: 'RUNNING',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
}
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'TOOL_RESULT',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
phase: 'result',
|
||||
sourceToolCallId: 'result-call-1',
|
||||
status: 'SUCCEEDED',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
});
|
||||
|
||||
const tools = items.filter((item) => item.type === 'tool');
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]).toMatchObject({
|
||||
mode: 'approval',
|
||||
status: 'success',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps assistant text and approval card when a tool request is rejected', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: '正在处理' },
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REQUEST',
|
||||
payload: {
|
||||
requestId: 'req-2',
|
||||
resumeToken: 'token-2',
|
||||
toolCallId: 'tool-2',
|
||||
toolName: '审批工具',
|
||||
input: { name: 'demo' },
|
||||
},
|
||||
});
|
||||
applyAgentSseEnvelope(items, {
|
||||
domain: 'TOOL',
|
||||
type: 'FORM_REJECTED',
|
||||
payload: {
|
||||
requestId: 'req-2',
|
||||
resumeToken: 'token-2',
|
||||
toolCallId: 'tool-2',
|
||||
reason: '用户拒绝执行',
|
||||
},
|
||||
});
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
const tool = items.find((item) => item.type === 'tool');
|
||||
|
||||
expect(assistant?.parts[0]?.content).toBe('正在处理');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(tool?.status).toBe('rejected');
|
||||
expect(tool?.rejectReason).toBe('用户拒绝执行');
|
||||
});
|
||||
|
||||
it('applies streaming round metadata to assistant messages for action toolbar anchoring', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
it('restores only terminal Skill invocation states from safe history fields', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
domain: 'LLM',
|
||||
type: 'MESSAGE',
|
||||
payload: { delta: '准备调用工具' },
|
||||
},
|
||||
{ roundId: 'runtime-round-1' },
|
||||
);
|
||||
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
|
||||
expect(assistant?.roundId).toBe('runtime-round-1');
|
||||
expect(assistant?.parts[0]?.content).toBe('准备调用工具');
|
||||
});
|
||||
|
||||
it('updates memory compression status within the current round', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
label: '正在整理上下文',
|
||||
phase: 'started',
|
||||
status: 'running',
|
||||
statusKey: 'memory-compression',
|
||||
id: 'skill-history',
|
||||
senderRole: 'assistant',
|
||||
contentText: '处理结束',
|
||||
roundId: 'round-skill-history',
|
||||
contentPayload: {
|
||||
agentResult: { text: '处理结束' },
|
||||
skillInvocationStatuses: [
|
||||
{
|
||||
input: { private: true },
|
||||
path: 'references/private.md',
|
||||
skillContent: 'private body',
|
||||
skillDisplayName: '不会恢复的运行态',
|
||||
status: 'RUNNING',
|
||||
statusKey: 'skill-invocation:round-skill-history:running',
|
||||
},
|
||||
{
|
||||
configJson: { token: 'secret' },
|
||||
skillDisplayName: '合同审查助手',
|
||||
status: 'SUCCESS',
|
||||
statusKey: 'skill-invocation:round-skill-history:101',
|
||||
},
|
||||
{
|
||||
skillDisplayName: '数据分析助手',
|
||||
status: 'FAILED',
|
||||
statusKey: 'skill-invocation:round-skill-history:102',
|
||||
},
|
||||
{
|
||||
skillDisplayName: '流程检查助手',
|
||||
status: 'CANCELLED',
|
||||
statusKey: 'skill-invocation:round-skill-history:103',
|
||||
},
|
||||
{
|
||||
skillDisplayName: '规范化助手',
|
||||
status: 'INCOMPLETE',
|
||||
statusKey: 'skill-invocation:round-skill-history:104',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
]);
|
||||
|
||||
const skillStatuses = items.filter(
|
||||
(item): item is ChatTimelineStatusItem =>
|
||||
item.type === 'status' && item.icon === 'skill',
|
||||
);
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: true,
|
||||
label: '已整理上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
expect(skillStatuses).toHaveLength(4);
|
||||
expect(skillStatuses.map((item) => item.status)).toEqual([
|
||||
'done',
|
||||
'error',
|
||||
'cancelled',
|
||||
'incomplete',
|
||||
]);
|
||||
expect(JSON.stringify(skillStatuses)).not.toMatch(
|
||||
/不会恢复的运行态|skillContent|private body|private\.md|configJson|token/,
|
||||
);
|
||||
|
||||
const statuses = items.filter((item) => item.type === 'status');
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.label).toBe('已整理上下文');
|
||||
expect(statuses[0]?.status).toBe('done');
|
||||
expect(statuses[0]?.statusKey).toBe('memory-compression:round-a');
|
||||
});
|
||||
|
||||
it('keeps memory compression statuses isolated by round', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: true,
|
||||
label: '已整理上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
);
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: false,
|
||||
label: '无需压缩上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-b' },
|
||||
);
|
||||
|
||||
const statuses = items.filter((item) => item.type === 'status');
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.statusKey).toBe('memory-compression:round-a');
|
||||
expect(statuses[0]?.label).toBe('已整理上下文');
|
||||
});
|
||||
|
||||
it('does not show no-compression status before a later compression run', () => {
|
||||
const items: any[] = [];
|
||||
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
compressed: false,
|
||||
label: '无需压缩上下文',
|
||||
phase: 'completed',
|
||||
status: 'done',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
);
|
||||
applyAgentSseEnvelope(
|
||||
items,
|
||||
{
|
||||
domain: 'BUSINESS',
|
||||
type: 'STATUS',
|
||||
payload: {
|
||||
label: '正在整理上下文',
|
||||
phase: 'started',
|
||||
status: 'running',
|
||||
statusKey: 'memory-compression',
|
||||
},
|
||||
},
|
||||
{ roundId: 'round-a' },
|
||||
);
|
||||
|
||||
const statuses = items.filter((item) => item.type === 'status');
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.label).toBe('正在整理上下文');
|
||||
expect(statuses[0]?.status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
ChatTimelineToolStatus,
|
||||
ChatTimelineSkillInvocationStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import type { AgentChatMessageRecord } from '../api';
|
||||
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
export interface AgentSseEnvelope {
|
||||
domain: string;
|
||||
payload: Record<string, any>;
|
||||
type: string;
|
||||
}
|
||||
import { projectArtifactPayload } from '../../shared/agent-agui/artifact-projection';
|
||||
import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events';
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
@@ -34,17 +28,6 @@ function asArray(value: unknown): any[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asyncToolTimelineStatus(
|
||||
payload: Record<string, any>,
|
||||
): ChatTimelineToolStatus {
|
||||
const status = asText(payload.status).toUpperCase();
|
||||
if (status === 'SUCCEEDED') return 'success';
|
||||
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
|
||||
return 'error';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function asTimestamp(value: unknown) {
|
||||
if (!value) {
|
||||
return Date.now();
|
||||
@@ -53,6 +36,44 @@ function asTimestamp(value: unknown) {
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
}
|
||||
|
||||
function optionalTimestamp(value: unknown) {
|
||||
if (!value) return undefined;
|
||||
const timestamp = new Date(String(value)).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function applyHistoryTurnTimings(
|
||||
items: ChatTimelineItem[],
|
||||
records: AgentChatMessageRecord[],
|
||||
) {
|
||||
const timings = new Map<
|
||||
string,
|
||||
{ finishedAt?: number; firstAt: number; startedAt?: number }
|
||||
>();
|
||||
for (const record of records) {
|
||||
const roundId = asText(record.roundId).trim();
|
||||
const createdAt = optionalTimestamp(record.created);
|
||||
if (!roundId || createdAt === undefined) continue;
|
||||
const current = timings.get(roundId) || { firstAt: createdAt };
|
||||
current.firstAt = Math.min(current.firstAt, createdAt);
|
||||
const role = normalizeRole(record.senderRole);
|
||||
if (role === 'user') {
|
||||
current.startedAt = Math.min(current.startedAt ?? createdAt, createdAt);
|
||||
} else {
|
||||
current.finishedAt = Math.max(current.finishedAt ?? createdAt, createdAt);
|
||||
}
|
||||
timings.set(roundId, current);
|
||||
}
|
||||
for (const item of items) {
|
||||
const timing = item.roundId ? timings.get(item.roundId) : undefined;
|
||||
if (!timing) continue;
|
||||
item.turnStartedAt = timing.startedAt ?? timing.firstAt;
|
||||
if (timing.finishedAt !== undefined) {
|
||||
item.turnFinishedAt = timing.finishedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): 'assistant' | 'system' | 'user' {
|
||||
const role = asText(value).toLowerCase();
|
||||
if (role === 'assistant' || role === 'system' || role === 'user') {
|
||||
@@ -102,6 +123,112 @@ function statusKeyForProjection(
|
||||
return roundId ? `${statusKey}:${roundId}` : statusKey;
|
||||
}
|
||||
|
||||
const terminalSkillInvocationStatuses =
|
||||
new Set<ChatTimelineSkillInvocationStatus>([
|
||||
'CANCELLED',
|
||||
'FAILED',
|
||||
'INCOMPLETE',
|
||||
'SUCCESS',
|
||||
]);
|
||||
|
||||
function statusFromRuntimeEventType(
|
||||
eventType: string,
|
||||
): ChatTimelineSkillInvocationStatus | undefined {
|
||||
if (eventType === 'SKILL_RESULT') return 'SUCCESS';
|
||||
if (eventType === 'SKILL_FAILED') return 'FAILED';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeHistorySkillInvocation(value: unknown, direct: boolean) {
|
||||
const event = asRecord(value);
|
||||
const eventName = asText(event.name ?? event.eventName);
|
||||
const eventType = asText(event.eventType ?? event.type).toUpperCase();
|
||||
const isSkillRuntimeEvent =
|
||||
eventType === 'SKILL_FAILED' || eventType === 'SKILL_RESULT';
|
||||
if (
|
||||
(!direct &&
|
||||
eventName !== easyFlowAguiCustomEvent.skillInvocationStatus &&
|
||||
!isSkillRuntimeEvent) ||
|
||||
(eventName && eventName !== easyFlowAguiCustomEvent.skillInvocationStatus)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const payload = asRecord(
|
||||
event.value ?? event.payload ?? event.payloadJson ?? event,
|
||||
);
|
||||
const rawStatus = asText(payload.status).toUpperCase();
|
||||
const status = (rawStatus || statusFromRuntimeEventType(eventType)) as
|
||||
| ChatTimelineSkillInvocationStatus
|
||||
| undefined;
|
||||
if (!status || !terminalSkillInvocationStatuses.has(status)) {
|
||||
return undefined;
|
||||
}
|
||||
const statusKey = asText(payload.statusKey).trim();
|
||||
if (!statusKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
displayName:
|
||||
asText(payload.skillDisplayName).trim() ||
|
||||
asText(payload.skillName).trim() ||
|
||||
'技能',
|
||||
status,
|
||||
statusKey,
|
||||
};
|
||||
}
|
||||
|
||||
function projectHistorySkillInvocations(
|
||||
items: ChatTimelineItem[],
|
||||
payload: Record<string, any>,
|
||||
metadata: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const directSource = [
|
||||
...asArray(payload.skillInvocationStatuses),
|
||||
...asArray(payload.skillInvocations),
|
||||
];
|
||||
const source =
|
||||
directSource.length > 0 ? directSource : asArray(payload.runtimeEvents);
|
||||
for (const value of source) {
|
||||
const invocation = normalizeHistorySkillInvocation(
|
||||
value,
|
||||
directSource.length > 0,
|
||||
);
|
||||
if (invocation) {
|
||||
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
|
||||
...metadata,
|
||||
...invocation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function projectHistoryArtifacts(
|
||||
items: ChatTimelineItem[],
|
||||
payload: Record<string, any>,
|
||||
metadata: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
let source: any[];
|
||||
if (Array.isArray(payload.artifacts)) {
|
||||
source = payload.artifacts;
|
||||
} else if (Array.isArray(payload.artifactPublishedEvents)) {
|
||||
source = payload.artifactPublishedEvents;
|
||||
} else {
|
||||
source = asArray(payload.runtimeEvents)
|
||||
.map((value) => asRecord(value))
|
||||
.filter(
|
||||
(event) =>
|
||||
asText(event.name ?? event.eventName) ===
|
||||
easyFlowAguiCustomEvent.artifactPublished,
|
||||
)
|
||||
.map((event) =>
|
||||
asRecord(event.value ?? event.payload ?? event.payloadJson),
|
||||
);
|
||||
}
|
||||
for (const artifact of source) {
|
||||
projectArtifactPayload(items, asRecord(artifact), metadata);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMetadata(record: AgentChatMessageRecord) {
|
||||
return {
|
||||
createdAt: asTimestamp(record.created),
|
||||
@@ -202,20 +329,6 @@ function normalizeDocuments(payload: Record<string, any>) {
|
||||
.filter((item): item is ChatDocumentAttachment => item !== undefined);
|
||||
}
|
||||
|
||||
function buildApprovalPayload(payload: Record<string, any>) {
|
||||
return {
|
||||
expiresAt: asText(payload.expiresAt),
|
||||
input: payload.input,
|
||||
metadata: payload.metadata,
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: normalizeToolCallId(payload),
|
||||
toolDisplayName: asText(payload.toolDisplayName),
|
||||
toolName: normalizeToolName(payload.toolName ?? payload.name) || '工具调用',
|
||||
toolType: asText(payload.toolType),
|
||||
} satisfies ChatTimelineToolApprovalPayload;
|
||||
}
|
||||
|
||||
function appendAssistantText(
|
||||
items: ChatTimelineItem[],
|
||||
record: AgentChatMessageRecord,
|
||||
@@ -276,6 +389,7 @@ function projectHistoryChain(
|
||||
}
|
||||
if (toolName && !shouldSkipToolProjection(toolName)) {
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...normalizeMetadata(record),
|
||||
input: item.arguments ?? item.input,
|
||||
output: item.result ?? item.output,
|
||||
status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running',
|
||||
@@ -314,6 +428,7 @@ function projectHistoryChain(
|
||||
continue;
|
||||
}
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...normalizeMetadata(record),
|
||||
input: normalizeToolCallInput(tool),
|
||||
status: 'running',
|
||||
statusKey: statusKeyForProjection(
|
||||
@@ -335,6 +450,7 @@ function projectHistoryChain(
|
||||
continue;
|
||||
}
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...normalizeMetadata(record),
|
||||
output: item.content ?? item.result,
|
||||
status: 'success',
|
||||
statusKey: statusKeyForProjection(
|
||||
@@ -368,12 +484,18 @@ function appendHistoryRecord(
|
||||
return;
|
||||
}
|
||||
if (role === 'system') {
|
||||
ChatTimelineBuilder.appendError(items, record.contentText || '系统消息');
|
||||
ChatTimelineBuilder.appendError(
|
||||
items,
|
||||
record.contentText || '系统消息',
|
||||
metadata,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = asRecord(record.contentPayload);
|
||||
const agentResult = asRecord(payload.agentResult);
|
||||
projectHistoryArtifacts(items, payload, metadata);
|
||||
projectHistorySkillInvocations(items, payload, metadata);
|
||||
const chainProjection = projectHistoryChain(items, record);
|
||||
if (!chainProjection.hasAssistantThinking) {
|
||||
appendAssistantThinking(
|
||||
@@ -399,9 +521,16 @@ function appendHistoryRecord(
|
||||
payload.knowledgeReferences,
|
||||
});
|
||||
if (knowledgeItems.length > 0) {
|
||||
ChatTimelineBuilder.appendKnowledge(items, knowledgeItems);
|
||||
ChatTimelineBuilder.appendKnowledge(items, knowledgeItems, metadata);
|
||||
}
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
const terminalStatus = asText(payload.terminalStatus).toUpperCase();
|
||||
const turnSucceeded = !terminalStatus || terminalStatus === 'COMPLETED';
|
||||
ChatTimelineBuilder.finalize(items, {
|
||||
...metadata,
|
||||
roundCompleted: turnSucceeded,
|
||||
turnFinishedAt: turnSucceeded ? undefined : metadata.createdAt,
|
||||
turnSucceeded,
|
||||
});
|
||||
}
|
||||
|
||||
export function recordsToTimelineItems(records: AgentChatMessageRecord[] = []) {
|
||||
@@ -410,166 +539,6 @@ export function recordsToTimelineItems(records: AgentChatMessageRecord[] = []) {
|
||||
appendHistoryRecord(items, record);
|
||||
}
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
applyHistoryTurnTimings(items, records);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function parseAgentSseMessage(message: ServerSentEventMessage) {
|
||||
const raw = message.data || '';
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
return {
|
||||
domain: asText(
|
||||
data.domain ?? data.eventDomain ?? data.typeDomain,
|
||||
).toUpperCase(),
|
||||
payload: asRecord(data.payload ?? data.data ?? data),
|
||||
type: asText(
|
||||
data.type ?? data.eventType ?? data.chatType ?? data.event,
|
||||
).toUpperCase(),
|
||||
} satisfies AgentSseEnvelope;
|
||||
} catch {
|
||||
return {
|
||||
domain: 'LLM',
|
||||
payload: { delta: raw },
|
||||
type: 'MESSAGE',
|
||||
} satisfies AgentSseEnvelope;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAgentSseEnvelope(
|
||||
items: ChatTimelineItem[],
|
||||
envelope: AgentSseEnvelope,
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const { domain, payload, type } = envelope;
|
||||
if (domain === 'LLM' && type === 'MESSAGE') {
|
||||
ChatTimelineBuilder.appendMessageDelta(
|
||||
items,
|
||||
payload.delta ?? payload.text,
|
||||
metadata,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (domain === 'LLM' && type === 'THINKING') {
|
||||
ChatTimelineBuilder.appendThinkingDelta(
|
||||
items,
|
||||
payload.reasoning ?? payload.delta ?? payload.text,
|
||||
metadata,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_REQUEST') {
|
||||
ChatTimelineBuilder.appendToolApproval(
|
||||
items,
|
||||
buildApprovalPayload(payload),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_APPROVING') {
|
||||
ChatTimelineBuilder.markToolApproving(items, {
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: normalizeToolCallId(payload),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_REJECTED') {
|
||||
ChatTimelineBuilder.markToolRejected(items, {
|
||||
reason: asText(payload.reason),
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: normalizeToolCallId(payload),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
|
||||
const asyncTool = payload.asyncTool === true;
|
||||
const taskInput = asRecord(payload.input ?? payload.toolInput);
|
||||
const toolName = normalizeToolName(
|
||||
payload.toolDisplayName ?? payload.toolName ?? payload.name,
|
||||
);
|
||||
let status: ChatTimelineToolStatus = 'running';
|
||||
if (asyncTool) {
|
||||
status = asyncToolTimelineStatus(payload);
|
||||
} else if (type === 'TOOL_RESULT') {
|
||||
status = 'success';
|
||||
}
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output: asyncTool
|
||||
? (payload.summary ??
|
||||
payload.label ??
|
||||
payload.output ??
|
||||
payload.result ??
|
||||
payload.text)
|
||||
: (payload.output ?? payload.result ?? payload.text),
|
||||
status,
|
||||
statusKey: statusKeyForProjection(
|
||||
payload,
|
||||
metadata,
|
||||
'knowledge-retrieval',
|
||||
),
|
||||
sourceToolCallId: asyncTool
|
||||
? asText(payload.sourceToolCallId ?? payload.source_tool_call_id)
|
||||
: undefined,
|
||||
taskId: asyncTool
|
||||
? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id)
|
||||
: undefined,
|
||||
toolCallId: asyncTool
|
||||
? asText(payload.toolCallId ?? payload.taskId ?? payload.id)
|
||||
: normalizeToolCallId(payload),
|
||||
toolName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (domain === 'BUSINESS' && type === 'CITATIONS') {
|
||||
ChatTimelineBuilder.appendKnowledge(
|
||||
items,
|
||||
normalizeKnowledgeItems(payload),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (domain === 'BUSINESS' && type === 'STATUS') {
|
||||
if (asText(payload.statusKey) === 'memory-compression') {
|
||||
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
|
||||
compressed:
|
||||
typeof payload.compressed === 'boolean'
|
||||
? payload.compressed
|
||||
: undefined,
|
||||
label: asText(payload.label),
|
||||
phase: asText(payload.phase),
|
||||
status: asText(payload.status),
|
||||
statusKey: statusKeyForProjection(
|
||||
payload,
|
||||
metadata,
|
||||
'memory-compression',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (asText(payload.statusKey) === 'knowledge-retrieval') {
|
||||
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
||||
items,
|
||||
asText(payload.status) === 'running' ? 'running' : 'done',
|
||||
statusKeyForProjection(payload, metadata, 'knowledge-retrieval'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (domain === 'SYSTEM' && type === 'DONE') {
|
||||
const finalText = asText(payload.finalText ?? payload.text);
|
||||
if (finalText) {
|
||||
ChatTimelineBuilder.replaceMessageContent(items, finalText);
|
||||
}
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
return;
|
||||
}
|
||||
if (domain === 'ERROR' || type === 'ERROR') {
|
||||
ChatTimelineBuilder.appendError(
|
||||
items,
|
||||
payload.message ?? payload.error ?? '请求失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { EasyFlowAguiRunOptions } from '../shared/agent-agui/client';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { EventType } from '@ag-ui/client';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
||||
|
||||
import { easyFlowAguiCustomEvent } from '../shared/agent-agui/custom-events';
|
||||
import { agentChatRuntimeManager } from './agentChatRuntimeManager';
|
||||
import { sendAgentChat } from './api';
|
||||
|
||||
const aguiMocks = vi.hoisted(() => ({
|
||||
abort: vi.fn(),
|
||||
run: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/agent-agui/client', () => ({
|
||||
EasyFlowAguiClient: class {
|
||||
abort = aguiMocks.abort;
|
||||
run = aguiMocks.run;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
generateAgentSessionId: vi.fn(),
|
||||
sendAgentChat: vi.fn(),
|
||||
stopAgentChatStream: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('agentChatRuntimeManager', () => {
|
||||
@@ -27,11 +40,43 @@ describe('agentChatRuntimeManager', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('replaces accepted attachments and isolates snapshots by account', async () => {
|
||||
let callbacks: any;
|
||||
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||
callbacks = options;
|
||||
return Promise.resolve() as any;
|
||||
it('发起请求后立即创建带起始时间的助手轮次', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(12_000);
|
||||
aguiMocks.run.mockImplementation(() => new Promise(() => {}));
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'timer-user',
|
||||
loginName: 'timer-user',
|
||||
nickname: '计时用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '计时测试',
|
||||
sessionId: 'timer-session',
|
||||
});
|
||||
|
||||
const assistant = agentChatRuntimeManager
|
||||
.getSnapshot('timer-session')
|
||||
?.items.find(
|
||||
(item) => item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
expect(assistant).toEqual(
|
||||
expect.objectContaining({
|
||||
roundId: expect.any(String),
|
||||
status: 'streaming',
|
||||
turnStartedAt: 12_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('投影 AG-UI 输入确认并按账号隔离快照', async () => {
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runOptions = options;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
const firstAccount = {
|
||||
@@ -46,78 +91,55 @@ describe('agentChatRuntimeManager', () => {
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
documentUploadIds: ['document-upload-1'],
|
||||
documents: [
|
||||
{
|
||||
downloadUrl:
|
||||
'/api/v1/agent/media/document/content?reference=draft%3Adocument-upload-1',
|
||||
name: 'draft.docx',
|
||||
status: 'ready',
|
||||
uploadId: 'document-upload-1',
|
||||
},
|
||||
],
|
||||
documents: [{ name: 'draft.docx', status: 'ready' }],
|
||||
images: [
|
||||
{
|
||||
name: 'draft.png',
|
||||
previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1',
|
||||
previewUrl: '/draft.png',
|
||||
status: 'ready',
|
||||
uploadId: 'upload-1',
|
||||
},
|
||||
],
|
||||
prompt: '识别图片',
|
||||
sessionId: '101',
|
||||
});
|
||||
callbacks.onMessage({
|
||||
data: JSON.stringify({
|
||||
domain: 'SYSTEM',
|
||||
payload: {
|
||||
attachments: [
|
||||
{
|
||||
attachmentRef: 'formal:101:201:document:0',
|
||||
downloadUrl:
|
||||
'/api/v1/agent/media/document/content?reference=formal:101:201:document:0',
|
||||
name: 'draft.docx',
|
||||
readSnapshotId: 'snapshot-1',
|
||||
size: 2048,
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
name: 'draft.png',
|
||||
previewUrl:
|
||||
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'INPUT_ACCEPTED',
|
||||
}),
|
||||
runOptions?.onEvent({
|
||||
name: easyFlowAguiCustomEvent.inputAccepted,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
attachments: [
|
||||
{
|
||||
attachmentRef: 'formal:101:201:document:0',
|
||||
name: 'draft.docx',
|
||||
readSnapshotId: 'snapshot-1',
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
name: 'draft.png',
|
||||
previewUrl: '/formal.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const accepted = agentChatRuntimeManager.getSnapshot('101');
|
||||
const userMessage = accepted?.items.find(
|
||||
(item) => item.type === 'message' && item.role === 'user',
|
||||
);
|
||||
expect(
|
||||
userMessage?.type === 'message' ? userMessage.images?.[0] : null,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
imageRef: 'formal:101:201:0:png',
|
||||
previewUrl:
|
||||
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||
}),
|
||||
expect(userMessage?.type === 'message' && userMessage.images?.[0]).toEqual(
|
||||
expect.objectContaining({ imageRef: 'formal:101:201:0:png' }),
|
||||
);
|
||||
expect(
|
||||
userMessage?.type === 'message' ? userMessage.documents?.[0] : null,
|
||||
).toEqual(
|
||||
userMessage?.type === 'message' && userMessage.documents?.[0],
|
||||
).toEqual(expect.objectContaining({ readSnapshotId: 'snapshot-1' }));
|
||||
expect(runOptions?.forwardedProps).toEqual(
|
||||
expect.objectContaining({
|
||||
attachmentRef: 'formal:101:201:document:0',
|
||||
readSnapshotId: 'snapshot-1',
|
||||
status: 'ready',
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(sendAgentChat).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
documentUploadIds: ['document-upload-1'],
|
||||
easyflow: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
documentUploadIds: ['document-upload-1'],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -125,7 +147,6 @@ describe('agentChatRuntimeManager', () => {
|
||||
...firstAccount,
|
||||
id: 'user-2',
|
||||
loginName: 'other',
|
||||
nickname: '其他用户',
|
||||
});
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
|
||||
@@ -134,12 +155,15 @@ describe('agentChatRuntimeManager', () => {
|
||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('coalesces streaming notifications and persists terminal state immediately', async () => {
|
||||
it('合并流式通知并在 AG-UI 终态立即持久化', async () => {
|
||||
vi.useFakeTimers();
|
||||
let callbacks: any;
|
||||
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||
callbacks = options;
|
||||
return Promise.resolve() as any;
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
let resolveRun: (() => void) | undefined;
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runOptions = options;
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
});
|
||||
const account = {
|
||||
avatar: '',
|
||||
@@ -160,17 +184,13 @@ describe('agentChatRuntimeManager', () => {
|
||||
const storageSpy = vi.spyOn(sessionStorage, 'setItem');
|
||||
|
||||
for (const delta of ['A', 'B', 'C']) {
|
||||
callbacks.onMessage({
|
||||
data: JSON.stringify({
|
||||
domain: 'LLM',
|
||||
payload: { delta },
|
||||
type: 'MESSAGE',
|
||||
}),
|
||||
runOptions?.onEvent({
|
||||
delta,
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
}
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(storageSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
JSON.stringify(
|
||||
agentChatRuntimeManager.getSnapshot('stream-session')?.items,
|
||||
@@ -179,21 +199,19 @@ describe('agentChatRuntimeManager', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(storageSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(storageSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
callbacks.onMessage({
|
||||
data: JSON.stringify({
|
||||
domain: 'LLM',
|
||||
payload: { delta: 'D' },
|
||||
type: 'MESSAGE',
|
||||
}),
|
||||
});
|
||||
listener.mockClear();
|
||||
storageSpy.mockClear();
|
||||
callbacks.onFinished();
|
||||
runOptions?.onEvent({
|
||||
runId: 'run-1',
|
||||
threadId: 'stream-session',
|
||||
type: EventType.RUN_FINISHED,
|
||||
});
|
||||
resolveRun?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(storageSpy).toHaveBeenCalledTimes(2);
|
||||
@@ -205,4 +223,141 @@ describe('agentChatRuntimeManager', () => {
|
||||
storageSpy.mockRestore();
|
||||
clearAgentChatBrowserCache(account);
|
||||
});
|
||||
|
||||
it('工具开始事件立即通知页面并保留调用中快照', async () => {
|
||||
vi.useFakeTimers();
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runOptions = options;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const account = {
|
||||
avatar: '',
|
||||
id: 'tool-status-user',
|
||||
loginName: 'tool-status-user',
|
||||
nickname: '工具状态用户',
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
useUserStore().setUserInfo(account);
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '生成文件',
|
||||
sessionId: 'tool-status-session',
|
||||
});
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = agentChatRuntimeManager.subscribe(listener);
|
||||
|
||||
runOptions?.onEvent({
|
||||
toolCallId: 'write-call-1',
|
||||
toolCallName: 'write_text_file',
|
||||
type: EventType.TOOL_CALL_START,
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
agentChatRuntimeManager
|
||||
.getSnapshot('tool-status-session')
|
||||
?.items.find(
|
||||
(item) => item.type === 'tool' && item.toolCallId === 'write-call-1',
|
||||
),
|
||||
).toEqual(expect.objectContaining({ status: 'running' }));
|
||||
|
||||
unsubscribe();
|
||||
clearAgentChatBrowserCache(account);
|
||||
});
|
||||
|
||||
it('停止后同会话重发不会接收旧运行的迟到事件', async () => {
|
||||
const runs: EasyFlowAguiRunOptions[] = [];
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runs.push(options);
|
||||
return new Promise(() => {});
|
||||
});
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'race-user',
|
||||
loginName: 'race-user',
|
||||
nickname: '竞态用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '旧问题',
|
||||
sessionId: 'race-session',
|
||||
});
|
||||
agentChatRuntimeManager.stop('race-session');
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '新问题',
|
||||
sessionId: 'race-session',
|
||||
});
|
||||
|
||||
runs[0]?.onEvent({
|
||||
delta: '旧流迟到正文',
|
||||
messageId: 'old-assistant',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
runs[1]?.onEvent({
|
||||
delta: '新流正文',
|
||||
messageId: 'new-assistant',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
|
||||
const snapshot = JSON.stringify(
|
||||
agentChatRuntimeManager.getSnapshot('race-session')?.items,
|
||||
);
|
||||
expect(snapshot).toContain('新流正文');
|
||||
expect(snapshot).not.toContain('旧流迟到正文');
|
||||
});
|
||||
|
||||
it('用户取消的 AG-UI 终态不会被异步完成回调改写为成功', async () => {
|
||||
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||
let resolveRun: (() => void) | undefined;
|
||||
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||
runOptions = options;
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRun = resolve;
|
||||
});
|
||||
});
|
||||
useUserStore().setUserInfo({
|
||||
avatar: '',
|
||||
id: 'cancel-user',
|
||||
loginName: 'cancel-user',
|
||||
nickname: '取消用户',
|
||||
tenantId: 'tenant-1',
|
||||
});
|
||||
|
||||
await agentChatRuntimeManager.start({
|
||||
agentId: 'agent-1',
|
||||
prompt: '需要审批的任务',
|
||||
sessionId: 'cancel-session',
|
||||
});
|
||||
runOptions?.onEvent({
|
||||
delta: '准备执行',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
runOptions?.onEvent({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户拒绝执行',
|
||||
runId: 'run-1',
|
||||
threadId: 'cancel-session',
|
||||
type: EventType.RUN_ERROR,
|
||||
});
|
||||
resolveRun?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const assistant = agentChatRuntimeManager
|
||||
.getSnapshot('cancel-session')
|
||||
?.items.find(
|
||||
(item) => item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
expect(assistant).toEqual(
|
||||
expect.objectContaining({
|
||||
turnSucceeded: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,21 +10,20 @@ import type { AgentChatCapabilityPayload } from './api';
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { EventType } from '@ag-ui/client';
|
||||
|
||||
import {
|
||||
onAgentChatCacheClear,
|
||||
resolveAgentChatIdentity,
|
||||
RUNTIME_STORAGE_PREFIX,
|
||||
} from '#/utils/agent-chat-cache';
|
||||
|
||||
import { EasyFlowAguiClient } from '../shared/agent-agui/client';
|
||||
import {
|
||||
applyAgentSseEnvelope,
|
||||
parseAgentSseMessage,
|
||||
} from './adapters/agentTimelineAdapter';
|
||||
import {
|
||||
generateAgentSessionId,
|
||||
sendAgentChat,
|
||||
stopAgentChatStream,
|
||||
} from './api';
|
||||
applyAguiEventToTimeline,
|
||||
createAguiTimelineProjectionState,
|
||||
} from '../shared/agent-agui/projection';
|
||||
import { generateAgentSessionId } from './api';
|
||||
|
||||
interface RuntimeSessionState {
|
||||
agentId: string;
|
||||
@@ -76,6 +75,7 @@ const sessions = new Map<string, RuntimeSessionState>();
|
||||
const listeners = new Set<() => void>();
|
||||
const latestSessionIds = new Map<string, string>();
|
||||
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const runClients = new Map<string, EasyFlowAguiClient>();
|
||||
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
@@ -221,7 +221,7 @@ function touchState(state: RuntimeSessionState) {
|
||||
sessions.set(sessionKey(state.identity, state.sessionId), state);
|
||||
}
|
||||
|
||||
function scheduleStateUpdate(state: RuntimeSessionState) {
|
||||
function scheduleStateUpdate(state: RuntimeSessionState, immediate = false) {
|
||||
touchState(state);
|
||||
const scopedSessionKey = sessionKey(state.identity, state.sessionId);
|
||||
if (!persistTimers.has(scopedSessionKey)) {
|
||||
@@ -236,7 +236,11 @@ function scheduleStateUpdate(state: RuntimeSessionState) {
|
||||
}, STREAM_PERSIST_INTERVAL_MS),
|
||||
);
|
||||
}
|
||||
scheduleNotify();
|
||||
if (immediate) {
|
||||
notifyNow();
|
||||
} else {
|
||||
scheduleNotify();
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSession(identity: string, sessionId: string) {
|
||||
@@ -454,6 +458,7 @@ export const agentChatRuntimeManager = {
|
||||
}
|
||||
const sessionId = await resolveSessionId(options.sessionId);
|
||||
const roundId = createRoundId();
|
||||
const startedAt = Date.now();
|
||||
const state: RuntimeSessionState = {
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
@@ -464,72 +469,121 @@ export const agentChatRuntimeManager = {
|
||||
roundId,
|
||||
sending: true,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: startedAt,
|
||||
};
|
||||
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
||||
documents: options.documents,
|
||||
images: options.images,
|
||||
roundId,
|
||||
});
|
||||
ChatTimelineBuilder.ensureAssistantTurn(state.items, {
|
||||
id: `turn-${roundId}`,
|
||||
roundId,
|
||||
turnStartedAt: startedAt,
|
||||
});
|
||||
upsertState(state);
|
||||
|
||||
void sendAgentChat(
|
||||
{
|
||||
agentId: options.agentId,
|
||||
capabilities: options.capabilities,
|
||||
documentUploadIds: options.documentUploadIds,
|
||||
imageUploadIds: options.imageUploadIds,
|
||||
prompt: options.prompt,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
current.error = errorMessage(error);
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
ChatTimelineBuilder.appendError(current.items, current.error);
|
||||
ChatTimelineBuilder.finalize(current.items);
|
||||
upsertState(current);
|
||||
const scopedSessionKey = sessionKey(identity, sessionId);
|
||||
const projectionState = createAguiTimelineProjectionState(startedAt);
|
||||
const runClient = new EasyFlowAguiClient();
|
||||
runClients.set(scopedSessionKey, runClient);
|
||||
void runClient
|
||||
.run({
|
||||
forwardedProps: {
|
||||
easyflow: {
|
||||
input: {
|
||||
capabilities: options.capabilities,
|
||||
documentUploadIds: options.documentUploadIds,
|
||||
imageUploadIds: options.imageUploadIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
onFinished() {
|
||||
onEvent(event) {
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
ChatTimelineBuilder.finalize(current.items);
|
||||
upsertState(current);
|
||||
},
|
||||
onMessage(message) {
|
||||
const current = sessions.get(sessionKey(identity, sessionId));
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
const envelope = parseAgentSseMessage(message);
|
||||
if (!envelope) {
|
||||
if (!current || current !== state || !current.sending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
envelope.domain === 'SYSTEM' &&
|
||||
envelope.type === 'INPUT_ACCEPTED'
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code !== 'RUN_CANCELLED'
|
||||
) {
|
||||
replaceAcceptedAttachments(
|
||||
current.items,
|
||||
roundId,
|
||||
envelope.payload,
|
||||
);
|
||||
void options.onInputAccepted?.();
|
||||
current.error = event.message || '发送失败,请稍后再试';
|
||||
}
|
||||
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
||||
scheduleStateUpdate(current);
|
||||
applyAguiEventToTimeline(
|
||||
current.items,
|
||||
event,
|
||||
{
|
||||
onInputAccepted(payload) {
|
||||
replaceAcceptedAttachments(current.items, roundId, payload);
|
||||
void options.onInputAccepted?.();
|
||||
},
|
||||
roundId,
|
||||
startedAt,
|
||||
},
|
||||
projectionState,
|
||||
);
|
||||
scheduleStateUpdate(
|
||||
current,
|
||||
event.type === EventType.TOOL_CALL_START,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
threadId: sessionId,
|
||||
url: `/api/v1/agent/${encodeURIComponent(options.agentId)}/agui/run`,
|
||||
userMessage: {
|
||||
content: options.prompt,
|
||||
id: `user-${roundId}`,
|
||||
role: 'user',
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (!current || current !== state || !current.sending) {
|
||||
return;
|
||||
}
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
const cancelled = current.items.some(
|
||||
(item) =>
|
||||
item.roundId === roundId &&
|
||||
item.turnFinishedAt !== undefined &&
|
||||
item.turnSucceeded === false,
|
||||
);
|
||||
if (!cancelled) {
|
||||
ChatTimelineBuilder.finalize(current.items, {
|
||||
roundCompleted: true,
|
||||
roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: true,
|
||||
});
|
||||
}
|
||||
upsertState(current);
|
||||
})
|
||||
.catch((error) => {
|
||||
const current = sessions.get(scopedSessionKey);
|
||||
if (!current || current !== state || !current.sending) {
|
||||
return;
|
||||
}
|
||||
current.error = errorMessage(error);
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
const last = current.items[current.items.length - 1];
|
||||
if (last?.type !== 'error') {
|
||||
ChatTimelineBuilder.appendError(current.items, current.error, {
|
||||
roundId,
|
||||
});
|
||||
}
|
||||
ChatTimelineBuilder.finalize(current.items, {
|
||||
roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: false,
|
||||
});
|
||||
upsertState(current);
|
||||
})
|
||||
.finally(() => {
|
||||
if (runClients.get(scopedSessionKey) === runClient) {
|
||||
runClients.delete(scopedSessionKey);
|
||||
}
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
},
|
||||
@@ -542,10 +596,15 @@ export const agentChatRuntimeManager = {
|
||||
if (!state || !state.sending) {
|
||||
return;
|
||||
}
|
||||
stopAgentChatStream();
|
||||
runClients.get(sessionKey(identity, state.sessionId))?.abort();
|
||||
runClients.delete(sessionKey(identity, state.sessionId));
|
||||
state.sending = false;
|
||||
state.completed = true;
|
||||
ChatTimelineBuilder.finalize(state.items);
|
||||
ChatTimelineBuilder.finalize(state.items, {
|
||||
roundId: state.roundId,
|
||||
turnFinishedAt: Date.now(),
|
||||
turnSucceeded: false,
|
||||
});
|
||||
upsertState(state);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type { AgentInfo } from '../agents/types';
|
||||
|
||||
import { api, SseClient } from '#/api/request';
|
||||
|
||||
const agentChatSseClient = new SseClient();
|
||||
import { api } from '#/api/request';
|
||||
|
||||
export interface RequestResult<T = any> {
|
||||
data: T;
|
||||
@@ -144,43 +140,17 @@ export function deleteAgentSession(sessionId: number | string) {
|
||||
return api.post<RequestResult>(`/api/v1/agent/session/${sessionId}/delete`);
|
||||
}
|
||||
|
||||
export function approveAgentRun(requestId: string, resumeToken: string) {
|
||||
return api.post<RequestResult>('/api/v1/agent/run/approve', {
|
||||
requestId,
|
||||
resumeToken,
|
||||
export function approveAgentRun(approvalId: string) {
|
||||
return api.post<RequestResult>('/api/v1/agent/agui/hitl/resolve', {
|
||||
approvalId,
|
||||
decision: 'APPROVE',
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectAgentRun(
|
||||
requestId: string,
|
||||
resumeToken: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return api.post<RequestResult>('/api/v1/agent/run/reject', {
|
||||
requestId,
|
||||
resumeToken,
|
||||
export function rejectAgentRun(approvalId: string, reason?: string) {
|
||||
return api.post<RequestResult>('/api/v1/agent/agui/hitl/resolve', {
|
||||
approvalId,
|
||||
decision: 'REJECT',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendAgentChat(
|
||||
data: {
|
||||
agentId: number | string;
|
||||
capabilities?: AgentChatCapabilityPayload[];
|
||||
documentUploadIds?: string[];
|
||||
imageUploadIds?: string[];
|
||||
prompt: string;
|
||||
sessionId?: number | string;
|
||||
},
|
||||
options: {
|
||||
onError?: (error: unknown) => void;
|
||||
onFinished?: () => void;
|
||||
onMessage?: (message: ServerSentEventMessage) => void;
|
||||
},
|
||||
) {
|
||||
return agentChatSseClient.post('/api/v1/agent/chat', data, options);
|
||||
}
|
||||
|
||||
export function stopAgentChatStream() {
|
||||
agentChatSseClient.abort();
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
createAgentArtifactLoader,
|
||||
loadAgentChatDocument,
|
||||
loadAgentChatImage,
|
||||
} from '#/components/ai-chat/mediaApi';
|
||||
@@ -91,6 +92,11 @@ const selectedAgentId = ref('');
|
||||
const currentSessionId = ref('');
|
||||
const composer = useAgentComposerDraft('FORMAL');
|
||||
const promptText = composer.text;
|
||||
const loadCurrentAgentArtifact = createAgentArtifactLoader(() => ({
|
||||
agentId: selectedAgentId.value,
|
||||
mode: 'FORMAL',
|
||||
sessionId: currentSessionId.value,
|
||||
}));
|
||||
const promptInputRef = ref();
|
||||
const attachmentFileInputRef = ref<HTMLInputElement>();
|
||||
const composerDragActive = ref(false);
|
||||
@@ -1043,11 +1049,11 @@ async function handleDeleteSession(session: AgentChatSessionView) {
|
||||
}
|
||||
|
||||
async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
|
||||
approvalLoadingKey.value = payload.toolCallId || payload.requestId;
|
||||
approvalLoadingKey.value = payload.toolCallId || payload.approvalId;
|
||||
ChatTimelineBuilder.markToolApproving(timelineItems.value, payload);
|
||||
persistCurrentRuntimeItems();
|
||||
try {
|
||||
const res = await approveAgentRun(payload.requestId, payload.resumeToken);
|
||||
const res = await approveAgentRun(payload.approvalId);
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || '批准失败');
|
||||
}
|
||||
@@ -1062,15 +1068,11 @@ async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
|
||||
}
|
||||
|
||||
async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
||||
approvalLoadingKey.value = payload.toolCallId || payload.requestId;
|
||||
approvalLoadingKey.value = payload.toolCallId || payload.approvalId;
|
||||
ChatTimelineBuilder.markToolApproving(timelineItems.value, payload);
|
||||
persistCurrentRuntimeItems();
|
||||
try {
|
||||
const res = await rejectAgentRun(
|
||||
payload.requestId,
|
||||
payload.resumeToken,
|
||||
'用户拒绝执行',
|
||||
);
|
||||
const res = await rejectAgentRun(payload.approvalId, '用户拒绝执行');
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || '拒绝失败');
|
||||
}
|
||||
@@ -1242,6 +1244,7 @@ onBeforeUnmount(() => {
|
||||
<ChatTimeline
|
||||
v-else
|
||||
:assistant-avatar="selectedAgent?.avatar || defaultAssistantAvatar"
|
||||
:artifact-loader="loadCurrentAgentArtifact"
|
||||
:items="timelineItems"
|
||||
:document-loader="loadAgentChatDocument"
|
||||
:image-loader="loadAgentChatImage"
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
/* cspell:ignore tryit */
|
||||
import type {
|
||||
AgentBuiltinToolCapabilities,
|
||||
AgentCapabilityKind,
|
||||
AgentOption,
|
||||
AgentValidationIssue,
|
||||
} from './types';
|
||||
|
||||
import { computed, onActivated, onDeactivated, onMounted, ref } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
@@ -26,34 +36,40 @@ import {
|
||||
getAgentDetail,
|
||||
getAgentMcpToolOptions,
|
||||
getAgentResourceOptions,
|
||||
saveAgent,
|
||||
saveAgentDraft,
|
||||
submitAgentOfflineApproval,
|
||||
submitAgentPublishApproval,
|
||||
updateAgent,
|
||||
updateAgentKnowledgeBindings,
|
||||
updateAgentToolBindings,
|
||||
} from './api';
|
||||
import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
|
||||
import AgentCommandBar from './components/AgentCommandBar.vue';
|
||||
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
||||
import AgentSkillSelectorDialog from './components/AgentSkillSelectorDialog.vue';
|
||||
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
||||
import { resolveAgentCompressionTokenThreshold } from './compression-threshold';
|
||||
import { createMcpToolLoader } from './mcpToolLoader';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const AGENT_TAB_PAGE_KEY = '/ai/agents';
|
||||
const DEFAULT_AGENT_TITLE = '未命名智能体';
|
||||
const {
|
||||
state,
|
||||
addKnowledgeNode,
|
||||
appendSkillOptions,
|
||||
addToolNode,
|
||||
buildKnowledgePayload,
|
||||
buildPayloadAgent,
|
||||
buildSkillPayload,
|
||||
buildToolPayload,
|
||||
commitBindingBaseline,
|
||||
getBindingChanges,
|
||||
markDirty,
|
||||
moveSkill,
|
||||
openTryout,
|
||||
removeSelectedCapability,
|
||||
removeSkill,
|
||||
replaceSkillBindings,
|
||||
reset,
|
||||
selectBase,
|
||||
selectNode,
|
||||
@@ -66,6 +82,9 @@ const canvasActive = ref(true);
|
||||
const saveLoading = ref(false);
|
||||
const offlineLoading = ref(false);
|
||||
const publishLoading = ref(false);
|
||||
const skillOptionsLoading = ref(true);
|
||||
const skillOptionsError = ref('');
|
||||
const skillSelectorOpen = ref(false);
|
||||
const mcpToolsLoading = ref<Record<string, boolean>>({});
|
||||
const issues = ref<AgentValidationIssue[]>([]);
|
||||
const categories = ref<AgentOption[]>([]);
|
||||
@@ -74,12 +93,41 @@ const knowledges = ref<AgentOption[]>([]);
|
||||
const workflows = ref<AgentOption[]>([]);
|
||||
const pluginTools = ref<AgentOption[]>([]);
|
||||
const mcps = ref<AgentOption[]>([]);
|
||||
const skills = ref<AgentOption[]>([]);
|
||||
const builtinToolCapabilities = ref<AgentBuiltinToolCapabilities>({});
|
||||
const fetchMcpToolResource = createMcpToolLoader(async (id) => {
|
||||
const res = await getAgentMcpToolOptions(id);
|
||||
return res.errorCode === 0 ? res.data : undefined;
|
||||
});
|
||||
|
||||
const isNew = computed(() => String(route.params.id || '') === 'new');
|
||||
const canDisableShellApproval = computed(() => {
|
||||
const serverCapability =
|
||||
state.agent.builtinToolCapabilities?.canDisableShellApproval ??
|
||||
builtinToolCapabilities.value.canDisableShellApproval;
|
||||
if (typeof serverCapability === 'boolean') {
|
||||
return serverCapability;
|
||||
}
|
||||
return (
|
||||
String(userStore.userInfo?.id || '') === '1' ||
|
||||
userStore.userRoles.includes('super_admin')
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
canDisableShellApproval,
|
||||
() => state.agent.executionConfigJson?.builtinTools,
|
||||
],
|
||||
([canDisable]) => {
|
||||
const builtinTools = state.agent.executionConfigJson?.builtinTools;
|
||||
if (!canDisable && builtinTools) {
|
||||
builtinTools.shell.approvalRequired = true;
|
||||
delete builtinTools.shellApprovalRiskConfirmed;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const publishText = computed(() => {
|
||||
if (
|
||||
canAiResourceRepublish(
|
||||
@@ -164,6 +212,7 @@ async function refreshAgentLifecycleState() {
|
||||
}
|
||||
const agentState = { ...res.data };
|
||||
delete agentState.knowledgeBindings;
|
||||
delete agentState.skillBindings;
|
||||
delete agentState.toolBindings;
|
||||
state.agent = {
|
||||
...state.agent,
|
||||
@@ -211,11 +260,11 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
||||
}
|
||||
|
||||
async function loadCriticalOptions() {
|
||||
const [categoryResult, resourceResult] = await Promise.allSettled([
|
||||
const [categoryResult] = await Promise.allSettled([
|
||||
api.get('/api/v1/agentCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
}),
|
||||
getAgentResourceOptions(),
|
||||
loadResourceOptions(),
|
||||
]);
|
||||
|
||||
if (categoryResult.status === 'fulfilled') {
|
||||
@@ -225,11 +274,19 @@ async function loadCriticalOptions() {
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
if (resourceResult.status === 'fulfilled') {
|
||||
const resources = resourceResult.value.data;
|
||||
if (resourceResult.value.errorCode !== 0 || !resources) {
|
||||
}
|
||||
|
||||
async function loadResourceOptions() {
|
||||
skillOptionsLoading.value = true;
|
||||
skillOptionsError.value = '';
|
||||
try {
|
||||
const res = await getAgentResourceOptions();
|
||||
const resources = res.data;
|
||||
if (res.errorCode !== 0 || !resources) {
|
||||
skillOptionsError.value = res.message || '技能列表加载失败';
|
||||
return;
|
||||
}
|
||||
builtinToolCapabilities.value = resources.capabilities || {};
|
||||
models.value = (resources.models || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
@@ -240,6 +297,11 @@ async function loadCriticalOptions() {
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
skills.value = (resources.skills || []).map((item: any) => ({
|
||||
label: item.displayName || item.name || '技能',
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
workflows.value = (resources.workflows || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
@@ -251,6 +313,11 @@ async function loadCriticalOptions() {
|
||||
raw: item,
|
||||
}));
|
||||
mcps.value = mapMcpOptions(resources.mcps || []);
|
||||
} catch (error) {
|
||||
console.error('加载 Agent 资源选项失败', error);
|
||||
skillOptionsError.value = '技能列表加载失败';
|
||||
} finally {
|
||||
skillOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +386,14 @@ async function loadMcpToolsForOption(id: number | string) {
|
||||
}
|
||||
|
||||
async function handleAdd(kind: AgentCapabilityKind) {
|
||||
if (kind === 'skill') {
|
||||
if (state.skillBindings.length > 0) {
|
||||
selectNode('skills');
|
||||
} else {
|
||||
skillSelectorOpen.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === 'knowledge') {
|
||||
addKnowledgeNode();
|
||||
return;
|
||||
@@ -326,6 +401,10 @@ async function handleAdd(kind: AgentCapabilityKind) {
|
||||
addToolNode(kind);
|
||||
}
|
||||
|
||||
function handleAddSkills(options: AgentOption[]) {
|
||||
appendSkillOptions(options);
|
||||
}
|
||||
|
||||
function handleSelectNode(nodeId: string) {
|
||||
selectNode(nodeId);
|
||||
}
|
||||
@@ -365,35 +444,39 @@ async function handleSave(showMessage = true) {
|
||||
syncAgentCompressionThreshold();
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
const agentPayload = buildPayloadAgent();
|
||||
const agentRes = state.agent.id
|
||||
? await updateAgent(agentPayload)
|
||||
: await saveAgent(agentPayload);
|
||||
const agentPayload = buildPayloadAgent({
|
||||
canDisableShellApproval: canDisableShellApproval.value,
|
||||
});
|
||||
const bindingChanges = getBindingChanges();
|
||||
const agentRes = await saveAgentDraft({
|
||||
agent: agentPayload,
|
||||
knowledgeBindings: buildKnowledgePayload(state.agent.id),
|
||||
replaceKnowledgeBindings: bindingChanges.knowledge,
|
||||
replaceSkillBindings: bindingChanges.skill,
|
||||
replaceToolBindings: bindingChanges.tool,
|
||||
skillBindings: buildSkillPayload(),
|
||||
toolBindings: buildToolPayload(state.agent.id),
|
||||
});
|
||||
if (agentRes.errorCode !== 0 || !agentRes.data?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = agentRes.data.id;
|
||||
const toolBindingRes = await updateAgentToolBindings(
|
||||
id,
|
||||
buildToolPayload(id),
|
||||
);
|
||||
if (toolBindingRes.errorCode !== 0) {
|
||||
return false;
|
||||
if (bindingChanges.skill) {
|
||||
replaceSkillBindings(agentRes.data.skillBindings || []);
|
||||
}
|
||||
const knowledgeBindingRes = await updateAgentKnowledgeBindings(
|
||||
id,
|
||||
buildKnowledgePayload(id),
|
||||
);
|
||||
if (knowledgeBindingRes.errorCode !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
knowledgeBindings: _knowledgeBindings,
|
||||
skillBindings: _skillBindings,
|
||||
toolBindings: _toolBindings,
|
||||
...savedAgent
|
||||
} = agentRes.data;
|
||||
state.agent = {
|
||||
...state.agent,
|
||||
...agentRes.data,
|
||||
...savedAgent,
|
||||
id,
|
||||
};
|
||||
commitBindingBaseline();
|
||||
state.dirty = false;
|
||||
const title = resolveAgentTitle();
|
||||
if (isNew.value) {
|
||||
@@ -513,6 +596,7 @@ async function handleBack() {
|
||||
@select="handleSelectNode"
|
||||
/>
|
||||
<AgentInspectorPanel
|
||||
:can-disable-shell-approval="canDisableShellApproval"
|
||||
:state="state"
|
||||
:models="models"
|
||||
:categories="categories"
|
||||
@@ -522,12 +606,26 @@ async function handleBack() {
|
||||
:mcps="mcps"
|
||||
:mcp-tools-loading="mcpToolsLoading"
|
||||
:issues="issues"
|
||||
@add-skills="skillSelectorOpen = true"
|
||||
@change="markDirty"
|
||||
@remove-capability="removeSelectedCapability"
|
||||
@close-tryout="handleCloseTryout"
|
||||
@load-mcp-tools="loadMcpToolsForOption"
|
||||
@move-skill="moveSkill"
|
||||
@remove-skill="removeSkill"
|
||||
@select-issue="handleSelectIssue"
|
||||
/>
|
||||
<AgentSkillSelectorDialog
|
||||
v-model="skillSelectorOpen"
|
||||
:bound-skill-ids="
|
||||
state.skillBindings.map((binding) => String(binding.skillId || ''))
|
||||
"
|
||||
:error="skillOptionsError"
|
||||
:loading="skillOptionsLoading"
|
||||
:options="skills"
|
||||
@add="handleAddSkills"
|
||||
@retry="loadResourceOptions"
|
||||
/>
|
||||
<AgentCommandBar
|
||||
:save-loading="saveLoading"
|
||||
:publish-loading="publishLoading"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import baseFormSource from './components/AgentBaseForm.vue?raw';
|
||||
import inspectorSource from './components/AgentInspectorPanel.vue?raw';
|
||||
|
||||
describe('agent Studio responsive layout contract', () => {
|
||||
it('keeps the inspector usable at the 768px breakpoint', () => {
|
||||
expect(inspectorSource).toContain('@media (max-width: 900px)');
|
||||
expect(inspectorSource).toMatch(
|
||||
/@media \(max-width: 900px\)[\s\S]*?left: var\(--space-4\);[\s\S]*?width: auto;/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the inspector and builtin tool controls usable at 375px', () => {
|
||||
expect(inspectorSource).toMatch(
|
||||
/@media \(max-width: 480px\)[\s\S]*?right: var\(--space-2\);[\s\S]*?left: var\(--space-2\);/,
|
||||
);
|
||||
expect(baseFormSource).toMatch(
|
||||
/@media \(max-width: 480px\)[\s\S]*?--agent-tool-approval-column: 72px;/,
|
||||
);
|
||||
expect(baseFormSource).toContain('white-space: normal;');
|
||||
expect(baseFormSource).toContain('-webkit-line-clamp: 2;');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AgentBuiltinToolCapabilities,
|
||||
AgentInfo,
|
||||
AgentKnowledgeBinding,
|
||||
AgentSkillBinding,
|
||||
AgentToolBinding,
|
||||
} from './types';
|
||||
|
||||
@@ -26,6 +28,23 @@ export function updateAgent(agent: AgentInfo) {
|
||||
return api.post<RequestResult<AgentInfo>>('/api/v1/agent/update', agent);
|
||||
}
|
||||
|
||||
export interface AgentDraftSavePayload {
|
||||
agent: AgentInfo;
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
replaceKnowledgeBindings: boolean;
|
||||
replaceSkillBindings: boolean;
|
||||
replaceToolBindings: boolean;
|
||||
skillBindings: AgentSkillBinding[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
}
|
||||
|
||||
export function saveAgentDraft(payload: AgentDraftSavePayload) {
|
||||
return api.post<RequestResult<AgentInfo>>(
|
||||
'/api/v1/agent/draft/save',
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAgentVisibilityScope(
|
||||
id: number | string,
|
||||
visibilityScope: string,
|
||||
@@ -56,6 +75,22 @@ export function updateAgentKnowledgeBindings(
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAgentSkillBindings(
|
||||
agentId: number | string,
|
||||
bindings: AgentSkillBinding[],
|
||||
) {
|
||||
return api.post<RequestResult<AgentSkillBinding[]>>(
|
||||
'/api/v1/agent/skillBinding/update',
|
||||
{
|
||||
agentId,
|
||||
bindings: bindings.map((binding, index) => ({
|
||||
skillId: binding.skillId,
|
||||
sortNo: index + 1,
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function submitAgentPublishApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
'/api/v1/agent/submitPublishApproval',
|
||||
@@ -77,21 +112,17 @@ export function submitAgentDeleteApproval(id: number | string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function approveAgentRun(requestId: string, resumeToken: string) {
|
||||
return api.post<RequestResult>('/api/v1/agent/run/approve', {
|
||||
requestId,
|
||||
resumeToken,
|
||||
export function approveAgentRun(approvalId: string) {
|
||||
return api.post<RequestResult>('/api/v1/agent/agui/hitl/resolve', {
|
||||
approvalId,
|
||||
decision: 'APPROVE',
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectAgentRun(
|
||||
requestId: string,
|
||||
resumeToken: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return api.post<RequestResult>('/api/v1/agent/run/reject', {
|
||||
requestId,
|
||||
resumeToken,
|
||||
export function rejectAgentRun(approvalId: string, reason?: string) {
|
||||
return api.post<RequestResult>('/api/v1/agent/agui/hitl/resolve', {
|
||||
approvalId,
|
||||
decision: 'REJECT',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
@@ -109,10 +140,12 @@ export function getAgentCategories() {
|
||||
}
|
||||
|
||||
export interface AgentResourceOptions {
|
||||
capabilities?: AgentBuiltinToolCapabilities;
|
||||
knowledges: any[];
|
||||
mcps: any[];
|
||||
models: any[];
|
||||
pluginTools: any[];
|
||||
skills: any[];
|
||||
workflows: any[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAgentExecutionConfig,
|
||||
createDefaultBuiltinTools,
|
||||
normalizeBuiltinTools,
|
||||
} from './builtin-tools';
|
||||
|
||||
describe('agent 内置工具配置', () => {
|
||||
it('新建 Agent 默认启用五项工具且仅 Shell 要求确认', () => {
|
||||
expect(createDefaultBuiltinTools()).toEqual({
|
||||
artifactPublish: { approvalRequired: false, enabled: true },
|
||||
patch: { approvalRequired: false, enabled: true },
|
||||
read: { approvalRequired: false, enabled: true },
|
||||
schemaVersion: 1,
|
||||
shell: { approvalRequired: true, enabled: true },
|
||||
write: { approvalRequired: false, enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('旧草稿仅在构造保存载荷时补齐完整配置', () => {
|
||||
const source = { documentContextBudgetTokens: 32_000 };
|
||||
|
||||
const normalized = buildAgentExecutionConfig(source, false);
|
||||
|
||||
expect(source).not.toHaveProperty('builtinTools');
|
||||
expect(normalized.builtinTools).toEqual(createDefaultBuiltinTools());
|
||||
expect(normalized.documentContextBudgetTokens).toBe(32_000);
|
||||
});
|
||||
|
||||
it('保留显式开关,并强制普通用户开启 Shell 调用确认', () => {
|
||||
const builtinTools = normalizeBuiltinTools({
|
||||
read: { enabled: false },
|
||||
shell: { approvalRequired: false, enabled: true },
|
||||
shellApprovalRiskConfirmed: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildAgentExecutionConfig({ builtinTools }, false).builtinTools,
|
||||
).toMatchObject({
|
||||
read: { approvalRequired: false, enabled: false },
|
||||
shell: { approvalRequired: true, enabled: true },
|
||||
});
|
||||
expect(
|
||||
buildAgentExecutionConfig({ builtinTools }, false).builtinTools,
|
||||
).not.toHaveProperty('shellApprovalRiskConfirmed');
|
||||
expect(
|
||||
buildAgentExecutionConfig({ builtinTools }, true).builtinTools?.shell
|
||||
.approvalRequired,
|
||||
).toBe(false);
|
||||
expect(
|
||||
buildAgentExecutionConfig({ builtinTools }, true).builtinTools
|
||||
?.shellApprovalRiskConfirmed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
106
easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.ts
Normal file
106
easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
AgentBuiltinToolConfig,
|
||||
AgentBuiltinToolsConfig,
|
||||
AgentExecutionConfig,
|
||||
} from './types';
|
||||
|
||||
export const AGENT_BUILTIN_TOOL_KEYS = [
|
||||
'read',
|
||||
'write',
|
||||
'patch',
|
||||
'shell',
|
||||
'artifactPublish',
|
||||
] as const;
|
||||
|
||||
export type AgentBuiltinToolKey = (typeof AGENT_BUILTIN_TOOL_KEYS)[number];
|
||||
|
||||
type AgentBuiltinToolsInput = Partial<
|
||||
Record<AgentBuiltinToolKey, Partial<AgentBuiltinToolConfig>>
|
||||
> & {
|
||||
schemaVersion?: number;
|
||||
shellApprovalRiskConfirmed?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_BUILTIN_TOOLS: AgentBuiltinToolsConfig = {
|
||||
schemaVersion: 1,
|
||||
read: { approvalRequired: false, enabled: true },
|
||||
write: { approvalRequired: false, enabled: true },
|
||||
patch: { approvalRequired: false, enabled: true },
|
||||
shell: { approvalRequired: true, enabled: true },
|
||||
artifactPublish: { approvalRequired: false, enabled: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新 Agent 使用的内置工具默认配置。
|
||||
*
|
||||
* @returns 五项工具全部启用、仅 Shell 默认确认的独立配置副本。
|
||||
*/
|
||||
export function createDefaultBuiltinTools(): AgentBuiltinToolsConfig {
|
||||
return {
|
||||
artifactPublish: { ...DEFAULT_BUILTIN_TOOLS.artifactPublish },
|
||||
patch: { ...DEFAULT_BUILTIN_TOOLS.patch },
|
||||
read: { ...DEFAULT_BUILTIN_TOOLS.read },
|
||||
schemaVersion: 1,
|
||||
shell: { ...DEFAULT_BUILTIN_TOOLS.shell },
|
||||
write: { ...DEFAULT_BUILTIN_TOOLS.write },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将接口中的内置工具配置补全为稳定的五项结构。
|
||||
*
|
||||
* @param value 接口返回的可选配置。
|
||||
* @returns 可直接用于表单和保存载荷的规范化配置。
|
||||
*/
|
||||
export function normalizeBuiltinTools(
|
||||
value?: AgentBuiltinToolsInput,
|
||||
): AgentBuiltinToolsConfig {
|
||||
const defaults = createDefaultBuiltinTools();
|
||||
const normalizeTool = (key: AgentBuiltinToolKey) => ({
|
||||
approvalRequired:
|
||||
value?.[key]?.approvalRequired ?? defaults[key].approvalRequired,
|
||||
enabled: value?.[key]?.enabled ?? defaults[key].enabled,
|
||||
});
|
||||
const normalized: AgentBuiltinToolsConfig = {
|
||||
artifactPublish: normalizeTool('artifactPublish'),
|
||||
patch: normalizeTool('patch'),
|
||||
read: normalizeTool('read'),
|
||||
schemaVersion: 1,
|
||||
shell: normalizeTool('shell'),
|
||||
write: normalizeTool('write'),
|
||||
};
|
||||
if (value && 'shellApprovalRiskConfirmed' in value) {
|
||||
normalized.shellApprovalRiskConfirmed = Boolean(
|
||||
value.shellApprovalRiskConfirmed,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造保存和草稿试用共用的执行配置。
|
||||
*
|
||||
* @param value 当前执行配置。
|
||||
* @param canDisableShellApproval 当前用户是否可关闭 Shell 调用确认。
|
||||
* @returns 已补齐默认值并落实 Shell 权限边界的配置。
|
||||
*/
|
||||
export function buildAgentExecutionConfig(
|
||||
value: AgentExecutionConfig | undefined,
|
||||
canDisableShellApproval: boolean,
|
||||
): AgentExecutionConfig {
|
||||
const builtinTools = normalizeBuiltinTools(value?.builtinTools);
|
||||
if (!canDisableShellApproval) {
|
||||
builtinTools.shell.approvalRequired = true;
|
||||
}
|
||||
if (builtinTools.shell.approvalRequired) {
|
||||
delete builtinTools.shellApprovalRiskConfirmed;
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
builtinTools,
|
||||
documentContextBudgetTokens: Math.max(
|
||||
1,
|
||||
Math.trunc(Number(value?.documentContextBudgetTokens) || 20_000),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-mutating-props */
|
||||
import type { AgentBuiltinToolKey } from '../builtin-tools';
|
||||
import type { AgentInfo, AgentOption, AgentToolBinding } from '../types';
|
||||
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessageBox,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
@@ -19,6 +21,7 @@ import { resolveAgentCompressionTokenThreshold } from '../compression-threshold'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentInfo;
|
||||
canDisableShellApproval: boolean;
|
||||
categories: AgentOption[];
|
||||
models: AgentOption[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
@@ -32,6 +35,92 @@ const visibilityScopeOptions = [
|
||||
{ label: '公开', value: 'PUBLIC' },
|
||||
];
|
||||
|
||||
const builtinToolOptions: Array<{
|
||||
description: string;
|
||||
key: AgentBuiltinToolKey;
|
||||
label: string;
|
||||
}> = [
|
||||
{ description: '读取工作区内的 UTF-8 文本', key: 'read', label: '读取文件' },
|
||||
{ description: '在工作区内写入或插入文本', key: 'write', label: '写入文件' },
|
||||
{
|
||||
description: '以补丁方式安全修改工作区文件',
|
||||
key: 'patch',
|
||||
label: '补丁修改',
|
||||
},
|
||||
{ description: '执行平台白名单中的受控命令', key: 'shell', label: 'Shell' },
|
||||
{
|
||||
description: '将工作区文件发布为可下载产物',
|
||||
key: 'artifactPublish',
|
||||
label: '发布产物',
|
||||
},
|
||||
];
|
||||
|
||||
function builtinTool(key: AgentBuiltinToolKey) {
|
||||
return props.agent.executionConfigJson!.builtinTools![key];
|
||||
}
|
||||
|
||||
function handleBuiltinToolEnabledChange(key: AgentBuiltinToolKey) {
|
||||
if (key === 'shell' && !props.canDisableShellApproval) {
|
||||
builtinTool('shell').approvalRequired = true;
|
||||
delete props.agent.executionConfigJson!.builtinTools!
|
||||
.shellApprovalRiskConfirmed;
|
||||
}
|
||||
emit('change');
|
||||
}
|
||||
|
||||
async function confirmUnsafeShellExecution() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'关闭后,命令和脚本将以后端服务权限在宿主环境运行,当前没有沙箱隔离。确认继续?',
|
||||
'关闭 Shell 调用确认',
|
||||
{
|
||||
cancelButtonText: '保持开启',
|
||||
confirmButtonText: '确认关闭',
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
props.agent.executionConfigJson!.builtinTools!.shellApprovalRiskConfirmed = true;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBuiltinToolEnabledChange(key: AgentBuiltinToolKey) {
|
||||
if (
|
||||
key !== 'shell' ||
|
||||
builtinTool('shell').enabled ||
|
||||
builtinTool('shell').approvalRequired
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!props.canDisableShellApproval) {
|
||||
builtinTool('shell').approvalRequired = true;
|
||||
return true;
|
||||
}
|
||||
return confirmUnsafeShellExecution();
|
||||
}
|
||||
|
||||
async function confirmShellApprovalChange() {
|
||||
if (!props.canDisableShellApproval) {
|
||||
return false;
|
||||
}
|
||||
if (!builtinTool('shell').approvalRequired) {
|
||||
delete props.agent.executionConfigJson!.builtinTools!
|
||||
.shellApprovalRiskConfirmed;
|
||||
return true;
|
||||
}
|
||||
return confirmUnsafeShellExecution();
|
||||
}
|
||||
|
||||
function handleApprovalChange(key: AgentBuiltinToolKey, value: unknown) {
|
||||
if (key === 'shell' && value === true) {
|
||||
delete props.agent.executionConfigJson!.builtinTools!
|
||||
.shellApprovalRiskConfirmed;
|
||||
}
|
||||
emit('change');
|
||||
}
|
||||
|
||||
function handleModelChange(modelId: AgentInfo['modelId']) {
|
||||
const selectedModel = props.models.find(
|
||||
(model) => model.value === String(modelId ?? ''),
|
||||
@@ -179,6 +268,66 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
|
||||
@input="emit('change')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<section
|
||||
class="agent-form__builtin-tools"
|
||||
aria-labelledby="builtin-tools-title"
|
||||
>
|
||||
<div class="agent-form__section-head">
|
||||
<div>
|
||||
<div id="builtin-tools-title" class="agent-form__section-title">
|
||||
内置工具
|
||||
</div>
|
||||
<div class="agent-form__section-description">
|
||||
为当前智能体配置工作区能力
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-form__tool-columns" aria-hidden="true">
|
||||
<span>启用</span>
|
||||
<span>调用前确认</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-form__tool-list">
|
||||
<div
|
||||
v-for="item in builtinToolOptions"
|
||||
:key="item.key"
|
||||
class="agent-form__tool-row"
|
||||
>
|
||||
<div class="agent-form__tool-copy">
|
||||
<div class="agent-form__tool-name">{{ item.label }}</div>
|
||||
<div class="agent-form__tool-description">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
<ElSwitch
|
||||
v-model="builtinTool(item.key).enabled"
|
||||
:aria-label="`启用${item.label}`"
|
||||
:before-change="() => confirmBuiltinToolEnabledChange(item.key)"
|
||||
@change="handleBuiltinToolEnabledChange(item.key)"
|
||||
/>
|
||||
<ElTooltip
|
||||
:disabled="item.key !== 'shell' || canDisableShellApproval"
|
||||
content="Shell 调用确认仅平台超级管理员可以关闭"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<span class="agent-form__tool-approval">
|
||||
<ElSwitch
|
||||
v-model="builtinTool(item.key).approvalRequired"
|
||||
:aria-label="`${item.label}调用前确认`"
|
||||
:before-change="
|
||||
item.key === 'shell' ? confirmShellApprovalChange : undefined
|
||||
"
|
||||
:disabled="
|
||||
!builtinTool(item.key).enabled ||
|
||||
(item.key === 'shell' && !canDisableShellApproval)
|
||||
"
|
||||
@change="handleApprovalChange(item.key, $event)"
|
||||
/>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="agent-form__grid">
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
@@ -238,7 +387,7 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
|
||||
|
||||
<style scoped>
|
||||
.agent-form {
|
||||
padding: 16px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.agent-form__grid {
|
||||
@@ -247,6 +396,89 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-form__builtin-tools {
|
||||
--agent-tool-approval-column: 96px;
|
||||
--agent-tool-enabled-column: 48px;
|
||||
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.agent-form__section-head {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(0, 1fr)
|
||||
calc(var(--agent-tool-enabled-column) + var(--agent-tool-approval-column));
|
||||
gap: var(--space-3);
|
||||
align-items: end;
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.agent-form__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.agent-form__section-description,
|
||||
.agent-form__tool-description,
|
||||
.agent-form__tool-columns {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.agent-form__tool-columns {
|
||||
display: grid;
|
||||
grid-template-columns: var(--agent-tool-enabled-column) var(
|
||||
--agent-tool-approval-column
|
||||
);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-form__tool-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-form__tool-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(0, 1fr) var(--agent-tool-enabled-column)
|
||||
var(--agent-tool-approval-column);
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.agent-form__tool-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-form__tool-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.agent-form__tool-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-form__tool-row > :deep(.el-switch),
|
||||
.agent-form__tool-approval {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.agent-form__tool-approval {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.agent-form__label {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
@@ -273,4 +505,31 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
|
||||
.agent-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agent-form {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.agent-form__builtin-tools {
|
||||
--agent-tool-approval-column: 72px;
|
||||
}
|
||||
|
||||
.agent-form__section-head,
|
||||
.agent-form__tool-row {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-form__tool-row {
|
||||
min-height: calc(var(--space-8) * 2);
|
||||
}
|
||||
|
||||
.agent-form__tool-description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { AgentCapabilityKind } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { KnowledgeIcon, SkillIcon } from '@easyflow/icons';
|
||||
|
||||
import {
|
||||
Connection,
|
||||
Files,
|
||||
Link,
|
||||
Loading,
|
||||
Plus,
|
||||
@@ -24,8 +25,6 @@ const props = defineProps<{
|
||||
tryoutDisabled?: boolean;
|
||||
}>();
|
||||
|
||||
const isRepublish = computed(() => props.publishText === '重新发布');
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [kind: AgentCapabilityKind];
|
||||
offline: [];
|
||||
@@ -34,6 +33,8 @@ const emit = defineEmits<{
|
||||
tryout: [];
|
||||
}>();
|
||||
|
||||
const isRepublish = computed(() => props.publishText === '重新发布');
|
||||
|
||||
const capabilityOpen = ref(false);
|
||||
const capabilityRef = ref<HTMLElement>();
|
||||
const capabilityItems = [
|
||||
@@ -41,7 +42,13 @@ const capabilityItems = [
|
||||
kind: 'knowledge' as const,
|
||||
title: '知识库',
|
||||
desc: '检索企业知识',
|
||||
icon: Files,
|
||||
icon: KnowledgeIcon,
|
||||
},
|
||||
{
|
||||
kind: 'skill' as const,
|
||||
title: '技能',
|
||||
desc: '复用操作说明与工具',
|
||||
icon: SkillIcon,
|
||||
},
|
||||
{
|
||||
kind: 'workflow' as const,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ElButton, ElTabPane, ElTabs } from 'element-plus';
|
||||
import AgentBaseForm from './AgentBaseForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
canDisableShellApproval: boolean;
|
||||
categories: AgentOption[];
|
||||
issues: AgentValidationIssue[];
|
||||
knowledges: AgentOption[];
|
||||
@@ -33,19 +34,24 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
addSkills: [];
|
||||
change: [];
|
||||
closeTryout: [];
|
||||
loadMcpTools: [id: string];
|
||||
moveSkill: [skillId: number | string | undefined, offset: -1 | 1];
|
||||
removeCapability: [];
|
||||
removeSkill: [skillId: number | string | undefined];
|
||||
selectIssue: [nodeId: string];
|
||||
}>();
|
||||
|
||||
const loadAgentInteractionForm = () => import('./AgentInteractionForm.vue');
|
||||
const loadAgentKnowledgeForm = () => import('./AgentKnowledgeForm.vue');
|
||||
const loadAgentSkillInspector = () => import('./AgentSkillInspector.vue');
|
||||
const loadAgentToolForm = () => import('./AgentToolForm.vue');
|
||||
const loadAgentTryoutPanel = () => import('./AgentTryoutPanel.vue');
|
||||
const AgentInteractionForm = defineAsyncComponent(loadAgentInteractionForm);
|
||||
const AgentKnowledgeForm = defineAsyncComponent(loadAgentKnowledgeForm);
|
||||
const AgentSkillInspector = defineAsyncComponent(loadAgentSkillInspector);
|
||||
const AgentToolForm = defineAsyncComponent(loadAgentToolForm);
|
||||
const AgentTryoutPanel = defineAsyncComponent(loadAgentTryoutPanel);
|
||||
|
||||
@@ -86,6 +92,10 @@ function handleIssueClick(issue: AgentValidationIssue) {
|
||||
emit('selectIssue', issue.nodeId);
|
||||
}
|
||||
|
||||
function handleMoveSkill(skillId: number | string | undefined, offset: -1 | 1) {
|
||||
emit('moveSkill', skillId, offset);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.issues,
|
||||
(issues) => {
|
||||
@@ -135,6 +145,7 @@ onMounted(() => {
|
||||
void Promise.allSettled([
|
||||
loadAgentInteractionForm(),
|
||||
loadAgentKnowledgeForm(),
|
||||
loadAgentSkillInspector(),
|
||||
loadAgentToolForm(),
|
||||
loadAgentTryoutPanel(),
|
||||
]);
|
||||
@@ -164,13 +175,17 @@ onBeforeUnmount(() => {
|
||||
<AgentTryoutPanel
|
||||
:agent="state.agent"
|
||||
:image-enabled="tryoutImageEnabled"
|
||||
:skill-bindings="state.skillBindings"
|
||||
:tool-bindings="state.toolBindings"
|
||||
:knowledge-bindings="state.knowledgeBindings"
|
||||
@close="emit('closeTryout')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<header class="agent-inspector__header">
|
||||
<header
|
||||
v-if="state.selectedNodeId !== 'skills'"
|
||||
class="agent-inspector__header"
|
||||
>
|
||||
<div>
|
||||
<div class="agent-inspector__title">
|
||||
{{ state.panelMode === 'base' ? '基座智能体' : '能力配置' }}
|
||||
@@ -194,11 +209,19 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="state.panelMode === 'base'">
|
||||
<AgentSkillInspector
|
||||
v-if="state.selectedNodeId === 'skills'"
|
||||
:bindings="state.skillBindings"
|
||||
@add="emit('addSkills')"
|
||||
@move="handleMoveSkill"
|
||||
@remove="emit('removeSkill', $event)"
|
||||
/>
|
||||
<template v-else-if="state.panelMode === 'base'">
|
||||
<ElTabs v-model="activeBaseTab" class="agent-inspector__tabs">
|
||||
<ElTabPane label="基础设置" name="basic">
|
||||
<AgentBaseForm
|
||||
:agent="state.agent"
|
||||
:can-disable-shell-approval="canDisableShellApproval"
|
||||
:categories="categories"
|
||||
:models="models"
|
||||
:tool-bindings="state.toolBindings"
|
||||
@@ -245,8 +268,8 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.agent-inspector {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
top: var(--space-6);
|
||||
right: var(--space-6);
|
||||
bottom: 96px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
@@ -257,13 +280,13 @@ onBeforeUnmount(() => {
|
||||
background: var(--el-bg-color);
|
||||
background: color-mix(in srgb, var(--el-bg-color) 94%, transparent);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-toolbar);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.agent-inspector__header {
|
||||
padding: 16px;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
@@ -320,4 +343,28 @@ onBeforeUnmount(() => {
|
||||
.agent-inspector__empty {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agent-inspector {
|
||||
top: calc(var(--space-8) + var(--space-8) + var(--space-2));
|
||||
right: var(--space-4);
|
||||
left: var(--space-4);
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agent-inspector {
|
||||
right: var(--space-2);
|
||||
left: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-inspector__header {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.agent-inspector__issues {
|
||||
padding: var(--space-3) var(--space-3) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import type { AgentSkillBinding } from '../types';
|
||||
|
||||
import { ArrowDown, ArrowUp, Delete, Plus } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElEmpty, ElTooltip } from 'element-plus';
|
||||
|
||||
defineProps<{
|
||||
bindings: AgentSkillBinding[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [];
|
||||
move: [skillId: number | string | undefined, offset: -1 | 1];
|
||||
remove: [skillId: number | string | undefined];
|
||||
}>();
|
||||
|
||||
function scopeLabel(scope: unknown) {
|
||||
const normalized = String(scope || '').toUpperCase();
|
||||
if (normalized === 'ALL' || normalized === 'PUBLIC') return '全部';
|
||||
if (normalized === 'DEPARTMENT' || normalized === 'DEPT') return '部门';
|
||||
return '个人';
|
||||
}
|
||||
|
||||
function resourceSummary(binding: AgentSkillBinding) {
|
||||
const summary = binding.resourceSummary || {};
|
||||
const textCount = Math.max(0, Number(summary.textResourceCount) || 0);
|
||||
const binaryCount = Math.max(0, Number(summary.binaryExcludedCount) || 0);
|
||||
const toolCount = Math.max(0, Number(summary.toolCount) || 0);
|
||||
return `${toolCount} 个工具 · ${textCount + binaryCount} 个资源`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="agent-skill-inspector">
|
||||
<header class="agent-skill-inspector__header">
|
||||
<div>
|
||||
<h2>技能</h2>
|
||||
<p>已绑定 {{ bindings.length }} / 20</p>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
:disabled="bindings.length >= 20"
|
||||
@click="emit('add')"
|
||||
>
|
||||
添加技能
|
||||
</ElButton>
|
||||
</header>
|
||||
|
||||
<div v-if="bindings.length === 0" class="agent-skill-inspector__empty">
|
||||
<ElEmpty description="尚未绑定技能">
|
||||
<ElButton type="primary" :icon="Plus" @click="emit('add')">
|
||||
添加技能
|
||||
</ElButton>
|
||||
</ElEmpty>
|
||||
</div>
|
||||
|
||||
<div v-else class="agent-skill-inspector__list">
|
||||
<article
|
||||
v-for="(binding, index) in bindings"
|
||||
:key="String(binding.skillId)"
|
||||
class="agent-skill-inspector__item"
|
||||
>
|
||||
<div class="agent-skill-inspector__item-heading">
|
||||
<div class="agent-skill-inspector__title-group">
|
||||
<strong>
|
||||
{{ binding.resourceSummary?.displayName || '技能' }}
|
||||
</strong>
|
||||
<span class="agent-skill-inspector__scope">
|
||||
{{ scopeLabel(binding.resourceSummary?.visibilityScope) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="binding.resourceSummary?.hasUpdate"
|
||||
class="agent-skill-inspector__update"
|
||||
title="重新发布智能体后生效"
|
||||
>
|
||||
有更新
|
||||
</span>
|
||||
</div>
|
||||
<div class="agent-skill-inspector__actions">
|
||||
<ElTooltip content="上移" placement="top">
|
||||
<ElButton
|
||||
text
|
||||
:icon="ArrowUp"
|
||||
:disabled="index === 0"
|
||||
:aria-label="`上移 ${binding.resourceSummary?.displayName || '技能'}`"
|
||||
@click="emit('move', binding.skillId, -1)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
<ElTooltip content="下移" placement="top">
|
||||
<ElButton
|
||||
text
|
||||
:icon="ArrowDown"
|
||||
:disabled="index === bindings.length - 1"
|
||||
:aria-label="`下移 ${binding.resourceSummary?.displayName || '技能'}`"
|
||||
@click="emit('move', binding.skillId, 1)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
<ElTooltip content="移除" placement="top">
|
||||
<ElButton
|
||||
text
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:aria-label="`移除 ${binding.resourceSummary?.displayName || '技能'}`"
|
||||
@click="emit('remove', binding.skillId)"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<p class="agent-skill-inspector__description">
|
||||
{{ binding.resourceSummary?.description || '暂无描述' }}
|
||||
</p>
|
||||
<div class="agent-skill-inspector__meta">
|
||||
<span>{{ resourceSummary(binding) }}</span>
|
||||
<span v-if="binding.resourceSummary?.binaryExcludedCount">
|
||||
{{ binding.resourceSummary.binaryExcludedCount }}
|
||||
个非文本资源不进入运行时
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="binding.resourceSummary?.hasUpdate"
|
||||
class="agent-skill-inspector__update-note"
|
||||
>
|
||||
重新发布智能体后生效
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="agent-skill-inspector__footer">最多绑定 20 个技能</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-skill-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.agent-skill-inspector__header h2,
|
||||
.agent-skill-inspector__header p,
|
||||
.agent-skill-inspector__description,
|
||||
.agent-skill-inspector__update-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
line-height: 26px;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.agent-skill-inspector__header p,
|
||||
.agent-skill-inspector__meta,
|
||||
.agent-skill-inspector__footer {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.agent-skill-inspector__header p {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__item {
|
||||
padding: var(--space-3);
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-toolbar);
|
||||
transition:
|
||||
background-color var(--motion-duration-base) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-base) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__item:hover,
|
||||
.agent-skill-inspector__item:focus-within {
|
||||
background: hsl(var(--surface-panel));
|
||||
border-color: hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.agent-skill-inspector__item-heading,
|
||||
.agent-skill-inspector__title-group,
|
||||
.agent-skill-inspector__actions,
|
||||
.agent-skill-inspector__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__item-heading {
|
||||
gap: var(--space-2);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__title-group {
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__title-group strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--text-strong));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__scope,
|
||||
.agent-skill-inspector__update {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__scope {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.agent-skill-inspector__update {
|
||||
color: var(--el-color-warning);
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__actions :deep(.el-button) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__description {
|
||||
display: -webkit-box;
|
||||
margin-top: var(--space-2);
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__meta {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__update-note {
|
||||
margin-top: var(--space-2);
|
||||
font-size: 12px;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.agent-skill-inspector__empty {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.agent-skill-inspector__footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AgentSkillSelectorDialog from './AgentSkillSelectorDialog.vue';
|
||||
|
||||
const dialogStub = defineComponent({
|
||||
name: 'ElDialog',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
title: { default: '', type: String },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
props.modelValue
|
||||
? h('section', [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
slots.footer?.(),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: '合同审查助手',
|
||||
value: 'skill-1',
|
||||
raw: {
|
||||
description: '识别风险条款并生成审查意见',
|
||||
textResourceCount: 2,
|
||||
toolCount: 1,
|
||||
visibilityScope: 'DEPARTMENT',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '季度经营分析',
|
||||
value: 'skill-2',
|
||||
raw: {
|
||||
description: '汇总经营数据',
|
||||
textResourceCount: 1,
|
||||
toolCount: 0,
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
return mount(AgentSkillSelectorDialog, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: { ElDialog: dialogStub },
|
||||
},
|
||||
props: {
|
||||
modelValue: true,
|
||||
options,
|
||||
...props,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe('agent skill selector dialog', () => {
|
||||
it('fuzzy searches multiple terms and supports keyboard selection', async () => {
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await wrapper.get('input[aria-label="搜索技能"]').setValue('合同 意见');
|
||||
expect(wrapper.text()).toContain('合同审查助手');
|
||||
expect(wrapper.text()).not.toContain('季度经营分析');
|
||||
|
||||
await wrapper.get('[role="option"]').trigger('keydown.enter');
|
||||
const addButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '添加');
|
||||
await addButton?.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toEqual([options[0]]);
|
||||
});
|
||||
|
||||
it('disables new selections after reaching the twenty-skill limit', () => {
|
||||
const wrapper = mountDialog({
|
||||
boundSkillIds: Array.from({ length: 20 }, (_, index) => `bound-${index}`),
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('已达到绑定上限');
|
||||
expect(wrapper.get('[role="option"]').attributes('aria-disabled')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a retry action when options fail to load', async () => {
|
||||
const wrapper = mountDialog({ error: '加载失败' });
|
||||
const retryButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '重新加载');
|
||||
|
||||
await retryButton?.trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('技能列表加载失败');
|
||||
expect(wrapper.emitted('retry')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import type { AgentOption } from '../types';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElCheckbox,
|
||||
ElDialog,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElScrollbar,
|
||||
} from 'element-plus';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
boundSkillIds?: Array<number | string>;
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
modelValue: boolean;
|
||||
options: AgentOption[];
|
||||
}>(),
|
||||
{
|
||||
boundSkillIds: () => [],
|
||||
error: '',
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [options: AgentOption[]];
|
||||
retry: [];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const keyword = ref('');
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const searchInput = ref<InstanceType<typeof ElInput>>();
|
||||
|
||||
const boundIds = computed(() => new Set(props.boundSkillIds.map(String)));
|
||||
const remainingCount = computed(() => Math.max(0, 20 - boundIds.value.size));
|
||||
const normalizedKeyword = computed(() =>
|
||||
keyword.value.trim().toLocaleLowerCase(),
|
||||
);
|
||||
const filteredOptions = computed(() => {
|
||||
const query = normalizedKeyword.value;
|
||||
if (!query) return props.options;
|
||||
const terms = query.split(/\s+/).filter(Boolean);
|
||||
return props.options.filter((option) => {
|
||||
const raw = option.raw || {};
|
||||
const searchableText = [option.label, raw.description]
|
||||
.map((value) => String(value || '').toLocaleLowerCase())
|
||||
.join(' ');
|
||||
return terms.every((term) => searchableText.includes(term));
|
||||
});
|
||||
});
|
||||
const selectedOptions = computed(() => {
|
||||
const ids = new Set(selectedIds.value);
|
||||
return props.options.filter((option) => ids.has(String(option.value)));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
keyword.value = '';
|
||||
selectedIds.value = [];
|
||||
await nextTick();
|
||||
searchInput.value?.focus();
|
||||
},
|
||||
);
|
||||
|
||||
function scopeLabel(scope: unknown) {
|
||||
const normalized = String(scope || '').toUpperCase();
|
||||
if (normalized === 'ALL' || normalized === 'PUBLIC') return '全部';
|
||||
if (normalized === 'DEPARTMENT' || normalized === 'DEPT') return '部门';
|
||||
return '个人';
|
||||
}
|
||||
|
||||
function resourceSummary(option: AgentOption) {
|
||||
const raw = option.raw || {};
|
||||
const textCount = Math.max(0, Number(raw.textResourceCount) || 0);
|
||||
const binaryCount = Math.max(0, Number(raw.binaryResourceCount) || 0);
|
||||
const toolCount = Math.max(0, Number(raw.toolCount) || 0);
|
||||
const resources = textCount + binaryCount;
|
||||
return `${toolCount} 个工具 · ${resources} 个资源`;
|
||||
}
|
||||
|
||||
function isOptionDisabled(option: AgentOption) {
|
||||
const id = String(option.value);
|
||||
if (boundIds.value.has(id)) return true;
|
||||
return (
|
||||
!selectedIds.value.includes(id) &&
|
||||
selectedIds.value.length >= remainingCount.value
|
||||
);
|
||||
}
|
||||
|
||||
function toggleOption(option: AgentOption) {
|
||||
if (isOptionDisabled(option)) return;
|
||||
const id = String(option.value);
|
||||
selectedIds.value = selectedIds.value.includes(id)
|
||||
? selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
: [...selectedIds.value, id];
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
if (selectedOptions.value.length === 0) return;
|
||||
emit('add', selectedOptions.value);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
class="agent-skill-selector"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="modelValue"
|
||||
title="添加技能"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="agent-skill-selector__toolbar">
|
||||
<ElInput
|
||||
ref="searchInput"
|
||||
v-model="keyword"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="搜索技能名称或描述"
|
||||
aria-label="搜索技能"
|
||||
/>
|
||||
<span class="agent-skill-selector__limit">
|
||||
已绑定 {{ boundIds.size }} / 20
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="agent-skill-selector__content">
|
||||
<div v-if="error && !loading" class="agent-skill-selector__state">
|
||||
<ElEmpty description="技能列表加载失败">
|
||||
<ElButton type="primary" @click="emit('retry')">重新加载</ElButton>
|
||||
</ElEmpty>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!loading && filteredOptions.length === 0"
|
||||
class="agent-skill-selector__state"
|
||||
>
|
||||
<ElEmpty :description="keyword ? '未找到匹配技能' : '暂无可用技能'" />
|
||||
</div>
|
||||
<ElScrollbar v-else class="agent-skill-selector__scrollbar">
|
||||
<div
|
||||
class="agent-skill-selector__list"
|
||||
role="listbox"
|
||||
aria-label="可用技能"
|
||||
>
|
||||
<div
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="agent-skill-selector__item"
|
||||
:class="{
|
||||
'is-bound': boundIds.has(String(option.value)),
|
||||
'is-disabled': isOptionDisabled(option),
|
||||
'is-selected': selectedIds.includes(String(option.value)),
|
||||
}"
|
||||
role="option"
|
||||
:aria-disabled="isOptionDisabled(option)"
|
||||
:aria-selected="selectedIds.includes(String(option.value))"
|
||||
:tabindex="isOptionDisabled(option) ? -1 : 0"
|
||||
@click="toggleOption(option)"
|
||||
@keydown.enter.prevent="toggleOption(option)"
|
||||
@keydown.space.prevent="toggleOption(option)"
|
||||
>
|
||||
<ElCheckbox
|
||||
:aria-label="`选择 ${option.label}`"
|
||||
:disabled="isOptionDisabled(option)"
|
||||
:model-value="selectedIds.includes(String(option.value))"
|
||||
@change="toggleOption(option)"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="agent-skill-selector__item-body">
|
||||
<span class="agent-skill-selector__item-heading">
|
||||
<span class="agent-skill-selector__name">{{
|
||||
option.label
|
||||
}}</span>
|
||||
<span class="agent-skill-selector__scope">
|
||||
{{ scopeLabel(option.raw?.visibilityScope) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="boundIds.has(String(option.value))"
|
||||
class="agent-skill-selector__bound"
|
||||
>
|
||||
已绑定
|
||||
</span>
|
||||
</span>
|
||||
<span class="agent-skill-selector__description">
|
||||
{{ option.raw?.description || '暂无描述' }}
|
||||
</span>
|
||||
<span class="agent-skill-selector__meta">
|
||||
{{ resourceSummary(option) }}
|
||||
<template v-if="Number(option.raw?.binaryResourceCount) > 0">
|
||||
· 含非文本资源
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="agent-skill-selector__footer">
|
||||
<span>
|
||||
<template v-if="remainingCount === 0">已达到绑定上限</template>
|
||||
<template v-else>已选择 {{ selectedIds.length }} 个</template>
|
||||
</span>
|
||||
<div class="agent-skill-selector__actions">
|
||||
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="selectedIds.length === 0"
|
||||
@click="confirmSelection"
|
||||
>
|
||||
添加
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.agent-skill-selector) {
|
||||
width: min(760px, calc(100vw - var(--space-8))) !important;
|
||||
border-radius: var(--radius-float);
|
||||
}
|
||||
|
||||
:global(.agent-skill-selector .el-dialog__body) {
|
||||
padding: 0 var(--space-6);
|
||||
}
|
||||
|
||||
.agent-skill-selector__toolbar,
|
||||
.agent-skill-selector__footer,
|
||||
.agent-skill-selector__item-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-skill-selector__toolbar {
|
||||
gap: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.agent-skill-selector__toolbar :deep(.el-input) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-skill-selector__limit,
|
||||
.agent-skill-selector__meta,
|
||||
.agent-skill-selector__footer {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.agent-skill-selector__content {
|
||||
min-height: 360px;
|
||||
border-top: 1px solid hsl(var(--line-subtle));
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.agent-skill-selector__scrollbar {
|
||||
height: min(52vh, 480px);
|
||||
}
|
||||
|
||||
.agent-skill-selector__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.agent-skill-selector__item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-toolbar);
|
||||
transition:
|
||||
background-color var(--motion-duration-base) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-base) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.agent-skill-selector__item:hover {
|
||||
background: hsl(var(--surface-subtle));
|
||||
}
|
||||
|
||||
.agent-skill-selector__item:active {
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
}
|
||||
|
||||
.agent-skill-selector__item:focus-within {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.agent-skill-selector__item.is-selected {
|
||||
background: hsl(var(--nav-item-active));
|
||||
border-color: hsl(var(--primary) / 0.28);
|
||||
}
|
||||
|
||||
.agent-skill-selector__item.is-bound,
|
||||
.agent-skill-selector__item.is-disabled {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.agent-skill-selector__item-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-skill-selector__item-heading {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.agent-skill-selector__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--text-strong));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-skill-selector__scope,
|
||||
.agent-skill-selector__bound {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.agent-skill-selector__scope {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.agent-skill-selector__bound {
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
}
|
||||
|
||||
.agent-skill-selector__description {
|
||||
margin-top: var(--space-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-skill-selector__meta {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.agent-skill-selector__state {
|
||||
display: grid;
|
||||
min-height: 360px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.agent-skill-selector__footer {
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.agent-skill-selector__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
:global(.agent-skill-selector) {
|
||||
width: calc(100vw - var(--space-4)) !important;
|
||||
margin-top: var(--space-4) !important;
|
||||
}
|
||||
|
||||
.agent-skill-selector__toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentKnowledgeBinding,
|
||||
AgentSkillBinding,
|
||||
AgentToolBinding,
|
||||
} from '../types';
|
||||
|
||||
@@ -26,6 +27,7 @@ import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
|
||||
import {
|
||||
createAgentArtifactLoader,
|
||||
loadAgentChatDocument,
|
||||
loadAgentChatImage,
|
||||
} from '#/components/ai-chat/mediaApi';
|
||||
@@ -41,6 +43,7 @@ const props = defineProps<{
|
||||
agent: AgentInfo;
|
||||
imageEnabled?: boolean;
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
skillBindings: AgentSkillBinding[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
}>();
|
||||
|
||||
@@ -61,6 +64,11 @@ const {
|
||||
} = useAgentTryoutStream();
|
||||
const approvalLoading = ref(false);
|
||||
const composer = useAgentComposerDraft('DRAFT');
|
||||
const loadDraftAgentArtifact = createAgentArtifactLoader(() => ({
|
||||
agentId: String(props.agent.id || ''),
|
||||
mode: 'DRAFT',
|
||||
runtimeSessionId: composer.sessionId.value,
|
||||
}));
|
||||
const interactionDisplay = computed(() =>
|
||||
resolveInteractionDisplay(props.agent),
|
||||
);
|
||||
@@ -68,6 +76,7 @@ const interactionDisplay = computed(() =>
|
||||
function getDraftContext() {
|
||||
return {
|
||||
agent: props.agent,
|
||||
skillBindings: props.skillBindings,
|
||||
toolBindings: props.toolBindings,
|
||||
knowledgeBindings: props.knowledgeBindings,
|
||||
};
|
||||
@@ -101,7 +110,12 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.agent, props.knowledgeBindings, props.toolBindings],
|
||||
() => [
|
||||
props.agent,
|
||||
props.knowledgeBindings,
|
||||
props.skillBindings,
|
||||
props.toolBindings,
|
||||
],
|
||||
() => {
|
||||
syncCurrentDraftContext();
|
||||
},
|
||||
@@ -312,7 +326,7 @@ async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
|
||||
approvalLoading.value = true;
|
||||
markToolApproving(payload);
|
||||
try {
|
||||
const res = await approveAgentRun(payload.requestId, payload.resumeToken);
|
||||
const res = await approveAgentRun(payload.approvalId);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success('已批准');
|
||||
}
|
||||
@@ -334,11 +348,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
||||
reason: '用户拒绝执行',
|
||||
});
|
||||
try {
|
||||
await rejectAgentRun(
|
||||
payload.requestId,
|
||||
payload.resumeToken,
|
||||
'用户拒绝执行',
|
||||
);
|
||||
await rejectAgentRun(payload.approvalId, '用户拒绝执行');
|
||||
} finally {
|
||||
approvalLoading.value = false;
|
||||
}
|
||||
@@ -397,6 +407,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
||||
<ChatTimeline
|
||||
v-else
|
||||
:assistant-avatar="agent.avatar || defaultAssistantAvatar"
|
||||
:artifact-loader="loadDraftAgentArtifact"
|
||||
:items="timelineItems"
|
||||
:document-loader="loadAgentChatDocument"
|
||||
:image-loader="loadAgentChatImage"
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { AgentStudioNodeData } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Connection, Cpu, Files, Link, Share } from '@element-plus/icons-vue';
|
||||
import { KnowledgeIcon, SkillIcon } from '@easyflow/icons';
|
||||
|
||||
import { Connection, Cpu, Link, Share } from '@element-plus/icons-vue';
|
||||
import { ElIcon } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -13,9 +15,10 @@ const props = defineProps<{
|
||||
const iconComponent = computed(() => {
|
||||
const icons = {
|
||||
base: Cpu,
|
||||
knowledge: Files,
|
||||
knowledge: KnowledgeIcon,
|
||||
mcp: Link,
|
||||
plugin: Connection,
|
||||
skill: SkillIcon,
|
||||
workflow: Share,
|
||||
};
|
||||
return icons[props.data.iconKey];
|
||||
@@ -45,6 +48,12 @@ const iconComponent = computed(() => {
|
||||
<span v-if="data.detail" class="agent-studio-node__detail">
|
||||
{{ data.detail }}
|
||||
</span>
|
||||
<span v-if="data.previewItems?.length" class="agent-studio-node__preview">
|
||||
<span v-for="item in data.previewItems" :key="item">{{ item }}</span>
|
||||
<span v-if="data.remainingCount" class="agent-studio-node__remaining">
|
||||
+{{ data.remainingCount }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -119,6 +128,12 @@ const iconComponent = computed(() => {
|
||||
min-height: 78px;
|
||||
}
|
||||
|
||||
.agent-studio-node--skill {
|
||||
align-items: flex-start;
|
||||
width: 240px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.agent-studio-node__icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -200,6 +215,30 @@ const iconComponent = computed(() => {
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.agent-studio-node__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding-top: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-regular);
|
||||
border-top: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.agent-studio-node__preview > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-studio-node__remaining {
|
||||
font-weight: 650;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, red, blue)) {
|
||||
.agent-studio-node:hover {
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {AgentCapabilityKind} from '../../types';
|
||||
import type { AgentCapabilityKind } from '../../types';
|
||||
|
||||
export type AgentStudioNodeKind = 'base' | AgentCapabilityKind;
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface AgentStudioNodeData {
|
||||
iconKey: AgentStudioNodeKind;
|
||||
id: string;
|
||||
kind: AgentStudioNodeKind;
|
||||
previewItems?: string[];
|
||||
remainingCount?: number;
|
||||
selected: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {useAgentStudioModel, resolveCapabilityNodePosition} from './useAgentStudioModel';
|
||||
import {
|
||||
resolveCapabilityNodePosition,
|
||||
useAgentStudioModel,
|
||||
} from './useAgentStudioModel';
|
||||
|
||||
describe('resolveCapabilityNodePosition', () => {
|
||||
it('无视口信息时沿用默认左侧列位置', () => {
|
||||
@@ -59,7 +62,7 @@ describe('resolveCapabilityNodePosition', () => {
|
||||
});
|
||||
|
||||
describe('useAgentStudioModel', () => {
|
||||
it('MCP 绑定缺少资源快照时从选项中回显节点信息', () => {
|
||||
it('mcp 绑定缺少资源快照时从选项中回显节点信息', () => {
|
||||
const model = useAgentStudioModel(
|
||||
{
|
||||
agent: {
|
||||
@@ -67,6 +70,7 @@ describe('useAgentStudioModel', () => {
|
||||
},
|
||||
dirty: false,
|
||||
knowledgeBindings: [],
|
||||
skillBindings: [],
|
||||
panelMode: 'capability',
|
||||
selectedNodeId: 'tool:mcp-1',
|
||||
toolBindings: [
|
||||
@@ -110,6 +114,7 @@ describe('useAgentStudioModel', () => {
|
||||
},
|
||||
dirty: false,
|
||||
knowledgeBindings: [],
|
||||
skillBindings: [],
|
||||
panelMode: 'capability',
|
||||
selectedNodeId: 'tool:plugin-1',
|
||||
toolBindings: [
|
||||
@@ -141,4 +146,37 @@ describe('useAgentStudioModel', () => {
|
||||
expect(model.value.nodes).toHaveLength(3);
|
||||
expect(optionReads).toBe(1);
|
||||
});
|
||||
|
||||
it('将多个技能收敛为一个聚合节点', () => {
|
||||
const model = useAgentStudioModel(
|
||||
{
|
||||
agent: { name: '技能智能体' },
|
||||
dirty: false,
|
||||
knowledgeBindings: [],
|
||||
panelMode: 'capability',
|
||||
selectedNodeId: 'skills',
|
||||
skillBindings: [
|
||||
{ skillId: '1', resourceSummary: { displayName: '合同审查' } },
|
||||
{ skillId: '2', resourceSummary: { displayName: '经营分析' } },
|
||||
{ skillId: '3', resourceSummary: { displayName: '知识问答' } },
|
||||
{ skillId: '4', resourceSummary: { displayName: '客户沟通' } },
|
||||
],
|
||||
toolBindings: [],
|
||||
},
|
||||
() => 'skills',
|
||||
);
|
||||
|
||||
const skillNodes = model.value.nodes.filter(
|
||||
(node) => node.data.kind === 'skill',
|
||||
);
|
||||
expect(skillNodes).toHaveLength(1);
|
||||
expect(skillNodes[0]?.data).toEqual(
|
||||
expect.objectContaining({
|
||||
detail: '4 / 20',
|
||||
previewItems: ['合同审查', '经营分析', '知识问答'],
|
||||
remainingCount: 1,
|
||||
selected: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,28 @@ function buildToolDetail(
|
||||
return resourceName || toolName || fallback;
|
||||
}
|
||||
|
||||
function resolveToolNodePresentation(toolType: string) {
|
||||
if (toolType === 'WORKFLOW') {
|
||||
return {
|
||||
fallback: '待选择工作流',
|
||||
kind: 'workflow' as const,
|
||||
title: '工作流',
|
||||
};
|
||||
}
|
||||
if (toolType === 'MCP') {
|
||||
return {
|
||||
fallback: '待选择 MCP',
|
||||
kind: 'mcp' as const,
|
||||
title: 'MCP',
|
||||
};
|
||||
}
|
||||
return {
|
||||
fallback: '待选择插件工具',
|
||||
kind: 'plugin' as const,
|
||||
title: '插件',
|
||||
};
|
||||
}
|
||||
|
||||
function toFlowPoint(
|
||||
point: { x: number; y: number },
|
||||
viewport: NonNullable<AgentStudioLayoutSnapshot['viewport']>,
|
||||
@@ -296,19 +318,13 @@ export function useAgentStudioModel(
|
||||
const toolNodes = state.toolBindings.map((binding, index) => {
|
||||
const nodeId = `tool:${binding.localId}`;
|
||||
const toolType = String(binding.toolType || '').toUpperCase();
|
||||
const isWorkflow = toolType === 'WORKFLOW';
|
||||
const isMcp = toolType === 'MCP';
|
||||
const matchedOptions = isWorkflow
|
||||
? toolOptionLookups.workflow
|
||||
: isMcp
|
||||
? toolOptionLookups.mcp
|
||||
: toolOptionLookups.plugin;
|
||||
const fallback = isWorkflow
|
||||
? '待选择工作流'
|
||||
: isMcp
|
||||
? '待选择 MCP'
|
||||
: '待选择插件工具';
|
||||
const detail = buildToolDetail(binding, fallback, matchedOptions);
|
||||
const presentation = resolveToolNodePresentation(toolType);
|
||||
const matchedOptions = toolOptionLookups[presentation.kind];
|
||||
const detail = buildToolDetail(
|
||||
binding,
|
||||
presentation.fallback,
|
||||
matchedOptions,
|
||||
);
|
||||
const position = resolveCapabilityNodePosition({
|
||||
canvasSize: size,
|
||||
fallbackIndex: state.knowledgeBindings.length + index,
|
||||
@@ -324,25 +340,54 @@ export function useAgentStudioModel(
|
||||
width: CAPABILITY_NODE_WIDTH,
|
||||
height: CAPABILITY_NODE_HEIGHT,
|
||||
data: {
|
||||
badge: isWorkflow ? '工作流' : isMcp ? 'MCP' : '插件',
|
||||
badge: presentation.title,
|
||||
detail,
|
||||
iconKey: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
|
||||
iconKey: presentation.kind,
|
||||
id: nodeId,
|
||||
kind: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
|
||||
kind: presentation.kind,
|
||||
selected: selectedNodeId() === nodeId,
|
||||
title:
|
||||
detail === fallback
|
||||
? isWorkflow
|
||||
? '工作流'
|
||||
: isMcp
|
||||
? 'MCP'
|
||||
: '插件'
|
||||
: detail,
|
||||
title: detail === presentation.fallback ? presentation.title : detail,
|
||||
} satisfies AgentStudioNodeData,
|
||||
};
|
||||
});
|
||||
|
||||
const capabilityNodes = [...knowledgeNodes, ...toolNodes];
|
||||
const skillNodes: AgentStudioNodeView[] = [];
|
||||
if (state.skillBindings.length > 0) {
|
||||
const nodeId = 'skills';
|
||||
const previewItems = state.skillBindings
|
||||
.slice(0, 3)
|
||||
.map((binding) =>
|
||||
firstText(binding.resourceSummary?.displayName, '技能'),
|
||||
);
|
||||
const position = resolveCapabilityNodePosition({
|
||||
canvasSize: size,
|
||||
fallbackIndex:
|
||||
state.knowledgeBindings.length + state.toolBindings.length,
|
||||
layout,
|
||||
nodeId,
|
||||
occupiedPositions,
|
||||
});
|
||||
occupiedPositions.push(position);
|
||||
skillNodes.push({
|
||||
id: nodeId,
|
||||
type: 'agentStudioCapability',
|
||||
position,
|
||||
width: 240,
|
||||
height: 154,
|
||||
data: {
|
||||
detail: `${state.skillBindings.length} / 20`,
|
||||
iconKey: 'skill',
|
||||
id: nodeId,
|
||||
kind: 'skill',
|
||||
previewItems,
|
||||
remainingCount: Math.max(0, state.skillBindings.length - 3),
|
||||
selected: selectedNodeId() === nodeId,
|
||||
title: '技能',
|
||||
} satisfies AgentStudioNodeData,
|
||||
});
|
||||
}
|
||||
|
||||
const capabilityNodes = [...knowledgeNodes, ...toolNodes, ...skillNodes];
|
||||
|
||||
const edges: AgentStudioEdgeView[] = capabilityNodes.map((node) => ({
|
||||
id: `edge:${node.id}`,
|
||||
|
||||
@@ -121,3 +121,114 @@ describe('useAgentDesignerState memory compression', () => {
|
||||
).toBe(128_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentDesignerState skill bindings', () => {
|
||||
it('adds unique skills, keeps summaries safe and caps the list at twenty', () => {
|
||||
const designer = useAgentDesignerState();
|
||||
const options = Array.from({ length: 22 }, (_, index) => ({
|
||||
label: `技能 ${index + 1}`,
|
||||
value: String(index + 1),
|
||||
raw: {
|
||||
binaryResourceCount: 1,
|
||||
description: `描述 ${index + 1}`,
|
||||
internalSnapshot: { secret: true },
|
||||
textResourceCount: 2,
|
||||
toolCount: index,
|
||||
visibilityScope: 'DEPARTMENT',
|
||||
},
|
||||
}));
|
||||
|
||||
designer.appendSkillOptions([...options, ...options.slice(0, 1)]);
|
||||
|
||||
expect(designer.state.skillBindings).toHaveLength(20);
|
||||
expect(designer.state.selectedNodeId).toBe('skills');
|
||||
expect(designer.state.skillBindings[0]?.resourceSummary).toEqual({
|
||||
binaryExcludedCount: 1,
|
||||
description: '描述 1',
|
||||
displayName: '技能 1',
|
||||
hasUpdate: false,
|
||||
snapshotHash: '',
|
||||
textResourceCount: 2,
|
||||
toolCount: 0,
|
||||
visibilityScope: 'DEPARTMENT',
|
||||
});
|
||||
expect(designer.state.skillBindings[0]?.resourceSummary).not.toHaveProperty(
|
||||
'internalSnapshot',
|
||||
);
|
||||
});
|
||||
|
||||
it('reorders and removes skills while generating a minimal save payload', () => {
|
||||
const designer = useAgentDesignerState();
|
||||
designer.reset({
|
||||
id: 'agent-1',
|
||||
name: '技能智能体',
|
||||
skillBindings: [
|
||||
{ skillId: 'skill-1', sortNo: 1 },
|
||||
{ skillId: 'skill-2', sortNo: 2 },
|
||||
{ skillId: 'skill-3', sortNo: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
designer.moveSkill('skill-3', -1);
|
||||
designer.removeSkill('skill-1');
|
||||
|
||||
expect(designer.buildSkillPayload()).toEqual([
|
||||
{ skillId: 'skill-3', sortNo: 1 },
|
||||
{ skillId: 'skill-2', sortNo: 2 },
|
||||
]);
|
||||
expect(designer.buildPayloadAgent()).not.toHaveProperty('skillBindings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentDesignerState binding change tracking', () => {
|
||||
it('sends only persisted fields and marks only changed binding groups', () => {
|
||||
const designer = useAgentDesignerState();
|
||||
designer.reset({
|
||||
id: 'agent-1',
|
||||
name: '保存性能测试',
|
||||
knowledgeBindings: [
|
||||
{
|
||||
id: 'knowledge-binding-1',
|
||||
knowledgeId: 'knowledge-1',
|
||||
localId: 'local-knowledge',
|
||||
resourceSummary: { title: '知识库' },
|
||||
},
|
||||
],
|
||||
toolBindings: [
|
||||
{
|
||||
id: 'tool-binding-1',
|
||||
localId: 'local-tool',
|
||||
resourceSummary: { title: '工具' },
|
||||
targetId: 'tool-1',
|
||||
toolName: 'lookup',
|
||||
toolType: 'PLUGIN',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(designer.getBindingChanges()).toEqual({
|
||||
knowledge: false,
|
||||
skill: false,
|
||||
tool: false,
|
||||
});
|
||||
expect(designer.buildToolPayload('agent-1')[0]).not.toHaveProperty(
|
||||
'localId',
|
||||
);
|
||||
expect(designer.buildToolPayload('agent-1')[0]).not.toHaveProperty(
|
||||
'resourceSummary',
|
||||
);
|
||||
|
||||
const toolBinding = designer.state.toolBindings[0];
|
||||
expect(toolBinding).toBeDefined();
|
||||
if (!toolBinding) return;
|
||||
toolBinding.hitlEnabled = true;
|
||||
expect(designer.getBindingChanges()).toEqual({
|
||||
knowledge: false,
|
||||
skill: false,
|
||||
tool: true,
|
||||
});
|
||||
|
||||
designer.commitBindingBaseline();
|
||||
expect(designer.getBindingChanges().tool).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,20 @@ import type {
|
||||
AgentDraftState,
|
||||
AgentInfo,
|
||||
AgentKnowledgeBinding,
|
||||
AgentOption,
|
||||
AgentSkillBinding,
|
||||
AgentSkillSummary,
|
||||
AgentToolBinding,
|
||||
AgentValidationIssue,
|
||||
} from '../types';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
import {
|
||||
buildAgentExecutionConfig,
|
||||
createDefaultBuiltinTools,
|
||||
normalizeBuiltinTools,
|
||||
} from '../builtin-tools';
|
||||
import { DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD } from '../compression-threshold';
|
||||
import {
|
||||
buildInteractionConfigPayload,
|
||||
@@ -20,6 +28,12 @@ import {
|
||||
const BASE_NODE_ID = 'agent-base';
|
||||
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
|
||||
|
||||
export interface AgentBindingChangeSet {
|
||||
knowledge: boolean;
|
||||
skill: boolean;
|
||||
tool: boolean;
|
||||
}
|
||||
|
||||
function createLocalId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
@@ -35,7 +49,7 @@ function buildFallbackToolName(prefix: string, resource?: Record<string, any>) {
|
||||
|
||||
function toolKindFromType(
|
||||
toolType?: string,
|
||||
): Exclude<AgentCapabilityKind, 'knowledge'> {
|
||||
): Exclude<AgentCapabilityKind, 'knowledge' | 'skill'> {
|
||||
const normalized = String(toolType || '').toUpperCase();
|
||||
if (normalized === 'WORKFLOW') return 'workflow';
|
||||
if (normalized === 'MCP') return 'mcp';
|
||||
@@ -43,7 +57,7 @@ function toolKindFromType(
|
||||
}
|
||||
|
||||
function resolveToolName(
|
||||
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
||||
kind: Exclude<AgentCapabilityKind, 'knowledge' | 'skill'>,
|
||||
resource?: Record<string, any>,
|
||||
) {
|
||||
if (isSafeToolName(resource?.englishName)) {
|
||||
@@ -96,6 +110,7 @@ export function createEmptyAgent(): AgentInfo {
|
||||
categoryId: '',
|
||||
visibilityScope: 'PRIVATE',
|
||||
executionConfigJson: {
|
||||
builtinTools: createDefaultBuiltinTools(),
|
||||
documentContextBudgetTokens: 20_000,
|
||||
},
|
||||
modelId: '',
|
||||
@@ -134,6 +149,9 @@ function normalizeAgent(agent?: AgentInfo): AgentInfo {
|
||||
},
|
||||
executionConfigJson: {
|
||||
...source.executionConfigJson,
|
||||
builtinTools: normalizeBuiltinTools(
|
||||
source.executionConfigJson?.builtinTools,
|
||||
),
|
||||
documentContextBudgetTokens:
|
||||
Number(source.executionConfigJson?.documentContextBudgetTokens) > 0
|
||||
? Number(source.executionConfigJson?.documentContextBudgetTokens)
|
||||
@@ -202,15 +220,50 @@ function normalizeToolBinding(
|
||||
};
|
||||
}
|
||||
|
||||
function toSkillSummary(source?: Record<string, any>): AgentSkillSummary {
|
||||
const raw = source || {};
|
||||
return {
|
||||
binaryExcludedCount: Math.max(
|
||||
0,
|
||||
Number(raw.binaryExcludedCount ?? raw.binaryResourceCount) || 0,
|
||||
),
|
||||
description: String(raw.description || ''),
|
||||
displayName: String(raw.displayName || raw.name || '技能'),
|
||||
hasUpdate: Boolean(raw.hasUpdate),
|
||||
snapshotHash: String(raw.snapshotHash || ''),
|
||||
textResourceCount: Math.max(0, Number(raw.textResourceCount) || 0),
|
||||
toolCount: Math.max(0, Number(raw.toolCount) || 0),
|
||||
visibilityScope: String(raw.visibilityScope || 'PRIVATE'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillBinding(
|
||||
binding: AgentSkillBinding,
|
||||
index: number,
|
||||
): AgentSkillBinding {
|
||||
return {
|
||||
id: binding.id,
|
||||
skillId: binding.skillId,
|
||||
resourceSummary: toSkillSummary(binding.resourceSummary),
|
||||
sortNo: binding.sortNo ?? index + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentDesignerState() {
|
||||
const state = reactive<AgentDraftState>({
|
||||
agent: createEmptyAgent(),
|
||||
knowledgeBindings: [],
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
selectedNodeId: BASE_NODE_ID,
|
||||
panelMode: 'base',
|
||||
dirty: false,
|
||||
});
|
||||
let savedBindingSignatures = {
|
||||
knowledge: '',
|
||||
skill: '',
|
||||
tool: '',
|
||||
};
|
||||
|
||||
const selectedCapability = computed(() => {
|
||||
if (state.selectedNodeId.startsWith('knowledge:')) {
|
||||
@@ -232,6 +285,12 @@ export function useAgentDesignerState() {
|
||||
binding,
|
||||
};
|
||||
}
|
||||
if (state.selectedNodeId === 'skills') {
|
||||
return {
|
||||
kind: 'skill' as AgentCapabilityKind,
|
||||
binding: undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
@@ -247,9 +306,13 @@ export function useAgentDesignerState() {
|
||||
state.toolBindings = (agent?.toolBindings || []).map((binding, index) =>
|
||||
normalizeToolBinding(binding, index),
|
||||
);
|
||||
state.skillBindings = (agent?.skillBindings || []).map((binding, index) =>
|
||||
normalizeSkillBinding(binding, index),
|
||||
);
|
||||
state.selectedNodeId = BASE_NODE_ID;
|
||||
state.panelMode = 'base';
|
||||
state.dirty = false;
|
||||
commitBindingBaseline();
|
||||
}
|
||||
|
||||
function selectBase() {
|
||||
@@ -281,7 +344,7 @@ export function useAgentDesignerState() {
|
||||
}
|
||||
|
||||
function addToolNode(
|
||||
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
||||
kind: Exclude<AgentCapabilityKind, 'knowledge' | 'skill'>,
|
||||
resource?: Record<string, any>,
|
||||
) {
|
||||
let toolType = 'PLUGIN';
|
||||
@@ -306,9 +369,84 @@ export function useAgentDesignerState() {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function appendSkillOptions(options: AgentOption[]) {
|
||||
let addedCount = 0;
|
||||
const existingIds = new Set(
|
||||
state.skillBindings.map((binding) => String(binding.skillId || '')),
|
||||
);
|
||||
for (const option of options) {
|
||||
if (state.skillBindings.length >= 20) break;
|
||||
const skillId = String(option.value || '').trim();
|
||||
if (!skillId || existingIds.has(skillId)) continue;
|
||||
state.skillBindings.push(
|
||||
normalizeSkillBinding(
|
||||
{
|
||||
skillId,
|
||||
resourceSummary: toSkillSummary({
|
||||
...option.raw,
|
||||
displayName: option.label,
|
||||
}),
|
||||
},
|
||||
state.skillBindings.length,
|
||||
),
|
||||
);
|
||||
existingIds.add(skillId);
|
||||
addedCount += 1;
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
state.selectedNodeId = 'skills';
|
||||
state.panelMode = 'capability';
|
||||
markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSkillBindings(bindings: AgentSkillBinding[]) {
|
||||
state.skillBindings = bindings.map((binding, index) =>
|
||||
normalizeSkillBinding(binding, index),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSkill(skillId?: number | string) {
|
||||
const normalizedId = String(skillId || '');
|
||||
const next = state.skillBindings.filter(
|
||||
(binding) => String(binding.skillId || '') !== normalizedId,
|
||||
);
|
||||
if (next.length === state.skillBindings.length) return;
|
||||
state.skillBindings = next.map((binding, index) => ({
|
||||
...binding,
|
||||
sortNo: index + 1,
|
||||
}));
|
||||
if (state.skillBindings.length === 0) selectBase();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function moveSkill(skillId: number | string | undefined, offset: -1 | 1) {
|
||||
const currentIndex = state.skillBindings.findIndex(
|
||||
(binding) => String(binding.skillId || '') === String(skillId || ''),
|
||||
);
|
||||
const targetIndex = currentIndex + offset;
|
||||
if (
|
||||
currentIndex < 0 ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= state.skillBindings.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const next = [...state.skillBindings];
|
||||
const [current] = next.splice(currentIndex, 1);
|
||||
if (!current) return;
|
||||
next.splice(targetIndex, 0, current);
|
||||
state.skillBindings = next.map((binding, index) => ({
|
||||
...binding,
|
||||
sortNo: index + 1,
|
||||
}));
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function removeSelectedCapability() {
|
||||
const selected = selectedCapability.value;
|
||||
if (!selected?.binding?.localId) return;
|
||||
if (!selected || selected.kind === 'skill') return;
|
||||
if (!selected.binding?.localId) return;
|
||||
if (selected.kind === 'knowledge') {
|
||||
state.knowledgeBindings = state.knowledgeBindings.filter(
|
||||
(item) => item.localId !== selected.binding?.localId,
|
||||
@@ -371,7 +509,9 @@ export function useAgentDesignerState() {
|
||||
return issues;
|
||||
}
|
||||
|
||||
function buildPayloadAgent(): AgentInfo {
|
||||
function buildPayloadAgent(options?: {
|
||||
canDisableShellApproval?: boolean;
|
||||
}): AgentInfo {
|
||||
const memoryConfigJson = state.agent.memoryConfigJson || {};
|
||||
const compressionParameter = tokenOnlyCompressionParameter(
|
||||
memoryConfigJson.compressionParameter,
|
||||
@@ -380,8 +520,14 @@ export function useAgentDesignerState() {
|
||||
maxAttachedMessageCount: _maxAttachedMessageCount,
|
||||
...restMemoryConfigJson
|
||||
} = memoryConfigJson;
|
||||
const {
|
||||
knowledgeBindings: _knowledgeBindings,
|
||||
skillBindings: _skillBindings,
|
||||
toolBindings: _toolBindings,
|
||||
...agent
|
||||
} = state.agent;
|
||||
return {
|
||||
...state.agent,
|
||||
...agent,
|
||||
interactionConfigJson: buildInteractionConfigPayload(
|
||||
state.agent.interactionConfigJson,
|
||||
),
|
||||
@@ -389,17 +535,10 @@ export function useAgentDesignerState() {
|
||||
...state.agent.generationConfigJson,
|
||||
stream: state.agent.generationConfigJson?.stream !== false,
|
||||
},
|
||||
executionConfigJson: {
|
||||
...state.agent.executionConfigJson,
|
||||
documentContextBudgetTokens: Math.max(
|
||||
1,
|
||||
Math.trunc(
|
||||
Number(
|
||||
state.agent.executionConfigJson?.documentContextBudgetTokens,
|
||||
) || 20_000,
|
||||
),
|
||||
),
|
||||
},
|
||||
executionConfigJson: buildAgentExecutionConfig(
|
||||
state.agent.executionConfigJson,
|
||||
Boolean(options?.canDisableShellApproval),
|
||||
),
|
||||
memoryConfigJson: {
|
||||
...restMemoryConfigJson,
|
||||
compressionParameter: {
|
||||
@@ -413,9 +552,11 @@ export function useAgentDesignerState() {
|
||||
|
||||
function buildKnowledgePayload(agentId?: number | string) {
|
||||
return state.knowledgeBindings.map((binding, index) => ({
|
||||
...binding,
|
||||
agentId,
|
||||
knowledgeId: binding.knowledgeId,
|
||||
enabled: binding.enabled !== false,
|
||||
optionsJson: binding.optionsJson || {},
|
||||
retrievalMode: binding.retrievalMode || 'HYBRID',
|
||||
sortNo: index + 1,
|
||||
}));
|
||||
}
|
||||
@@ -424,16 +565,66 @@ export function useAgentDesignerState() {
|
||||
return state.toolBindings.map((binding, index) => {
|
||||
const isMcp = String(binding.toolType || '').toUpperCase() === 'MCP';
|
||||
return {
|
||||
...binding,
|
||||
agentId,
|
||||
enabled: binding.enabled !== false,
|
||||
hitlConfigJson: binding.hitlConfigJson || {},
|
||||
hitlEnabled: Boolean(binding.hitlEnabled),
|
||||
optionsJson: binding.optionsJson || {},
|
||||
targetId: binding.targetId,
|
||||
toolType: binding.toolType,
|
||||
toolName: isMcp ? '' : binding.toolName,
|
||||
sortNo: index + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSkillPayload() {
|
||||
return state.skillBindings.map((binding, index) => ({
|
||||
skillId: binding.skillId,
|
||||
sortNo: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function bindingSignature(value: unknown) {
|
||||
return JSON.stringify(canonicalValue(value));
|
||||
}
|
||||
|
||||
function canonicalValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => canonicalValue(item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([first], [second]) => first.localeCompare(second))
|
||||
.map(([key, item]) => [key, canonicalValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentBindingSignatures() {
|
||||
return {
|
||||
knowledge: bindingSignature(buildKnowledgePayload()),
|
||||
skill: bindingSignature(buildSkillPayload()),
|
||||
tool: bindingSignature(buildToolPayload()),
|
||||
};
|
||||
}
|
||||
|
||||
function getBindingChanges(): AgentBindingChangeSet {
|
||||
const current = currentBindingSignatures();
|
||||
return {
|
||||
knowledge: current.knowledge !== savedBindingSignatures.knowledge,
|
||||
skill: current.skill !== savedBindingSignatures.skill,
|
||||
tool: current.tool !== savedBindingSignatures.tool,
|
||||
};
|
||||
}
|
||||
|
||||
function commitBindingBaseline() {
|
||||
savedBindingSignatures = currentBindingSignatures();
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
return {
|
||||
@@ -441,13 +632,20 @@ export function useAgentDesignerState() {
|
||||
state,
|
||||
selectedCapability,
|
||||
addKnowledgeNode,
|
||||
appendSkillOptions,
|
||||
addToolNode,
|
||||
buildKnowledgePayload,
|
||||
buildPayloadAgent,
|
||||
buildSkillPayload,
|
||||
buildToolPayload,
|
||||
commitBindingBaseline,
|
||||
getBindingChanges,
|
||||
markDirty,
|
||||
moveSkill,
|
||||
openTryout,
|
||||
removeSkill,
|
||||
removeSelectedCapability,
|
||||
replaceSkillBindings,
|
||||
reset,
|
||||
selectBase,
|
||||
selectNode,
|
||||
|
||||
@@ -1,398 +1,266 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import type { AguiEvent } from '../../shared/agent-agui/client';
|
||||
|
||||
import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds';
|
||||
import { EventType } from '@ag-ui/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events';
|
||||
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
|
||||
|
||||
function event(value: AguiEvent) {
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('useAgentTryoutRawRounds', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('按原始事件顺序生成 timeline', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-1',
|
||||
});
|
||||
const roundId = store.createRound('上一轮问题');
|
||||
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { reasoning: '先思考' },
|
||||
type: 'THINKING',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta: '上一轮回答' },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
store.completeRound(roundId);
|
||||
|
||||
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
|
||||
'message',
|
||||
'message',
|
||||
]);
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('业务引用只用于展示', () => {
|
||||
it('按原生 AG-UI 事件重建思考和正文', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-2',
|
||||
});
|
||||
const roundId = store.createRound('查知识库');
|
||||
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'BUSINESS',
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
chunkContent: '知识库原文',
|
||||
chunkId: 'chunk-1',
|
||||
documentId: 'doc-1',
|
||||
knowledgeId: 'kb-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'CITATIONS',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta: '引用后的回答' },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
store.completeRound(roundId);
|
||||
|
||||
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
|
||||
'message',
|
||||
'knowledge',
|
||||
'message',
|
||||
]);
|
||||
});
|
||||
|
||||
it('AgentScope fragment 工具事件不进入页面时间线', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-fragment',
|
||||
});
|
||||
const roundId = store.createRound('调用内部片段');
|
||||
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
input: { text: 'fragment' },
|
||||
toolCallId: 'fragment-1',
|
||||
toolName: '__fragment__',
|
||||
},
|
||||
type: 'TOOL_CALL',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
output: 'internal',
|
||||
toolCallId: 'fragment-1',
|
||||
toolName: '__fragment__',
|
||||
},
|
||||
type: 'TOOL_RESULT',
|
||||
});
|
||||
|
||||
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
|
||||
'message',
|
||||
]);
|
||||
});
|
||||
|
||||
it('AgentScope context_reload 工具事件不进入页面时间线', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-context-reload',
|
||||
});
|
||||
const roundId = store.createRound('展开第一层');
|
||||
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
input: { working_context_offload_uuid: 'context-id' },
|
||||
toolCallId: 'context-reload-1',
|
||||
toolName: 'context_reload',
|
||||
},
|
||||
type: 'TOOL_CALL',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
output: 'context',
|
||||
toolCallId: 'context-reload-1',
|
||||
toolName: 'context_reload',
|
||||
},
|
||||
type: 'TOOL_RESULT',
|
||||
});
|
||||
|
||||
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
|
||||
'message',
|
||||
]);
|
||||
});
|
||||
|
||||
it('刷新后能从 raw rounds 恢复 timeline', () => {
|
||||
const first = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-5',
|
||||
});
|
||||
const roundId = first.createRound('问题');
|
||||
first.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta: '回答' },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
first.completeRound(roundId);
|
||||
|
||||
const restored = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-5',
|
||||
});
|
||||
|
||||
expect(restored.buildTimelineItems().map((item) => item.type)).toEqual([
|
||||
'message',
|
||||
'message',
|
||||
]);
|
||||
});
|
||||
|
||||
it('错误轮次不会被 completeRound 覆盖为成功状态', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-error',
|
||||
});
|
||||
const roundId = store.createRound('会失败的问题');
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta: '半截回答' },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'SYSTEM',
|
||||
payload: { message: '调用失败' },
|
||||
type: 'ERROR',
|
||||
});
|
||||
|
||||
store.completeRound(roundId);
|
||||
|
||||
const assistant = store
|
||||
.buildTimelineItems()
|
||||
.find((item) => item.type === 'message' && item.role === 'assistant');
|
||||
expect(assistant).toMatchObject({ status: 'error' });
|
||||
});
|
||||
|
||||
it('流式重建 timeline 时保持稳定 item id', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-stable-id',
|
||||
sessionId: 'standard-events',
|
||||
});
|
||||
const roundId = store.createRound('问题');
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta: '你' },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
const firstIds = store.buildTimelineItems().map((item) => item.id);
|
||||
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta: '好' },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
const secondIds = store.buildTimelineItems().map((item) => item.id);
|
||||
|
||||
expect(secondIds).toEqual(firstIds);
|
||||
});
|
||||
|
||||
it('审批状态作为展示事件缓存并可刷新恢复', () => {
|
||||
const first = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-approval',
|
||||
});
|
||||
const roundId = first.createRound('审批工具');
|
||||
first.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
resumeToken: 'resume-1',
|
||||
toolCallId: 'call-approval',
|
||||
toolName: 'dangerous_tool',
|
||||
},
|
||||
type: 'FORM_REQUEST',
|
||||
});
|
||||
first.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
resumeToken: 'resume-1',
|
||||
toolCallId: 'call-approval',
|
||||
},
|
||||
type: 'FORM_APPROVING',
|
||||
});
|
||||
first.flush();
|
||||
|
||||
const restored = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-approval',
|
||||
});
|
||||
const tool = restored
|
||||
.buildTimelineItems()
|
||||
.find((item) => item.type === 'tool');
|
||||
|
||||
expect(tool).toMatchObject({
|
||||
status: 'approving',
|
||||
toolCallId: 'call-approval',
|
||||
});
|
||||
});
|
||||
|
||||
it('异步工作流轮询事件始终归并到首张审批卡', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'session-raw-async-tool',
|
||||
});
|
||||
const roundId = store.createRound('生成文档');
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
input: { user_input: '写一篇小作文' },
|
||||
requestId: 'req-async',
|
||||
resumeToken: 'resume-async',
|
||||
toolCallId: 'submit-call-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
type: 'FORM_REQUEST',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
phase: 'submit',
|
||||
sourceToolCallId: 'submit-call-1',
|
||||
status: 'RUNNING',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
type: 'TOOL_RESULT',
|
||||
});
|
||||
for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) {
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
input: { taskId: 'task-1' },
|
||||
phase: 'observe',
|
||||
sourceToolCallId,
|
||||
status: 'RUNNING',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
type: 'TOOL_CALL',
|
||||
});
|
||||
}
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'TOOL',
|
||||
payload: {
|
||||
asyncTool: true,
|
||||
phase: 'result',
|
||||
sourceToolCallId: 'result-call-1',
|
||||
status: 'SUCCEEDED',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
},
|
||||
type: 'TOOL_RESULT',
|
||||
});
|
||||
|
||||
const tools = store
|
||||
.buildTimelineItems()
|
||||
.filter((item) => item.type === 'tool');
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]).toMatchObject({
|
||||
mode: 'approval',
|
||||
status: 'success',
|
||||
taskId: 'task-1',
|
||||
toolCallId: 'task-1',
|
||||
toolName: '文档生成',
|
||||
});
|
||||
});
|
||||
|
||||
it('连续文本增量压缩后刷新内容保持一致', () => {
|
||||
const sessionId = 'stream-compaction';
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId,
|
||||
});
|
||||
const roundId = store.createRound('你好');
|
||||
const liveItems = store.buildTimelineItems();
|
||||
const assistantText = (
|
||||
items: ReturnType<typeof store.buildTimelineItems>,
|
||||
) =>
|
||||
items
|
||||
.flatMap((item) =>
|
||||
item.type === 'message' && item.role === 'assistant'
|
||||
? item.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.content)
|
||||
: [],
|
||||
)
|
||||
.join('');
|
||||
|
||||
for (const delta of ['你', '好', ',', '世界']) {
|
||||
const event = store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { delta },
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error('流式事件记录失败');
|
||||
}
|
||||
store.projectEvent(liveItems, roundId, event);
|
||||
}
|
||||
|
||||
expect(store.currentVariant(roundId)?.runtimeEvents).toHaveLength(1);
|
||||
expect(store.currentVariant(roundId)?.runtimeEvents[0]?.payload.delta).toBe(
|
||||
'你好,世界',
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
delta: '先思考',
|
||||
messageId: 'reasoning-1',
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
}),
|
||||
);
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
delta: '回答',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
}),
|
||||
);
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
runId: 'run-1',
|
||||
threadId: 'standard-events',
|
||||
type: EventType.RUN_FINISHED,
|
||||
}),
|
||||
);
|
||||
expect(assistantText(liveItems)).toBe('你好,世界');
|
||||
|
||||
store.flush();
|
||||
const restored = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId,
|
||||
});
|
||||
expect(assistantText(restored.buildTimelineItems())).toBe('你好,世界');
|
||||
const messages = store
|
||||
.buildTimelineItems()
|
||||
.filter((item) => item.type === 'message');
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(JSON.stringify(messages)).toContain('先思考');
|
||||
expect(JSON.stringify(messages)).toContain('回答');
|
||||
});
|
||||
|
||||
it('存在待持久化增量时结束事件仍立即落盘', () => {
|
||||
const sessionId = 'terminal-persist';
|
||||
it('使用原始轮次时间重放已完成处理时长', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId,
|
||||
sessionId: 'stable-duration',
|
||||
});
|
||||
const roundId = store.createRound('结束测试');
|
||||
const roundId = store.createRound('问题');
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
runId: roundId,
|
||||
threadId: 'stable-duration',
|
||||
type: EventType.RUN_STARTED,
|
||||
}),
|
||||
);
|
||||
vi.setSystemTime(19_000);
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
delta: '回答',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
}),
|
||||
);
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
runId: roundId,
|
||||
threadId: 'stable-duration',
|
||||
type: EventType.RUN_FINISHED,
|
||||
}),
|
||||
);
|
||||
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'LLM',
|
||||
payload: { reasoning: '思考中' },
|
||||
type: 'THINKING',
|
||||
});
|
||||
store.recordEvent(roundId, {
|
||||
domain: 'SYSTEM',
|
||||
payload: {},
|
||||
type: 'DONE',
|
||||
});
|
||||
const turnItems = store
|
||||
.buildTimelineItems()
|
||||
.filter(
|
||||
(item) =>
|
||||
item.roundId === roundId &&
|
||||
!(item.type === 'message' && item.role === 'user'),
|
||||
);
|
||||
expect(turnItems.length).toBeGreaterThan(0);
|
||||
expect(turnItems.every((item) => item.turnStartedAt === 1000)).toBe(true);
|
||||
expect(turnItems.every((item) => item.turnFinishedAt === 19_000)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
const restored = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId,
|
||||
});
|
||||
expect(restored.currentVariant(roundId)?.status).toBe('completed');
|
||||
it('持久化并恢复 EasyFlow 自定义引用事件', () => {
|
||||
const sessionId = 'custom-citations';
|
||||
const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
const roundId = store.createRound('查知识库');
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
name: easyFlowAguiCustomEvent.knowledgeCitations,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
items: [{ chunkContent: '知识库原文', id: 'chunk-1' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
store.flush();
|
||||
|
||||
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
expect(
|
||||
restored
|
||||
.currentVariant(roundId)
|
||||
?.runtimeEvents.some(
|
||||
(event) => event.domain === 'SYSTEM' && event.type === 'DONE',
|
||||
.buildTimelineItems()
|
||||
.some(
|
||||
(item) =>
|
||||
item.type === 'message' && Boolean(item.knowledgeItems?.length),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('按工具调用 ID 合并标准工具参数与结果', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'tool-events',
|
||||
});
|
||||
const roundId = store.createRound('调用工具');
|
||||
for (const item of [
|
||||
event({
|
||||
toolCallId: 'call-1',
|
||||
toolCallName: 'calculator',
|
||||
type: EventType.TOOL_CALL_START,
|
||||
}),
|
||||
event({
|
||||
delta: '{"value":1}',
|
||||
toolCallId: 'call-1',
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
}),
|
||||
event({
|
||||
content: '2',
|
||||
messageId: 'tool-result-1',
|
||||
role: 'tool',
|
||||
toolCallId: 'call-1',
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
}),
|
||||
]) {
|
||||
store.recordEvent(roundId, item);
|
||||
}
|
||||
|
||||
expect(
|
||||
store.buildTimelineItems().find((item) => item.type === 'tool'),
|
||||
).toMatchObject({
|
||||
input: { value: 1 },
|
||||
output: '2',
|
||||
status: 'success',
|
||||
toolCallId: 'call-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('审批缓存仅保存不透明 approvalId', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'approval-events',
|
||||
});
|
||||
const roundId = store.createRound('审批工具');
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
name: easyFlowAguiCustomEvent.toolApprovalRequired,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
approvalId: 'approval-public',
|
||||
toolCallId: 'call-approval',
|
||||
toolName: 'dangerous_tool',
|
||||
},
|
||||
}),
|
||||
);
|
||||
store.flush();
|
||||
|
||||
const serialized = JSON.stringify(store.currentVariant(roundId));
|
||||
expect(serialized).toContain('approval-public');
|
||||
expect(serialized).not.toContain('resumeToken');
|
||||
const tool = store
|
||||
.buildTimelineItems()
|
||||
.find((item) => item.type === 'tool');
|
||||
expect(tool?.type === 'tool' && tool.approval?.approvalId).toBe(
|
||||
'approval-public',
|
||||
);
|
||||
});
|
||||
|
||||
it('压缩连续标准文本增量且刷新内容一致', () => {
|
||||
const sessionId = 'stream-compaction';
|
||||
const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
const roundId = store.createRound('你好');
|
||||
for (const delta of ['你', '好', ',', '世界']) {
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
delta,
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
store.flush();
|
||||
|
||||
expect(store.currentVariant(roundId)?.runtimeEvents).toHaveLength(1);
|
||||
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
expect(JSON.stringify(restored.buildTimelineItems())).toContain(
|
||||
'你好,世界',
|
||||
);
|
||||
});
|
||||
|
||||
it('记录 SDK 或 Vue 代理事件时转换为可持久化 JSON 数据', () => {
|
||||
const store = useAgentTryoutRawRounds({
|
||||
mode: 'draft',
|
||||
sessionId: 'proxied-event',
|
||||
});
|
||||
const roundId = store.createRound('代理事件');
|
||||
const proxiedEvent = new Proxy(
|
||||
event({
|
||||
delta: '代理响应',
|
||||
messageId: 'assistant-proxy',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(() => store.recordEvent(roundId, proxiedEvent)).not.toThrow();
|
||||
store.flush();
|
||||
expect(JSON.stringify(store.buildTimelineItems())).toContain('代理响应');
|
||||
});
|
||||
|
||||
it('rUN_ERROR 终态立即持久化为失败轮次', () => {
|
||||
const sessionId = 'terminal-error';
|
||||
const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
const roundId = store.createRound('失败测试');
|
||||
store.recordEvent(
|
||||
roundId,
|
||||
event({
|
||||
message: '调用失败',
|
||||
runId: 'run-1',
|
||||
threadId: sessionId,
|
||||
type: EventType.RUN_ERROR,
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId });
|
||||
expect(restored.currentVariant(roundId)?.status).toBe('error');
|
||||
expect(JSON.stringify(restored.buildTimelineItems())).toContain('调用失败');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,21 @@ import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatImageAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import type { AguiEvent } from '../../shared/agent-agui/client';
|
||||
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
interface AgentTryoutRuntimeEvent {
|
||||
createdAt: number;
|
||||
domain: string;
|
||||
payload: Record<string, unknown>;
|
||||
type: string;
|
||||
}
|
||||
import { EventSchemas, EventType } from '@ag-ui/client';
|
||||
|
||||
import {
|
||||
applyAguiEventToTimeline,
|
||||
createAguiTimelineProjectionState,
|
||||
} from '../../shared/agent-agui/projection';
|
||||
|
||||
type AgentTryoutRuntimeEvent = AguiEvent;
|
||||
type AgentTryoutRoundStatus = 'completed' | 'error' | 'running';
|
||||
|
||||
interface AgentTryoutRawVariant {
|
||||
@@ -44,7 +45,7 @@ interface AgentTryoutRawSessionRecord {
|
||||
version: number;
|
||||
}
|
||||
|
||||
const STORAGE_VERSION = 2;
|
||||
const STORAGE_VERSION = 3;
|
||||
const MAX_ROUNDS = 50;
|
||||
const MAX_VARIANTS = 10;
|
||||
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
|
||||
@@ -55,41 +56,11 @@ function createRoundId() {
|
||||
return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true';
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function normalizeToolName(value: unknown) {
|
||||
return asText(value).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isHiddenToolName(value: unknown) {
|
||||
const normalizedName = normalizeToolName(value);
|
||||
return (
|
||||
normalizedName === 'retrieve_knowledge' ||
|
||||
normalizedName === 'context_reload' ||
|
||||
normalizedName === '__fragment__'
|
||||
);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
// 草稿缓存最终写入 sessionStorage,仅接受 JSON 数据;JSON 往返同时解除 Vue/SDK Proxy,
|
||||
// 避免 structuredClone 对代理对象抛出 DataCloneError。
|
||||
// eslint-disable-next-line unicorn/prefer-structured-clone -- structuredClone 无法复制 Proxy
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function storageKey(mode: string, sessionId: string) {
|
||||
@@ -115,32 +86,18 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimeEvent(
|
||||
value: any,
|
||||
): AgentTryoutRuntimeEvent | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const domain = asText(value.domain).toUpperCase();
|
||||
const type = asText(value.type).toUpperCase();
|
||||
if (!domain || !type) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
createdAt: Number(value.createdAt || Date.now()),
|
||||
domain,
|
||||
payload: asRecord(value.payload),
|
||||
type,
|
||||
};
|
||||
function normalizeRuntimeEvent(value: unknown) {
|
||||
const parsed = EventSchemas.safeParse(value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
function normalizeVariant(value: any, index: number) {
|
||||
function normalizeVariant(value: any, index: number): AgentTryoutRawVariant {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return createVariant(index);
|
||||
}
|
||||
const runtimeEvents = Array.isArray(value.runtimeEvents)
|
||||
? value.runtimeEvents
|
||||
.map((item: any) => normalizeRuntimeEvent(item))
|
||||
.map((item: unknown) => normalizeRuntimeEvent(item))
|
||||
.filter(
|
||||
(
|
||||
item: AgentTryoutRuntimeEvent | undefined,
|
||||
@@ -163,8 +120,8 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const prompt = asText(value.prompt);
|
||||
const roundId = asText(value.roundId);
|
||||
const prompt = String(value.prompt || '');
|
||||
const roundId = String(value.roundId || '');
|
||||
const documents = Array.isArray(value.documents)
|
||||
? value.documents.slice(0, 3)
|
||||
: [];
|
||||
@@ -175,7 +132,9 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
|
||||
const variants = Array.isArray(value.variants)
|
||||
? value.variants
|
||||
.slice(-MAX_VARIANTS)
|
||||
.map((item: any, index: number) => normalizeVariant(item, index + 1))
|
||||
.map((item: unknown, index: number) =>
|
||||
normalizeVariant(item, index + 1),
|
||||
)
|
||||
: [];
|
||||
if (variants.length === 0) {
|
||||
variants.push(createVariant(1));
|
||||
@@ -207,14 +166,10 @@ function restoreSession(mode: string, sessionId: string) {
|
||||
return memoryRecords.map((item) => clone(item));
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return [];
|
||||
}
|
||||
if (!storage) return [];
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as AgentTryoutRawSessionRecord;
|
||||
if (parsed.sessionId !== sessionId || parsed.version !== STORAGE_VERSION) {
|
||||
return [];
|
||||
@@ -224,10 +179,7 @@ function restoreSession(mode: string, sessionId: string) {
|
||||
.map((item) => normalizeRound(item))
|
||||
.filter((item): item is AgentTryoutRawRound => item !== undefined)
|
||||
: [];
|
||||
memorySessions.set(
|
||||
key,
|
||||
rounds.map((item) => clone(item)),
|
||||
);
|
||||
memorySessions.set(key, clone(rounds));
|
||||
return rounds;
|
||||
} catch {
|
||||
return [];
|
||||
@@ -240,37 +192,29 @@ function persistSession(
|
||||
rounds: AgentTryoutRawRound[],
|
||||
) {
|
||||
const key = storageKey(mode, sessionId);
|
||||
const snapshot: AgentTryoutRawSessionRecord = {
|
||||
rounds: rounds.slice(-MAX_ROUNDS).map((item) => clone(item)),
|
||||
sessionId,
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
memorySessions.set(
|
||||
key,
|
||||
snapshot.rounds.map((item) => clone(item)),
|
||||
);
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(snapshot));
|
||||
// 同一份 JSON 同时用于解除 Proxy、内存快照和 sessionStorage,
|
||||
// 避免对完整草稿会话重复做全量序列化。
|
||||
const serialized = JSON.stringify({
|
||||
rounds: rounds.slice(-MAX_ROUNDS),
|
||||
sessionId,
|
||||
version: STORAGE_VERSION,
|
||||
} satisfies AgentTryoutRawSessionRecord);
|
||||
const snapshot = JSON.parse(serialized) as AgentTryoutRawSessionRecord;
|
||||
memorySessions.set(key, snapshot.rounds);
|
||||
safeSessionStorage()?.setItem(key, serialized);
|
||||
} catch {
|
||||
// 试运行缓存失败不影响当前聊天主流程。
|
||||
// 本地试用缓存失败不影响当前聊天。
|
||||
}
|
||||
}
|
||||
|
||||
function removeStoredSession(mode: string, sessionId: string) {
|
||||
const key = storageKey(mode, sessionId);
|
||||
memorySessions.delete(key);
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
safeSessionStorage()?.removeItem(key);
|
||||
} catch {
|
||||
// 清理缓存失败不影响界面重置。
|
||||
// 清理本地缓存失败不影响界面重置。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,50 +226,36 @@ function selectedVariant(round: AgentTryoutRawRound) {
|
||||
);
|
||||
}
|
||||
|
||||
function streamingPayloadKey(event: AgentTryoutRuntimeEvent) {
|
||||
if (event.domain !== 'LLM') {
|
||||
return undefined;
|
||||
}
|
||||
let candidates: string[] = [];
|
||||
if (event.type === 'MESSAGE') {
|
||||
candidates = ['delta'];
|
||||
} else if (event.type === 'THINKING') {
|
||||
candidates = ['reasoning', 'delta', 'text'];
|
||||
}
|
||||
return candidates.find((key) => typeof event.payload[key] === 'string');
|
||||
}
|
||||
|
||||
function appendRuntimeEvent(
|
||||
variant: AgentTryoutRawVariant,
|
||||
event: AgentTryoutRuntimeEvent,
|
||||
) {
|
||||
const previous = variant.runtimeEvents[variant.runtimeEvents.length - 1];
|
||||
const currentKey = streamingPayloadKey(event);
|
||||
const previousKey = previous ? streamingPayloadKey(previous) : undefined;
|
||||
if (
|
||||
previous &&
|
||||
previous.domain === event.domain &&
|
||||
previous.type === event.type &&
|
||||
currentKey &&
|
||||
currentKey === previousKey
|
||||
previous?.type === EventType.TEXT_MESSAGE_CONTENT &&
|
||||
event.type === EventType.TEXT_MESSAGE_CONTENT &&
|
||||
previous.messageId === event.messageId
|
||||
) {
|
||||
previous.payload = {
|
||||
...previous.payload,
|
||||
...event.payload,
|
||||
[currentKey]:
|
||||
asText(previous.payload[currentKey]) +
|
||||
asText(event.payload[currentKey]),
|
||||
};
|
||||
previous.delta += event.delta;
|
||||
return;
|
||||
}
|
||||
variant.runtimeEvents.push(event);
|
||||
}
|
||||
|
||||
function visibleText(item: ChatTimelineMessageItem) {
|
||||
return item.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.content)
|
||||
.join('');
|
||||
if (
|
||||
previous?.type === EventType.REASONING_MESSAGE_CONTENT &&
|
||||
event.type === EventType.REASONING_MESSAGE_CONTENT &&
|
||||
previous.messageId === event.messageId
|
||||
) {
|
||||
previous.delta += event.delta;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
previous?.type === EventType.TOOL_CALL_ARGS &&
|
||||
event.type === EventType.TOOL_CALL_ARGS &&
|
||||
previous.toolCallId === event.toolCallId
|
||||
) {
|
||||
previous.delta += event.delta;
|
||||
return;
|
||||
}
|
||||
variant.runtimeEvents.push(clone(event));
|
||||
}
|
||||
|
||||
function isUserMessage(
|
||||
@@ -340,335 +270,37 @@ function isAssistantMessage(
|
||||
return item.type === 'message' && item.role === 'assistant';
|
||||
}
|
||||
|
||||
function findRoundResponseRange(items: ChatTimelineItem[], roundId: string) {
|
||||
const userIndex = items.findIndex(
|
||||
(item) => isUserMessage(item) && item.roundId === roundId,
|
||||
);
|
||||
if (userIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const nextUserIndex = items.findIndex(
|
||||
(item, index) => index > userIndex && isUserMessage(item),
|
||||
);
|
||||
return {
|
||||
end: nextUserIndex === -1 ? items.length : nextUserIndex,
|
||||
start: userIndex + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantSegmentIndex(items: ChatTimelineItem[], roundId: string) {
|
||||
return items.filter(
|
||||
(item) => isAssistantMessage(item) && item.roundId === roundId,
|
||||
).length;
|
||||
}
|
||||
|
||||
function nextAssistantId(
|
||||
items: ChatTimelineItem[],
|
||||
roundId: string,
|
||||
variantIndex: number,
|
||||
) {
|
||||
const last = items[items.length - 1];
|
||||
if (
|
||||
last &&
|
||||
isAssistantMessage(last) &&
|
||||
last.roundId === roundId &&
|
||||
last.status !== 'done'
|
||||
) {
|
||||
return last.id;
|
||||
}
|
||||
return `assistant-${roundId}-${variantIndex}-${assistantSegmentIndex(items, roundId) + 1}`;
|
||||
}
|
||||
|
||||
function normalizeAssistantPartIds(
|
||||
items: ChatTimelineItem[],
|
||||
roundId: string,
|
||||
variantIndex: number,
|
||||
) {
|
||||
const segment = assistantSegmentIndex(items, roundId);
|
||||
const latest = [...items]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
isAssistantMessage(item) && item.roundId === roundId,
|
||||
);
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
latest.id = `assistant-${roundId}-${variantIndex}-${segment}`;
|
||||
latest.parts.forEach((part, index) => {
|
||||
part.id = `${part.type}-${roundId}-${variantIndex}-${segment}-${index + 1}`;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLatestItemId(
|
||||
items: ChatTimelineItem[],
|
||||
prefix: string,
|
||||
roundId: string,
|
||||
variantIndex: number,
|
||||
) {
|
||||
const last = items[items.length - 1];
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
last.id = `${prefix}-${roundId}-${variantIndex}`;
|
||||
function visibleText(item: ChatTimelineMessageItem) {
|
||||
return item.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.content)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function markRoundCompleted(
|
||||
items: ChatTimelineItem[],
|
||||
roundId: string,
|
||||
round: AgentTryoutRawRound,
|
||||
variant: AgentTryoutRawVariant,
|
||||
variantCount: number,
|
||||
selectedVariantIndex: number,
|
||||
) {
|
||||
const range = findRoundResponseRange(items, roundId);
|
||||
const source = range ? items.slice(range.start, range.end) : items;
|
||||
const latest = [...source]
|
||||
.reverse()
|
||||
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item));
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
const userIndex = items.findIndex(
|
||||
(item) => isUserMessage(item) && item.roundId === round.roundId,
|
||||
);
|
||||
const nextUserIndex = items.findIndex(
|
||||
(item, index) => index > userIndex && isUserMessage(item),
|
||||
);
|
||||
const source = items.slice(
|
||||
userIndex + 1,
|
||||
nextUserIndex === -1 ? items.length : nextUserIndex,
|
||||
);
|
||||
const latest = [...source].reverse().find((item) => isAssistantMessage(item));
|
||||
if (!latest) return;
|
||||
latest.roundCompleted = true;
|
||||
latest.status = latest.status === 'error' ? 'error' : 'done';
|
||||
latest.regenerable = true;
|
||||
latest.switchable = variantCount > 1;
|
||||
latest.variantCount = variantCount;
|
||||
latest.switchable = round.variants.length > 1;
|
||||
latest.variantCount = round.variants.length;
|
||||
latest.variantIndex = variant.variantIndex;
|
||||
latest.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
|
||||
function normalizeKnowledgeItems(payload: Record<string, unknown>) {
|
||||
const source =
|
||||
payload.items ||
|
||||
payload.hits ||
|
||||
payload.documents ||
|
||||
payload.knowledgeResults ||
|
||||
[];
|
||||
if (!Array.isArray(source)) {
|
||||
return [];
|
||||
}
|
||||
const topLevelKnowledgeType = asText(payload.knowledgeType);
|
||||
const topLevelFaqCollection =
|
||||
payload.faqCollection === undefined
|
||||
? topLevelKnowledgeType.toUpperCase() === 'FAQ'
|
||||
: asBoolean(payload.faqCollection);
|
||||
return source.map((item: any) => {
|
||||
const metadata = asRecord(item.metadata);
|
||||
const sourceFileName = asText(
|
||||
item.sourceFileName ?? metadata.sourceFileName,
|
||||
);
|
||||
const documentName = asText(
|
||||
item.documentName ?? item.documentTitle ?? item.title,
|
||||
);
|
||||
const chunkId = asText(item.chunkId ?? metadata.chunkId ?? item.id);
|
||||
const documentId = asText(
|
||||
item.documentId ?? metadata.documentId ?? item.id,
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
id: asText(item.id || chunkId || documentId),
|
||||
knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId),
|
||||
knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName),
|
||||
knowledgeType: asText(item.knowledgeType ?? payload.knowledgeType),
|
||||
faqCollection:
|
||||
item.faqCollection === undefined
|
||||
? topLevelFaqCollection
|
||||
: asBoolean(item.faqCollection),
|
||||
documentId,
|
||||
documentName,
|
||||
chunkId,
|
||||
score: item.score ?? item.similarity,
|
||||
source: item.source,
|
||||
sourceFileName,
|
||||
sourceUri: asText(item.sourceUri ?? metadata.sourceUri),
|
||||
metadata,
|
||||
chunkContent: asText(
|
||||
item.chunkContent ?? item.content ?? item.text ?? item.summary,
|
||||
),
|
||||
content: asText(item.content ?? item.text ?? item.summary),
|
||||
title: documentName || sourceFileName || item.source,
|
||||
} satisfies ChatTimelineKnowledgeHit;
|
||||
});
|
||||
}
|
||||
|
||||
function statusKeyForProjection(
|
||||
payload: Record<string, unknown>,
|
||||
roundId: string,
|
||||
variantIndex: number,
|
||||
fallback = 'status',
|
||||
) {
|
||||
const statusKey = asText(payload.statusKey) || fallback;
|
||||
return `${statusKey}:${roundId}:${variantIndex}`;
|
||||
}
|
||||
|
||||
function projectEventToTimeline(
|
||||
items: ChatTimelineItem[],
|
||||
event: AgentTryoutRuntimeEvent,
|
||||
roundId: string,
|
||||
variantIndex: number,
|
||||
) {
|
||||
const { domain, payload, type } = event;
|
||||
if (domain === 'LLM' && type === 'MESSAGE') {
|
||||
ChatTimelineBuilder.appendMessageDelta(items, payload.delta, {
|
||||
id: nextAssistantId(items, roundId, variantIndex),
|
||||
roundId,
|
||||
});
|
||||
normalizeAssistantPartIds(items, roundId, variantIndex);
|
||||
return;
|
||||
}
|
||||
if (domain === 'LLM' && type === 'THINKING') {
|
||||
const text = asText(payload.reasoning ?? payload.delta ?? payload.text);
|
||||
ChatTimelineBuilder.appendThinkingDelta(items, text, {
|
||||
id: nextAssistantId(items, roundId, variantIndex),
|
||||
roundId,
|
||||
});
|
||||
normalizeAssistantPartIds(items, roundId, variantIndex);
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_REQUEST') {
|
||||
ChatTimelineBuilder.appendToolApproval(items, {
|
||||
expiresAt: asText(payload.expiresAt),
|
||||
input: payload.input,
|
||||
metadata: payload.metadata,
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||
),
|
||||
toolDisplayName: asText(payload.toolDisplayName),
|
||||
toolName: asText(payload.toolName),
|
||||
toolType: asText(payload.toolType),
|
||||
});
|
||||
if (items[items.length - 1]?.type === 'tool') {
|
||||
normalizeLatestItemId(items, 'tool-approval', roundId, variantIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_APPROVING') {
|
||||
ChatTimelineBuilder.markToolApproving(items, {
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_REJECTED') {
|
||||
ChatTimelineBuilder.markToolRejected(items, {
|
||||
reason: asText(payload.reason),
|
||||
requestId: asText(payload.requestId),
|
||||
resumeToken: asText(payload.resumeToken),
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
|
||||
const rawToolName = asText(payload.toolName ?? payload.name);
|
||||
const normalizedToolName = normalizeToolName(rawToolName);
|
||||
if (!normalizedToolName && type === 'TOOL_CALL') {
|
||||
return;
|
||||
}
|
||||
const displayToolName = asText(
|
||||
payload.toolDisplayName ?? rawToolName ?? '工具',
|
||||
);
|
||||
const asyncTool = payload.asyncTool === true;
|
||||
const taskInput = asRecord(payload.input ?? payload.toolInput);
|
||||
let status: ChatTimelineToolStatus = 'running';
|
||||
if (asyncTool) {
|
||||
status = asyncToolTimelineStatus(payload);
|
||||
} else if (type === 'TOOL_RESULT') {
|
||||
status = 'success';
|
||||
}
|
||||
let toolName = displayToolName;
|
||||
if (!asyncTool && isHiddenToolName(rawToolName)) {
|
||||
toolName = rawToolName;
|
||||
}
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output: asyncTool
|
||||
? (payload.summary ??
|
||||
payload.label ??
|
||||
payload.output ??
|
||||
payload.result ??
|
||||
payload.text)
|
||||
: (payload.output ?? payload.result ?? payload.text),
|
||||
status,
|
||||
statusKey: statusKeyForProjection(
|
||||
payload,
|
||||
roundId,
|
||||
variantIndex,
|
||||
'knowledge-retrieval',
|
||||
),
|
||||
sourceToolCallId: asyncTool
|
||||
? asText(payload.sourceToolCallId ?? payload.source_tool_call_id)
|
||||
: undefined,
|
||||
taskId: asyncTool
|
||||
? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id)
|
||||
: undefined,
|
||||
toolCallId: asText(
|
||||
payload.toolCallId ??
|
||||
payload.taskId ??
|
||||
payload.tool_call_id ??
|
||||
payload.id,
|
||||
),
|
||||
toolName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (domain === 'BUSINESS' && type === 'CITATIONS') {
|
||||
const itemsToAppend = normalizeKnowledgeItems(payload);
|
||||
if (itemsToAppend.length > 0) {
|
||||
ChatTimelineBuilder.appendKnowledge(items, itemsToAppend);
|
||||
if (items[items.length - 1]?.type === 'knowledge') {
|
||||
normalizeLatestItemId(items, 'knowledge', roundId, variantIndex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (domain === 'BUSINESS' && type === 'STATUS') {
|
||||
if (asText(payload.statusKey) === 'memory-compression') {
|
||||
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
|
||||
compressed:
|
||||
typeof payload.compressed === 'boolean'
|
||||
? payload.compressed
|
||||
: undefined,
|
||||
label: asText(payload.label),
|
||||
phase: asText(payload.phase),
|
||||
status: asText(payload.status),
|
||||
statusKey: statusKeyForProjection(payload, roundId, variantIndex),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (asText(payload.statusKey) === 'knowledge-retrieval') {
|
||||
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
||||
items,
|
||||
asText(payload.status) === 'running' ? 'running' : 'done',
|
||||
statusKeyForProjection(payload, roundId, variantIndex),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type === 'ERROR' || domain === 'ERROR') {
|
||||
ChatTimelineBuilder.appendError(
|
||||
items,
|
||||
payload.message ?? payload.error ?? '试运行失败',
|
||||
);
|
||||
normalizeLatestItemId(items, 'error', roundId, variantIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function asyncToolTimelineStatus(
|
||||
payload: Record<string, unknown>,
|
||||
): ChatTimelineToolStatus {
|
||||
const status = asText(payload.status).toUpperCase();
|
||||
if (status === 'SUCCEEDED') return 'success';
|
||||
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
|
||||
return 'error';
|
||||
}
|
||||
return 'running';
|
||||
latest.selectedVariantIndex = round.selectedVariantIndex;
|
||||
}
|
||||
|
||||
function sortedRounds(rounds: Map<string, AgentTryoutRawRound>) {
|
||||
@@ -684,6 +316,10 @@ export function useAgentTryoutRawRounds(options: {
|
||||
sessionId: string;
|
||||
}) {
|
||||
const rounds = new Map<string, AgentTryoutRawRound>();
|
||||
const liveProjectionStates = new Map<
|
||||
string,
|
||||
ReturnType<typeof createAguiTimelineProjectionState>
|
||||
>();
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
for (const round of restoreSession(options.mode, options.sessionId)) {
|
||||
@@ -705,20 +341,15 @@ export function useAgentTryoutRawRounds(options: {
|
||||
}
|
||||
|
||||
function schedulePersist() {
|
||||
if (persistTimer) {
|
||||
return;
|
||||
}
|
||||
persistTimer = setTimeout(() => {
|
||||
persistNow();
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
if (persistTimer) return;
|
||||
persistTimer = setTimeout(persistNow, PERSIST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
rounds.clear();
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
}
|
||||
liveProjectionStates.clear();
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
removeStoredSession(options.mode, options.sessionId);
|
||||
}
|
||||
|
||||
@@ -746,10 +377,7 @@ export function useAgentTryoutRawRounds(options: {
|
||||
|
||||
function regenerateRound(roundId: string) {
|
||||
const round = rounds.get(roundId);
|
||||
if (!round) {
|
||||
return undefined;
|
||||
}
|
||||
const nextVariantIndex = Math.min(round.variants.length + 1, MAX_VARIANTS);
|
||||
if (!round) return undefined;
|
||||
round.variants.push(createVariant(round.variants.length + 1));
|
||||
if (round.variants.length > MAX_VARIANTS) {
|
||||
round.variants.splice(0, round.variants.length - MAX_VARIANTS);
|
||||
@@ -757,7 +385,7 @@ export function useAgentTryoutRawRounds(options: {
|
||||
variant.variantIndex = index + 1;
|
||||
});
|
||||
}
|
||||
round.selectedVariantIndex = nextVariantIndex;
|
||||
round.selectedVariantIndex = round.variants.length;
|
||||
round.status = 'running';
|
||||
round.updatedAt = Date.now();
|
||||
persistNow();
|
||||
@@ -773,68 +401,60 @@ export function useAgentTryoutRawRounds(options: {
|
||||
return round ? selectedVariant(round) : undefined;
|
||||
}
|
||||
|
||||
function recordEvent(
|
||||
roundId: string,
|
||||
event: {
|
||||
domain: string;
|
||||
payload?: Record<string, unknown>;
|
||||
type: string;
|
||||
},
|
||||
) {
|
||||
function recordEvent(roundId: string, event: AgentTryoutRuntimeEvent) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) {
|
||||
return;
|
||||
}
|
||||
const runtimeEvent: AgentTryoutRuntimeEvent = {
|
||||
createdAt: Date.now(),
|
||||
domain: event.domain.toUpperCase(),
|
||||
payload: event.payload || {},
|
||||
type: event.type.toUpperCase(),
|
||||
};
|
||||
appendRuntimeEvent(variant, runtimeEvent);
|
||||
if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') {
|
||||
if (!round || !variant) return undefined;
|
||||
appendRuntimeEvent(variant, event);
|
||||
if (event.type === EventType.RUN_FINISHED) {
|
||||
variant.status = 'completed';
|
||||
round.status = 'completed';
|
||||
}
|
||||
if (runtimeEvent.type === 'ERROR' || runtimeEvent.domain === 'ERROR') {
|
||||
} else if (event.type === EventType.RUN_ERROR) {
|
||||
variant.status = 'error';
|
||||
round.status = 'error';
|
||||
}
|
||||
variant.updatedAt = Date.now();
|
||||
round.updatedAt = variant.updatedAt;
|
||||
if (
|
||||
(runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') ||
|
||||
runtimeEvent.type === 'ERROR' ||
|
||||
runtimeEvent.domain === 'ERROR'
|
||||
event.type === EventType.RUN_FINISHED ||
|
||||
event.type === EventType.RUN_ERROR
|
||||
) {
|
||||
persistNow();
|
||||
return runtimeEvent;
|
||||
} else {
|
||||
schedulePersist();
|
||||
}
|
||||
schedulePersist();
|
||||
return runtimeEvent;
|
||||
return event;
|
||||
}
|
||||
|
||||
function projectEvent(
|
||||
items: ChatTimelineItem[],
|
||||
roundId: string,
|
||||
event: AgentTryoutRuntimeEvent,
|
||||
onInputAccepted?: (
|
||||
payload: Record<string, unknown>,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) {
|
||||
return;
|
||||
if (!round || !variant) return;
|
||||
const key = `${roundId}:${variant.variantIndex}`;
|
||||
let state = liveProjectionStates.get(key);
|
||||
if (!state) {
|
||||
state = createAguiTimelineProjectionState(variant.createdAt);
|
||||
liveProjectionStates.set(key, state);
|
||||
}
|
||||
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
event,
|
||||
{ onInputAccepted, roundId, startedAt: variant.createdAt },
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
function completeRound(roundId: string) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) {
|
||||
return;
|
||||
}
|
||||
if (variant.status === 'error' || round.status === 'error') {
|
||||
if (!round || !variant || variant.status === 'error') {
|
||||
persistNow();
|
||||
return;
|
||||
}
|
||||
@@ -845,6 +465,20 @@ export function useAgentTryoutRawRounds(options: {
|
||||
persistNow();
|
||||
}
|
||||
|
||||
function failRound(roundId: string) {
|
||||
const round = rounds.get(roundId);
|
||||
const variant = round && selectedVariant(round);
|
||||
if (!round || !variant) {
|
||||
persistNow();
|
||||
return;
|
||||
}
|
||||
variant.status = 'error';
|
||||
round.status = 'error';
|
||||
variant.updatedAt = Date.now();
|
||||
round.updatedAt = variant.updatedAt;
|
||||
persistNow();
|
||||
}
|
||||
|
||||
function buildTimelineItems() {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
for (const round of sortedRounds(rounds)) {
|
||||
@@ -855,26 +489,34 @@ export function useAgentTryoutRawRounds(options: {
|
||||
roundId: round.roundId,
|
||||
});
|
||||
const variant = selectedVariant(round);
|
||||
if (!variant) {
|
||||
continue;
|
||||
}
|
||||
if (!variant) continue;
|
||||
const state = createAguiTimelineProjectionState(variant.createdAt);
|
||||
ChatTimelineBuilder.ensureAssistantTurn(items, {
|
||||
id: `turn-${round.roundId}`,
|
||||
roundId: round.roundId,
|
||||
turnStartedAt: variant.createdAt,
|
||||
});
|
||||
for (const event of variant.runtimeEvents) {
|
||||
projectEventToTimeline(
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
event,
|
||||
round.roundId,
|
||||
variant.variantIndex,
|
||||
{
|
||||
finishedAt: variant.updatedAt,
|
||||
roundId: round.roundId,
|
||||
startedAt: variant.createdAt,
|
||||
},
|
||||
state,
|
||||
);
|
||||
}
|
||||
if (variant.status === 'completed' || variant.status === 'error') {
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
markRoundCompleted(
|
||||
items,
|
||||
round.roundId,
|
||||
variant,
|
||||
round.variants.length,
|
||||
round.selectedVariantIndex,
|
||||
);
|
||||
if (variant.status !== 'running') {
|
||||
ChatTimelineBuilder.finalize(items, {
|
||||
roundCompleted: variant.status === 'completed',
|
||||
roundId: round.roundId,
|
||||
turnFinishedAt: variant.updatedAt,
|
||||
turnStartedAt: variant.createdAt,
|
||||
turnSucceeded: variant.status === 'completed',
|
||||
});
|
||||
markRoundCompleted(items, round, variant);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
@@ -882,16 +524,12 @@ export function useAgentTryoutRawRounds(options: {
|
||||
|
||||
function selectVariant(roundId: string, direction: 'next' | 'previous') {
|
||||
const round = rounds.get(roundId);
|
||||
if (!round) {
|
||||
return;
|
||||
}
|
||||
if (!round) return;
|
||||
const next =
|
||||
direction === 'previous'
|
||||
? round.selectedVariantIndex - 1
|
||||
: round.selectedVariantIndex + 1;
|
||||
if (next < 1 || next > round.variants.length) {
|
||||
return;
|
||||
}
|
||||
if (next < 1 || next > round.variants.length) return;
|
||||
round.selectedVariantIndex = next;
|
||||
round.updatedAt = Date.now();
|
||||
persistNow();
|
||||
@@ -906,18 +544,15 @@ export function useAgentTryoutRawRounds(options: {
|
||||
return direction === 'previous' ? current > 1 : current < total;
|
||||
}
|
||||
|
||||
function copyText(item: ChatTimelineMessageItem) {
|
||||
return visibleText(item);
|
||||
}
|
||||
|
||||
return {
|
||||
buildTimelineItems,
|
||||
canSwitch,
|
||||
clear,
|
||||
completeRound,
|
||||
copyText,
|
||||
copyText: visibleText,
|
||||
createRound,
|
||||
currentVariant,
|
||||
failRound,
|
||||
getPrompt,
|
||||
projectEvent,
|
||||
recordEvent,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { EasyFlowAguiRunOptions } from '../../shared/agent-agui/client';
|
||||
import type { AgentInfo } from '../types';
|
||||
|
||||
import { EventType } from '@ag-ui/client';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAgentTryoutStream } from './useAgentTryoutStream';
|
||||
|
||||
const aguiMocks = vi.hoisted(() => ({
|
||||
abort: vi.fn(),
|
||||
runs: [] as EasyFlowAguiRunOptions[],
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/agent-agui/client', () => ({
|
||||
EasyFlowAguiClient: class {
|
||||
abort = aguiMocks.abort;
|
||||
|
||||
run(options: EasyFlowAguiRunOptions) {
|
||||
aguiMocks.runs.push(options);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
clearAgentDraftSession: vi.fn(async () => ({ errorCode: 0 })),
|
||||
}));
|
||||
|
||||
describe('useAgentTryoutStream', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
aguiMocks.runs.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ignores late events from a stopped draft run after resending', async () => {
|
||||
const stream = useAgentTryoutStream();
|
||||
const payload = {
|
||||
agent: { id: 'agent-1', name: 'Agent' } as AgentInfo,
|
||||
knowledgeBindings: [],
|
||||
prompt: '旧问题',
|
||||
sessionId: 'draft-race',
|
||||
skillBindings: [],
|
||||
toolBindings: [],
|
||||
};
|
||||
|
||||
void stream.sendDraft(payload);
|
||||
await Promise.resolve();
|
||||
stream.stop();
|
||||
void stream.sendDraft({ ...payload, prompt: '新问题' });
|
||||
await Promise.resolve();
|
||||
|
||||
aguiMocks.runs[0]?.onEvent({
|
||||
delta: '旧流迟到正文',
|
||||
messageId: 'old-assistant',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
aguiMocks.runs[1]?.onEvent({
|
||||
delta: '新流正文',
|
||||
messageId: 'new-assistant',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
});
|
||||
|
||||
const timeline = JSON.stringify(stream.timelineItems.value);
|
||||
expect(timeline).toContain('新流正文');
|
||||
expect(timeline).not.toContain('旧流迟到正文');
|
||||
});
|
||||
|
||||
it('sends only the draft runtime snapshot through forwardedProps', async () => {
|
||||
const stream = useAgentTryoutStream();
|
||||
void stream.sendDraft({
|
||||
agent: {
|
||||
created: '2026-08-14 10:00:00',
|
||||
displayPublishStatus: 'PUBLISHED',
|
||||
id: 'agent-1',
|
||||
modelId: 'model-1',
|
||||
name: 'Agent',
|
||||
publishedSnapshotJson: { internal: true },
|
||||
},
|
||||
knowledgeBindings: [
|
||||
{
|
||||
agentId: 'agent-1',
|
||||
enabled: true,
|
||||
id: 'knowledge-binding-1',
|
||||
knowledgeId: 'knowledge-1',
|
||||
localId: 'local-knowledge',
|
||||
optionsJson: { topK: 5 },
|
||||
resourceSnapshot: { internal: true },
|
||||
resourceSummary: { displayName: '知识库' },
|
||||
retrievalMode: 'HYBRID',
|
||||
sortNo: 2,
|
||||
},
|
||||
],
|
||||
prompt: '问题',
|
||||
sessionId: 'draft-snapshot',
|
||||
skillBindings: [
|
||||
{
|
||||
agentId: 'agent-1',
|
||||
id: 'skill-binding-1',
|
||||
resourceSummary: {
|
||||
displayName: '合同审查',
|
||||
toolCount: 2,
|
||||
},
|
||||
skillId: 'skill-1',
|
||||
sortNo: 3,
|
||||
},
|
||||
],
|
||||
toolBindings: [
|
||||
{
|
||||
agentId: 'agent-1',
|
||||
enabled: true,
|
||||
hitlConfigJson: { message: '请确认' },
|
||||
hitlEnabled: true,
|
||||
id: 'tool-binding-1',
|
||||
localId: 'local-tool',
|
||||
optionsJson: { timeout: 30 },
|
||||
resourceSnapshot: { internal: true },
|
||||
resourceSummary: { displayName: '工具' },
|
||||
sortNo: 1,
|
||||
targetId: 'tool-1',
|
||||
toolName: 'search',
|
||||
toolType: 'PLUGIN',
|
||||
},
|
||||
],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const forwardedProps = aguiMocks.runs[0]?.forwardedProps as {
|
||||
easyflow: {
|
||||
draft: {
|
||||
agent: Record<string, unknown>;
|
||||
knowledgeBindings: Record<string, unknown>[];
|
||||
skillBindings: Record<string, unknown>[];
|
||||
toolBindings: Record<string, unknown>[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(forwardedProps.easyflow.draft.agent).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'agent-1',
|
||||
modelId: 'model-1',
|
||||
name: 'Agent',
|
||||
}),
|
||||
);
|
||||
expect(forwardedProps.easyflow.draft.agent).not.toHaveProperty('created');
|
||||
expect(forwardedProps.easyflow.draft.agent).not.toHaveProperty(
|
||||
'publishedSnapshotJson',
|
||||
);
|
||||
expect(forwardedProps.easyflow.draft.toolBindings).toEqual([
|
||||
{
|
||||
enabled: true,
|
||||
hitlConfigJson: { message: '请确认' },
|
||||
hitlEnabled: true,
|
||||
id: 'tool-binding-1',
|
||||
optionsJson: { timeout: 30 },
|
||||
sortNo: 1,
|
||||
targetId: 'tool-1',
|
||||
toolName: 'search',
|
||||
toolType: 'PLUGIN',
|
||||
},
|
||||
]);
|
||||
expect(forwardedProps.easyflow.draft.knowledgeBindings).toEqual([
|
||||
{
|
||||
enabled: true,
|
||||
id: 'knowledge-binding-1',
|
||||
knowledgeId: 'knowledge-1',
|
||||
optionsJson: { topK: 5 },
|
||||
retrievalMode: 'HYBRID',
|
||||
sortNo: 2,
|
||||
},
|
||||
]);
|
||||
expect(forwardedProps.easyflow.draft.skillBindings).toEqual([
|
||||
{
|
||||
skillId: 'skill-1',
|
||||
sortNo: 3,
|
||||
},
|
||||
]);
|
||||
stream.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||
|
||||
import type {
|
||||
ChatDocumentAttachment,
|
||||
ChatImageAttachment,
|
||||
@@ -7,9 +5,11 @@ import type {
|
||||
ChatTimelineMessageItem,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import type { AguiEvent } from '../../shared/agent-agui/client';
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentKnowledgeBinding,
|
||||
AgentSkillBinding,
|
||||
AgentToolBinding,
|
||||
} from '../types';
|
||||
|
||||
@@ -17,8 +17,9 @@ import { ref } from 'vue';
|
||||
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import { sseClient } from '#/api/request';
|
||||
import { EventType } from '@ag-ui/client';
|
||||
|
||||
import { EasyFlowAguiClient } from '../../shared/agent-agui/client';
|
||||
import { clearAgentDraftSession } from '../api';
|
||||
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
|
||||
|
||||
@@ -26,24 +27,6 @@ function resolveDraftSessionId(agent: AgentInfo) {
|
||||
return `agent-draft-${agent.id || agent.localId || 'unsaved'}`;
|
||||
}
|
||||
|
||||
function parseEventData(message: ServerSentEventMessage) {
|
||||
const raw = message.data || '';
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { payload: { delta: raw } };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnvelope(data: any) {
|
||||
return {
|
||||
domain: data.domain || data.eventDomain || data.typeDomain,
|
||||
type: data.type || data.eventType || data.chatType || data.event,
|
||||
payload: data.payload ?? data.data ?? data,
|
||||
};
|
||||
}
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
@@ -54,23 +37,78 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
function isEndOfRoundEvent(domain: string, type: string) {
|
||||
return domain === 'SYSTEM' && type === 'DONE';
|
||||
function draftAgentTransport(agent: AgentInfo) {
|
||||
return {
|
||||
avatar: agent.avatar,
|
||||
categoryId: agent.categoryId,
|
||||
description: agent.description,
|
||||
executionConfigJson: agent.executionConfigJson,
|
||||
generationConfigJson: agent.generationConfigJson,
|
||||
id: agent.id,
|
||||
memoryConfigJson: agent.memoryConfigJson,
|
||||
modelConfigJson: agent.modelConfigJson,
|
||||
modelId: agent.modelId,
|
||||
name: agent.name,
|
||||
promptConfigJson: agent.promptConfigJson,
|
||||
publishStatus: agent.publishStatus,
|
||||
status: agent.status,
|
||||
visibilityScope: agent.visibilityScope,
|
||||
};
|
||||
}
|
||||
|
||||
function draftToolBindingTransport(binding: AgentToolBinding) {
|
||||
return {
|
||||
enabled: binding.enabled,
|
||||
hitlConfigJson: binding.hitlConfigJson,
|
||||
hitlEnabled: binding.hitlEnabled,
|
||||
id: binding.id,
|
||||
optionsJson: binding.optionsJson,
|
||||
sortNo: binding.sortNo,
|
||||
targetId: binding.targetId,
|
||||
toolName: binding.toolName,
|
||||
toolType: binding.toolType,
|
||||
};
|
||||
}
|
||||
|
||||
function draftKnowledgeBindingTransport(binding: AgentKnowledgeBinding) {
|
||||
return {
|
||||
enabled: binding.enabled,
|
||||
id: binding.id,
|
||||
knowledgeId: binding.knowledgeId,
|
||||
optionsJson: binding.optionsJson,
|
||||
retrievalMode: binding.retrievalMode,
|
||||
sortNo: binding.sortNo,
|
||||
};
|
||||
}
|
||||
|
||||
function draftSkillBindingTransport(binding: AgentSkillBinding) {
|
||||
return {
|
||||
skillId: binding.skillId,
|
||||
sortNo: binding.sortNo,
|
||||
};
|
||||
}
|
||||
|
||||
interface DraftRuntimeContext {
|
||||
agent: AgentInfo;
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
skillBindings: AgentSkillBinding[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
}
|
||||
|
||||
interface ActiveDraftRun {
|
||||
roundId: string;
|
||||
sessionId: string;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export function useAgentTryoutStream() {
|
||||
const timelineItems = ref<ChatTimelineItemType[]>([]);
|
||||
const loading = ref(false);
|
||||
let rawRounds: ReturnType<typeof useAgentTryoutRawRounds> | undefined;
|
||||
let activeRoundId = '';
|
||||
let activeSessionId = '';
|
||||
let userStopped = false;
|
||||
let activeRun: ActiveDraftRun | undefined;
|
||||
const aguiClient = new EasyFlowAguiClient();
|
||||
|
||||
function errorMessageOf(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
@@ -87,18 +125,11 @@ export function useAgentTryoutStream() {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
const message = errorMessageOf(error).toLowerCase();
|
||||
return message.includes('abort');
|
||||
}
|
||||
|
||||
function shouldIgnoreStoppedError(error: unknown) {
|
||||
return userStopped && isAbortError(error);
|
||||
}
|
||||
|
||||
function finishStoppedRun() {
|
||||
finishAssistant();
|
||||
rawRounds?.flush();
|
||||
function finishStoppedRun(roundId: string) {
|
||||
if (roundId) {
|
||||
rawRounds?.failRound(roundId);
|
||||
rebuildTimeline();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -141,79 +172,18 @@ export function useAgentTryoutStream() {
|
||||
}
|
||||
|
||||
function markToolApproving(payload: {
|
||||
requestId?: string;
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
toolCallId?: string;
|
||||
}) {
|
||||
rawRounds?.recordEvent(activeRoundId, {
|
||||
domain: 'TOOL',
|
||||
payload,
|
||||
type: 'FORM_APPROVING',
|
||||
});
|
||||
rawRounds?.flush();
|
||||
rebuildTimeline();
|
||||
ChatTimelineBuilder.markToolApproving(timelineItems.value, payload);
|
||||
}
|
||||
|
||||
function markToolRejected(payload: {
|
||||
approvalId?: string;
|
||||
reason?: string;
|
||||
requestId?: string;
|
||||
resumeToken?: string;
|
||||
toolCallId?: string;
|
||||
}) {
|
||||
rawRounds?.recordEvent(activeRoundId, {
|
||||
domain: 'TOOL',
|
||||
payload,
|
||||
type: 'FORM_REJECTED',
|
||||
});
|
||||
rawRounds?.flush();
|
||||
rebuildTimeline();
|
||||
}
|
||||
|
||||
function handleMessage(message: ServerSentEventMessage) {
|
||||
const data = parseEventData(message);
|
||||
const envelope = resolveEnvelope(data);
|
||||
const domain = String(envelope.domain || '').toUpperCase();
|
||||
const type = String(envelope.type || '').toUpperCase();
|
||||
const payload = envelope.payload || {};
|
||||
|
||||
if (activeRoundId) {
|
||||
const runtimeEvent = rawRounds?.recordEvent(activeRoundId, {
|
||||
domain,
|
||||
payload,
|
||||
type,
|
||||
});
|
||||
if (runtimeEvent) {
|
||||
rawRounds?.projectEvent(
|
||||
timelineItems.value,
|
||||
activeRoundId,
|
||||
runtimeEvent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (domain === 'LLM' && type === 'MESSAGE') {
|
||||
return;
|
||||
}
|
||||
if (domain === 'LLM' && type === 'THINKING') {
|
||||
const text = asText(payload.reasoning ?? payload.delta ?? payload.text);
|
||||
if (!text) return;
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && type === 'FORM_REQUEST') {
|
||||
return;
|
||||
}
|
||||
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
|
||||
return;
|
||||
}
|
||||
if (domain === 'BUSINESS' && type === 'CITATIONS') {
|
||||
return;
|
||||
}
|
||||
if (domain === 'BUSINESS' && type === 'STATUS') {
|
||||
return;
|
||||
}
|
||||
if (isEndOfRoundEvent(domain, type)) {
|
||||
markRoundCompleted(activeRoundId);
|
||||
}
|
||||
ChatTimelineBuilder.markToolRejected(timelineItems.value, payload);
|
||||
}
|
||||
|
||||
async function runDraft(payload: {
|
||||
@@ -226,6 +196,7 @@ export function useAgentTryoutStream() {
|
||||
onAccepted?: () => Promise<void> | void;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
skillBindings: AgentSkillBinding[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
}) {
|
||||
syncDraftContext(payload, false, payload.sessionId);
|
||||
@@ -237,59 +208,83 @@ export function useAgentTryoutStream() {
|
||||
payload.images,
|
||||
payload.documents,
|
||||
);
|
||||
const run: ActiveDraftRun = {
|
||||
roundId: activeRoundId,
|
||||
sessionId: activeSessionId,
|
||||
stopped: false,
|
||||
};
|
||||
activeRun = run;
|
||||
rebuildTimeline();
|
||||
loading.value = true;
|
||||
userStopped = false;
|
||||
let accepted = false;
|
||||
await sseClient.post(
|
||||
'/api/v1/agent/chat/draft',
|
||||
{
|
||||
agent: payload.agent,
|
||||
documentUploadIds: payload.documentUploadIds,
|
||||
imageUploadIds: payload.imageUploadIds,
|
||||
knowledgeBindings: payload.knowledgeBindings,
|
||||
prompt: payload.prompt,
|
||||
sessionId: activeSessionId,
|
||||
toolBindings: payload.toolBindings,
|
||||
},
|
||||
{
|
||||
onMessage: (message) => {
|
||||
const envelope = resolveEnvelope(parseEventData(message));
|
||||
const domain = String(envelope.domain || '').toUpperCase();
|
||||
const type = String(envelope.type || '').toUpperCase();
|
||||
if (!accepted && domain === 'SYSTEM' && type === 'INPUT_ACCEPTED') {
|
||||
accepted = true;
|
||||
void payload.onAccepted?.();
|
||||
}
|
||||
handleMessage(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (shouldIgnoreStoppedError(error)) {
|
||||
return;
|
||||
}
|
||||
rawRounds?.recordEvent(activeRoundId, {
|
||||
domain: 'SYSTEM',
|
||||
payload: {
|
||||
message: error?.message ?? '试运行失败,请稍后再试',
|
||||
try {
|
||||
await aguiClient.run({
|
||||
forwardedProps: {
|
||||
easyflow: {
|
||||
draft: {
|
||||
agent: draftAgentTransport(payload.agent),
|
||||
knowledgeBindings: payload.knowledgeBindings.map((binding) =>
|
||||
draftKnowledgeBindingTransport(binding),
|
||||
),
|
||||
skillBindings: payload.skillBindings.map((binding) =>
|
||||
draftSkillBindingTransport(binding),
|
||||
),
|
||||
toolBindings: payload.toolBindings.map((binding) =>
|
||||
draftToolBindingTransport(binding),
|
||||
),
|
||||
},
|
||||
type: 'ERROR',
|
||||
});
|
||||
rebuildTimeline();
|
||||
finishAssistant();
|
||||
rawRounds?.flush();
|
||||
loading.value = false;
|
||||
input: {
|
||||
documentUploadIds: payload.documentUploadIds,
|
||||
imageUploadIds: payload.imageUploadIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
onFinished: () => {
|
||||
if (userStopped) {
|
||||
return;
|
||||
}
|
||||
finishAssistant();
|
||||
markRoundCompleted(activeRoundId);
|
||||
rawRounds?.flush();
|
||||
loading.value = false;
|
||||
onEvent(event) {
|
||||
if (activeRun !== run) return;
|
||||
const runtimeEvent = rawRounds?.recordEvent(run.roundId, event);
|
||||
if (!runtimeEvent) return;
|
||||
rawRounds?.projectEvent(
|
||||
timelineItems.value,
|
||||
run.roundId,
|
||||
runtimeEvent,
|
||||
() => {
|
||||
if (accepted) return;
|
||||
accepted = true;
|
||||
void payload.onAccepted?.();
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
threadId: run.sessionId,
|
||||
url: '/api/v1/agent/agui/run/draft',
|
||||
userMessage: {
|
||||
content: payload.prompt,
|
||||
id: `user-${run.roundId}`,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
if (activeRun === run && !run.stopped) {
|
||||
finishAssistant();
|
||||
markRoundCompleted(run.roundId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (activeRun === run && !run.stopped) {
|
||||
const runError = {
|
||||
message: errorMessageOf(error) || '试运行失败,请稍后再试',
|
||||
runId: run.roundId,
|
||||
threadId: run.sessionId,
|
||||
type: EventType.RUN_ERROR,
|
||||
} as AguiEvent;
|
||||
rawRounds?.recordEvent(run.roundId, runError);
|
||||
rawRounds?.projectEvent(timelineItems.value, run.roundId, runError);
|
||||
finishAssistant();
|
||||
rawRounds?.flush();
|
||||
}
|
||||
} finally {
|
||||
if (activeRun === run) {
|
||||
activeRun = undefined;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDraft(payload: {
|
||||
@@ -302,6 +297,7 @@ export function useAgentTryoutStream() {
|
||||
onAccepted?: () => Promise<void> | void;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
skillBindings: AgentSkillBinding[];
|
||||
toolBindings: AgentToolBinding[];
|
||||
}) {
|
||||
await runDraft(payload);
|
||||
@@ -329,8 +325,9 @@ export function useAgentTryoutStream() {
|
||||
|
||||
async function clearDraftSession() {
|
||||
if (loading.value) {
|
||||
userStopped = true;
|
||||
sseClient.abort();
|
||||
if (activeRun) activeRun.stopped = true;
|
||||
activeRun = undefined;
|
||||
aguiClient.abort();
|
||||
loading.value = false;
|
||||
}
|
||||
const sessionId = activeSessionId;
|
||||
@@ -346,16 +343,20 @@ export function useAgentTryoutStream() {
|
||||
if (!loading.value) {
|
||||
return;
|
||||
}
|
||||
userStopped = true;
|
||||
sseClient.abort();
|
||||
finishStoppedRun();
|
||||
const stoppedRoundId = activeRun?.roundId || activeRoundId;
|
||||
if (activeRun) activeRun.stopped = true;
|
||||
activeRun = undefined;
|
||||
aguiClient.abort();
|
||||
finishStoppedRun(stoppedRoundId);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (loading.value) {
|
||||
userStopped = true;
|
||||
sseClient.abort();
|
||||
finishStoppedRun();
|
||||
const stoppedRoundId = activeRun?.roundId || activeRoundId;
|
||||
if (activeRun) activeRun.stopped = true;
|
||||
activeRun = undefined;
|
||||
aguiClient.abort();
|
||||
finishStoppedRun(stoppedRoundId);
|
||||
return;
|
||||
}
|
||||
rawRounds?.flush();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* cspell:ignore hitl */
|
||||
|
||||
export type AgentPanelMode = 'base' | 'capability' | 'tryout';
|
||||
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp';
|
||||
export type AgentCapabilityKind =
|
||||
| 'knowledge'
|
||||
| 'mcp'
|
||||
| 'plugin'
|
||||
| 'skill'
|
||||
| 'workflow';
|
||||
|
||||
export interface AgentInteractionConfig {
|
||||
inputPlaceholder: string;
|
||||
@@ -9,6 +14,30 @@ export interface AgentInteractionConfig {
|
||||
welcomeMessage: string;
|
||||
}
|
||||
|
||||
export interface AgentBuiltinToolConfig {
|
||||
approvalRequired: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AgentBuiltinToolsConfig {
|
||||
artifactPublish: AgentBuiltinToolConfig;
|
||||
patch: AgentBuiltinToolConfig;
|
||||
read: AgentBuiltinToolConfig;
|
||||
schemaVersion: 1;
|
||||
shell: AgentBuiltinToolConfig;
|
||||
shellApprovalRiskConfirmed?: boolean;
|
||||
write: AgentBuiltinToolConfig;
|
||||
}
|
||||
|
||||
export interface AgentExecutionConfig extends Record<string, any> {
|
||||
builtinTools?: AgentBuiltinToolsConfig;
|
||||
documentContextBudgetTokens?: number;
|
||||
}
|
||||
|
||||
export interface AgentBuiltinToolCapabilities {
|
||||
canDisableShellApproval?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
@@ -20,7 +49,7 @@ export interface AgentInfo {
|
||||
generationConfigJson?: Record<string, any>;
|
||||
promptConfigJson?: Record<string, any>;
|
||||
memoryConfigJson?: Record<string, any>;
|
||||
executionConfigJson?: Record<string, any>;
|
||||
executionConfigJson?: AgentExecutionConfig;
|
||||
interactionConfigJson?: AgentInteractionConfig;
|
||||
supportImage?: boolean;
|
||||
status?: number;
|
||||
@@ -28,11 +57,13 @@ export interface AgentInfo {
|
||||
publishStatus?: string;
|
||||
displayPublishStatus?: string;
|
||||
approvalPending?: boolean;
|
||||
builtinToolCapabilities?: AgentBuiltinToolCapabilities;
|
||||
currentApprovalActionType?: string;
|
||||
currentApprovalInstanceId?: number | string;
|
||||
publishedSnapshotJson?: Record<string, any>;
|
||||
toolBindings?: AgentToolBinding[];
|
||||
knowledgeBindings?: AgentKnowledgeBinding[];
|
||||
skillBindings?: AgentSkillBinding[];
|
||||
created?: string;
|
||||
createdByName?: string;
|
||||
[key: string]: any;
|
||||
@@ -69,10 +100,30 @@ export interface AgentKnowledgeBinding {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AgentSkillBinding {
|
||||
id?: number | string;
|
||||
agentId?: number | string;
|
||||
skillId?: number | string;
|
||||
resourceSummary?: AgentSkillSummary;
|
||||
sortNo?: number;
|
||||
}
|
||||
|
||||
export interface AgentSkillSummary {
|
||||
binaryExcludedCount?: number;
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
hasUpdate?: boolean;
|
||||
snapshotHash?: string;
|
||||
textResourceCount?: number;
|
||||
toolCount?: number;
|
||||
visibilityScope?: string;
|
||||
}
|
||||
|
||||
export interface AgentDraftState {
|
||||
agent: AgentInfo;
|
||||
toolBindings: AgentToolBinding[];
|
||||
knowledgeBindings: AgentKnowledgeBinding[];
|
||||
skillBindings: AgentSkillBinding[];
|
||||
selectedNodeId: string;
|
||||
panelMode: AgentPanelMode;
|
||||
dirty: boolean;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
ChatArtifactAttachment,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
const ARTIFACT_ID_PATTERN = /^[\w-]{1,64}$/;
|
||||
const SHA256_PATTERN = /^[a-f\d]{64}$/i;
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function artifactStatus(value: unknown): ChatArtifactAttachment['status'] {
|
||||
const status = asText(value).trim().toUpperCase();
|
||||
if (!status || status === 'AVAILABLE') return 'available';
|
||||
if (status === 'EXPIRED' || status === 'DELETED') return 'expired';
|
||||
if (status === 'DELETE_FAILED') return 'delete_failed';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
function safeFileName(value: unknown) {
|
||||
const withoutControlCharacters = [...asText(value)]
|
||||
.filter((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code > 31 && code !== 127;
|
||||
})
|
||||
.join('');
|
||||
return (
|
||||
withoutControlCharacters.split(/[\\/]/).pop()?.trim().slice(0, 255) || ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Artifact 事件裁剪为前端允许展示的稳定字段。
|
||||
*
|
||||
* @param payload AG-UI 或历史记录中的 Artifact 载荷。
|
||||
* @returns 安全 Artifact;标识或文件名非法时不投影。
|
||||
*/
|
||||
export function normalizeArtifactPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): ChatArtifactAttachment | undefined {
|
||||
const artifactId = asText(payload.artifactId).trim();
|
||||
const fileName = safeFileName(payload.fileName);
|
||||
if (!ARTIFACT_ID_PATTERN.test(artifactId) || !fileName) {
|
||||
return undefined;
|
||||
}
|
||||
const status = artifactStatus(payload.status);
|
||||
const rawSize = Number(payload.size);
|
||||
const mimeType = asText(payload.mimeType).trim().slice(0, 128);
|
||||
const sha256 = asText(payload.sha256).trim();
|
||||
return {
|
||||
artifactId,
|
||||
downloadUrl:
|
||||
status === 'available'
|
||||
? `/api/v1/agent/artifacts/${encodeURIComponent(artifactId)}/content`
|
||||
: undefined,
|
||||
fileName,
|
||||
mimeType: mimeType || undefined,
|
||||
sha256: SHA256_PATTERN.test(sha256) ? sha256.toLowerCase() : undefined,
|
||||
size: Number.isSafeInteger(rawSize) && rawSize >= 0 ? rawSize : undefined,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用共享 Builder 将安全 Artifact 投影到聊天时间线。
|
||||
*
|
||||
* @param items 当前时间线。
|
||||
* @param payload Artifact 公开载荷。
|
||||
* @param metadata 当前轮次元数据。
|
||||
*/
|
||||
export function projectArtifactPayload(
|
||||
items: ChatTimelineItem[],
|
||||
payload: Record<string, unknown>,
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const artifact = normalizeArtifactPayload(payload);
|
||||
if (artifact) {
|
||||
ChatTimelineBuilder.upsertArtifact(items, artifact, metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { EventType } from '@ag-ui/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EasyFlowAguiClient } from './client';
|
||||
|
||||
vi.mock('#/api/request', () => ({
|
||||
createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }),
|
||||
resolveApiUrl: (url: string) => `http://localhost${url}`,
|
||||
}));
|
||||
|
||||
function sse(events: unknown[]) {
|
||||
return new Response(
|
||||
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''),
|
||||
{ headers: { 'Content-Type': 'text/event-stream' }, status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
describe('easyFlowAguiClient', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('trims outbound history, tools, context and state at the transport boundary', async () => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
requestBody = JSON.parse(String(init?.body));
|
||||
return sse([
|
||||
{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED },
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_FINISHED,
|
||||
},
|
||||
]);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const received: string[] = [];
|
||||
await new EasyFlowAguiClient().run({
|
||||
forwardedProps: { easyflow: { input: { imageUploadIds: ['image-1'] } } },
|
||||
onEvent: (event) => received.push(event.type),
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
});
|
||||
|
||||
expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]);
|
||||
expect(requestBody).toEqual(
|
||||
expect.objectContaining({
|
||||
context: [],
|
||||
messages: [{ content: '你好', id: 'user-1', role: 'user' }],
|
||||
state: {},
|
||||
tools: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('converts proxied forwardedProps to transport JSON before the SDK clones input', async () => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
requestBody = JSON.parse(String(init?.body));
|
||||
return sse([
|
||||
{ runId: 'run-1', threadId: 'draft-1', type: EventType.RUN_STARTED },
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: 'draft-1',
|
||||
type: EventType.RUN_FINISHED,
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
const forwardedProps = new Proxy(
|
||||
{ easyflow: { draft: { agent: { id: 'agent-1' } } } },
|
||||
{},
|
||||
);
|
||||
|
||||
await expect(
|
||||
new EasyFlowAguiClient().run({
|
||||
forwardedProps,
|
||||
onEvent: () => undefined,
|
||||
threadId: 'draft-1',
|
||||
url: '/api/v1/agent/agui/run/draft',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(requestBody?.forwardedProps).toEqual(forwardedProps);
|
||||
});
|
||||
|
||||
it('rejects a clean EOF without RUN_FINISHED or RUN_ERROR', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
sse([{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }]),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new EasyFlowAguiClient().run({
|
||||
onEvent: () => undefined,
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
}),
|
||||
).rejects.toThrow('缺少终态');
|
||||
});
|
||||
|
||||
it('treats a standard cancelled terminal as an accepted stop', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
sse([
|
||||
{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED },
|
||||
{
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户拒绝执行',
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_ERROR,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new EasyFlowAguiClient().run({
|
||||
onEvent: () => undefined,
|
||||
threadId: '101',
|
||||
url: '/api/v1/agent/1/agui/run',
|
||||
userMessage: { content: '你好', id: 'user-1', role: 'user' },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
127
easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts
Normal file
127
easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
AgentSubscriber,
|
||||
Message,
|
||||
RunAgentInput,
|
||||
State,
|
||||
} from '@ag-ui/client';
|
||||
|
||||
import { EventSchemas, EventType, HttpAgent, randomUUID } from '@ag-ui/client';
|
||||
|
||||
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
|
||||
|
||||
export interface EasyFlowAguiRunOptions {
|
||||
forwardedProps?: Record<string, unknown>;
|
||||
onEvent: (event: AguiEvent) => void;
|
||||
onMessagesChanged?: (messages: ReadonlyArray<Readonly<Message>>) => void;
|
||||
onStateChanged?: (state: Readonly<State>) => void;
|
||||
threadId: string;
|
||||
url: string;
|
||||
userMessage: Message;
|
||||
}
|
||||
|
||||
export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>;
|
||||
|
||||
interface ActiveAguiRun {
|
||||
aborted: boolean;
|
||||
agent: HttpAgent;
|
||||
}
|
||||
|
||||
function toTransportJson<T>(value: T): T {
|
||||
// AG-UI SDK 会在请求前 structuredClone;先以真实传输格式解除 Vue Proxy,避免草稿对象克隆失败。
|
||||
// eslint-disable-next-line unicorn/prefer-structured-clone -- structuredClone 无法复制 Proxy
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* EasyFlow 的无头 AG-UI 运行客户端。
|
||||
*
|
||||
* <p>SDK 持有标准 messages/state;页面只消费投影回调。每次运行都会在出站边界再次裁剪
|
||||
* tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。</p>
|
||||
*/
|
||||
export class EasyFlowAguiClient {
|
||||
private activeRun?: ActiveAguiRun;
|
||||
|
||||
abort() {
|
||||
if (!this.activeRun) return;
|
||||
this.activeRun.aborted = true;
|
||||
this.activeRun.agent.abortRun();
|
||||
this.activeRun = undefined;
|
||||
}
|
||||
|
||||
async run(options: EasyFlowAguiRunOptions) {
|
||||
this.abort();
|
||||
const requestUrl = options.url;
|
||||
const agent = new EasyFlowHttpAgent({
|
||||
headers: createEventStreamHeaders(requestUrl),
|
||||
initialMessages: [options.userMessage],
|
||||
threadId: options.threadId,
|
||||
url: resolveApiUrl(requestUrl),
|
||||
});
|
||||
const activeRun: ActiveAguiRun = { aborted: false, agent };
|
||||
this.activeRun = activeRun;
|
||||
let terminalReceived = false;
|
||||
let cancelledReceived = false;
|
||||
const subscriber: AgentSubscriber = {
|
||||
onEvent: ({ event }) => {
|
||||
if (
|
||||
event.type === EventType.RUN_FINISHED ||
|
||||
event.type === EventType.RUN_ERROR
|
||||
) {
|
||||
terminalReceived = true;
|
||||
}
|
||||
if (
|
||||
event.type === EventType.RUN_ERROR &&
|
||||
event.code === 'RUN_CANCELLED'
|
||||
) {
|
||||
cancelledReceived = true;
|
||||
}
|
||||
options.onEvent(event as AguiEvent);
|
||||
},
|
||||
onMessagesChanged: ({ messages }) => {
|
||||
options.onMessagesChanged?.(messages);
|
||||
},
|
||||
onStateChanged: ({ state }) => {
|
||||
options.onStateChanged?.(state);
|
||||
},
|
||||
};
|
||||
try {
|
||||
await agent.runAgent(
|
||||
{
|
||||
context: [],
|
||||
forwardedProps: options.forwardedProps
|
||||
? toTransportJson(options.forwardedProps)
|
||||
: undefined,
|
||||
runId: `run_${randomUUID()}`,
|
||||
tools: [],
|
||||
},
|
||||
subscriber,
|
||||
);
|
||||
if (!terminalReceived) {
|
||||
if (activeRun.aborted) return;
|
||||
throw new Error('Agent 事件流缺少终态,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
if (activeRun.aborted || cancelledReceived) return;
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.activeRun === activeRun) {
|
||||
this.activeRun = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EasyFlowHttpAgent extends HttpAgent {
|
||||
protected override requestInit(input: RunAgentInput): RequestInit {
|
||||
const latestUserMessage = [...input.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user');
|
||||
return super.requestInit({
|
||||
...input,
|
||||
context: [],
|
||||
messages: latestUserMessage ? [latestUserMessage] : [],
|
||||
state: {},
|
||||
tools: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const easyFlowAguiCustomEvent = {
|
||||
artifactPublished: 'easyflow.artifact.published',
|
||||
asyncToolStatus: 'easyflow.async_tool.status',
|
||||
inputAccepted: 'easyflow.input.accepted',
|
||||
knowledgeCitations: 'easyflow.knowledge.citations',
|
||||
knowledgeRetrievalStatus: 'easyflow.knowledge.retrieval_status',
|
||||
runtimeContextStatus: 'easyflow.runtime.context_status',
|
||||
skillInvocationStatus: 'easyflow.skill.invocation_status',
|
||||
toolMetadata: 'easyflow.tool.metadata',
|
||||
toolApprovalRequired: 'easyflow.hitl.tool_approval_required',
|
||||
toolApprovalResolved: 'easyflow.hitl.tool_approval_resolved',
|
||||
} as const;
|
||||
@@ -0,0 +1,485 @@
|
||||
import type { ChatTimelineItem } from '@easyflow/common-ui';
|
||||
|
||||
import { EventSchemas, EventType } from '@ag-ui/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { easyFlowAguiCustomEvent } from './custom-events';
|
||||
import {
|
||||
applyAguiEventToTimeline,
|
||||
createAguiTimelineProjectionState,
|
||||
} from './projection';
|
||||
|
||||
describe('aG-UI wire contract and timeline projection', () => {
|
||||
it('projects a published Artifact through safe public fields only', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
name: easyFlowAguiCustomEvent.artifactPublished,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
artifactId: '01JARTIFACT',
|
||||
bucket: 'private-bucket',
|
||||
downloadUrl: 'https://evil.example/file',
|
||||
fileName: '../项目报告.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
objectKey: 'formal/tenant/secret',
|
||||
schemaVersion: 1,
|
||||
sha256: 'A'.repeat(64),
|
||||
size: 2048,
|
||||
status: 'AVAILABLE',
|
||||
workspacePath: '/app/data/agent-workspaces/private/report.pdf',
|
||||
},
|
||||
}),
|
||||
{ roundId: 'round-artifact' },
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
artifactId: '01JARTIFACT',
|
||||
downloadUrl: '/api/v1/agent/artifacts/01JARTIFACT/content',
|
||||
fileName: '项目报告.pdf',
|
||||
roundId: 'round-artifact',
|
||||
sha256: 'a'.repeat(64),
|
||||
status: 'available',
|
||||
type: 'artifact',
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(items)).not.toMatch(
|
||||
/private-bucket|evil\.example|objectKey|workspacePath|agent-workspaces/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts representative Java wire events with the official schemas', () => {
|
||||
const events = [
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_STARTED,
|
||||
},
|
||||
{
|
||||
messageId: 'reasoning-1',
|
||||
role: 'reasoning',
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.REASONING_MESSAGE_START,
|
||||
},
|
||||
{
|
||||
messageId: 'assistant-1',
|
||||
role: 'assistant',
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.TEXT_MESSAGE_START,
|
||||
},
|
||||
{
|
||||
delta: '你好',
|
||||
messageId: 'assistant-1',
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
},
|
||||
{
|
||||
name: easyFlowAguiCustomEvent.inputAccepted,
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.CUSTOM,
|
||||
value: { schemaVersion: 1 },
|
||||
},
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_FINISHED,
|
||||
},
|
||||
];
|
||||
|
||||
expect(events.map((event) => EventSchemas.parse(event))).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('creates a running turn as soon as RUN_STARTED arrives', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
runId: 'run-started',
|
||||
threadId: 'thread-started',
|
||||
type: EventType.RUN_STARTED,
|
||||
}),
|
||||
{ roundId: 'round-started', startedAt: 1000 },
|
||||
);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
parts: [],
|
||||
role: 'assistant',
|
||||
roundId: 'round-started',
|
||||
status: 'streaming',
|
||||
turnStartedAt: 1000,
|
||||
type: 'message',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects standard and custom events without a local standard enum', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
const state = createAguiTimelineProjectionState();
|
||||
const events = [
|
||||
{
|
||||
delta: '分析中',
|
||||
messageId: 'reasoning-1',
|
||||
type: EventType.REASONING_MESSAGE_CONTENT,
|
||||
},
|
||||
{
|
||||
delta: '执行结果',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
},
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolCallName: 'workflow',
|
||||
type: EventType.TOOL_CALL_START,
|
||||
},
|
||||
{
|
||||
name: easyFlowAguiCustomEvent.toolMetadata,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
toolCallId: 'tool-1',
|
||||
toolDisplayName: '数据处理工作流',
|
||||
toolName: 'workflow',
|
||||
},
|
||||
},
|
||||
{
|
||||
delta: '{"topic":"AG-UI"}',
|
||||
toolCallId: 'tool-1',
|
||||
type: EventType.TOOL_CALL_ARGS,
|
||||
},
|
||||
{
|
||||
content: '完成',
|
||||
messageId: 'tool-result-1',
|
||||
role: 'tool',
|
||||
toolCallId: 'tool-1',
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
},
|
||||
{
|
||||
name: easyFlowAguiCustomEvent.toolApprovalRequired,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
approvalId: 'approval-public',
|
||||
toolCallId: 'tool-2',
|
||||
toolName: 'dangerous_tool',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: easyFlowAguiCustomEvent.toolApprovalResolved,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
approvalId: 'approval-public',
|
||||
reason: '用户拒绝执行',
|
||||
status: 'REJECTED',
|
||||
},
|
||||
},
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_FINISHED,
|
||||
},
|
||||
].map((event) => EventSchemas.parse(event));
|
||||
|
||||
for (const event of events) {
|
||||
applyAguiEventToTimeline(items, event, { roundId: 'round-1' }, state);
|
||||
}
|
||||
|
||||
expect(JSON.stringify(items)).toContain('分析中');
|
||||
expect(JSON.stringify(items)).toContain('执行结果');
|
||||
expect(items.find((item) => item.id === 'tool-1')).toMatchObject({
|
||||
input: { topic: 'AG-UI' },
|
||||
output: '完成',
|
||||
status: 'success',
|
||||
toolName: '数据处理工作流',
|
||||
});
|
||||
const approval = items.find((item) => item.id === 'tool-2');
|
||||
expect(approval?.type === 'tool' && approval.approval).toEqual(
|
||||
expect.objectContaining({ approvalId: 'approval-public' }),
|
||||
);
|
||||
expect(approval).toMatchObject({ status: 'rejected' });
|
||||
expect(JSON.stringify(approval)).not.toContain('resumeToken');
|
||||
});
|
||||
|
||||
it('uses standard message snapshots for authoritative text and keeps cancellation non-error', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
delta: 'draft',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
}),
|
||||
);
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
messages: [
|
||||
{ content: 'question', id: 'user-1', role: 'user' },
|
||||
{ content: 'final', id: 'assistant-1', role: 'assistant' },
|
||||
],
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.MESSAGES_SNAPSHOT,
|
||||
}),
|
||||
);
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: '用户拒绝执行',
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.RUN_ERROR,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(JSON.stringify(items)).toContain('final');
|
||||
expect(JSON.stringify(items)).not.toContain('draft');
|
||||
expect(items.some((item) => item.type === 'error')).toBe(false);
|
||||
});
|
||||
|
||||
it('aligns message snapshots by message id and supports an empty authoritative body', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
const state = createAguiTimelineProjectionState();
|
||||
for (const event of [
|
||||
{
|
||||
delta: '第一段',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
},
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolCallName: 'workflow',
|
||||
type: EventType.TOOL_CALL_START,
|
||||
},
|
||||
{
|
||||
delta: '过期正文',
|
||||
messageId: 'assistant-2',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
},
|
||||
].map((event) => EventSchemas.parse(event))) {
|
||||
applyAguiEventToTimeline(items, event, { roundId: 'round-1' }, state);
|
||||
}
|
||||
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
messages: [{ content: '', id: 'assistant-2', role: 'assistant' }],
|
||||
runId: 'run-1',
|
||||
threadId: '101',
|
||||
type: EventType.MESSAGES_SNAPSHOT,
|
||||
}),
|
||||
{ roundId: 'round-1' },
|
||||
state,
|
||||
);
|
||||
|
||||
const assistants = items.filter(
|
||||
(item) => item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
expect(assistants).toHaveLength(1);
|
||||
expect(assistants[0]).toMatchObject({ id: 'assistant-2', status: 'done' });
|
||||
expect(JSON.stringify(assistants[0])).not.toContain('过期正文');
|
||||
});
|
||||
|
||||
it('projects one AG-UI run as a completed timeline turn', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
const state = createAguiTimelineProjectionState();
|
||||
const events = [
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
type: EventType.RUN_STARTED,
|
||||
},
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolCallName: 'context7',
|
||||
type: EventType.TOOL_CALL_START,
|
||||
},
|
||||
{
|
||||
content: 'ok',
|
||||
messageId: 'tool-result-1',
|
||||
role: 'tool',
|
||||
toolCallId: 'tool-1',
|
||||
type: EventType.TOOL_CALL_RESULT,
|
||||
},
|
||||
{
|
||||
delta: '最终回答',
|
||||
messageId: 'assistant-1',
|
||||
type: EventType.TEXT_MESSAGE_CONTENT,
|
||||
},
|
||||
{
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
type: EventType.RUN_FINISHED,
|
||||
},
|
||||
].map((event) => EventSchemas.parse(event));
|
||||
|
||||
for (const event of events) {
|
||||
applyAguiEventToTimeline(items, event, { roundId: 'round-1' }, state);
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.every((item) => item.roundId === 'round-1')).toBe(true);
|
||||
expect(items.every((item) => item.turnSucceeded === true)).toBe(true);
|
||||
expect(items.every((item) => item.turnStartedAt !== undefined)).toBe(true);
|
||||
expect(items.every((item) => item.turnFinishedAt !== undefined)).toBe(true);
|
||||
expect(
|
||||
items.some(
|
||||
(item) =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
item.roundCompleted,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('projects Skill invocation status in place through the strict public fields', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
const state = createAguiTimelineProjectionState();
|
||||
for (const event of [
|
||||
{
|
||||
name: easyFlowAguiCustomEvent.skillInvocationStatus,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
configJson: { token: 'secret' },
|
||||
input: { contract: 'private' },
|
||||
path: 'references/private.md',
|
||||
skillContent: 'private Skill body',
|
||||
skillDisplayName: '合同审查助手',
|
||||
skillId: '101',
|
||||
skillName: 'contract-review',
|
||||
status: 'RUNNING',
|
||||
statusKey: 'skill-invocation:round-skill:101',
|
||||
toolCallId: 'tool-skill-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: easyFlowAguiCustomEvent.skillInvocationStatus,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
message: '完成',
|
||||
skillDisplayName: '合同审查助手',
|
||||
skillId: '101',
|
||||
skillName: 'contract-review',
|
||||
status: 'SUCCESS',
|
||||
statusKey: 'skill-invocation:round-skill:101',
|
||||
toolCallId: 'tool-skill-1',
|
||||
},
|
||||
},
|
||||
].map((event) => EventSchemas.parse(event))) {
|
||||
applyAguiEventToTimeline(items, event, { roundId: 'round-skill' }, state);
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
icon: 'skill',
|
||||
label: '已调用 合同审查助手',
|
||||
roundId: 'round-skill',
|
||||
status: 'done',
|
||||
statusKey: 'skill-invocation:round-skill:101',
|
||||
type: 'status',
|
||||
});
|
||||
expect(JSON.stringify(items[0])).not.toMatch(
|
||||
/skillContent|private Skill body|private\.md|contract|token|toolCallId/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['FAILED', 'error', '调用 合同审查助手 失败'],
|
||||
['CANCELLED', 'cancelled', '已停止调用 合同审查助手'],
|
||||
['INCOMPLETE', 'incomplete', '调用 合同审查助手 未完成'],
|
||||
] as const)(
|
||||
'keeps the %s Skill terminal semantics',
|
||||
(status, expected, label) => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
name: easyFlowAguiCustomEvent.skillInvocationStatus,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
skillDisplayName: '合同审查助手',
|
||||
status,
|
||||
statusKey: 'skill-invocation:r1:101',
|
||||
},
|
||||
}),
|
||||
{ roundId: 'r1' },
|
||||
);
|
||||
|
||||
expect(items[0]).toMatchObject({ label, status: expected });
|
||||
},
|
||||
);
|
||||
|
||||
it('closes a running Skill as cancelled when the run is cancelled', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
const state = createAguiTimelineProjectionState();
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
name: easyFlowAguiCustomEvent.skillInvocationStatus,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
skillDisplayName: '合同审查助手',
|
||||
status: 'RUNNING',
|
||||
statusKey: 'skill-invocation:r1:101',
|
||||
},
|
||||
}),
|
||||
{ roundId: 'r1' },
|
||||
state,
|
||||
);
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
code: 'RUN_CANCELLED',
|
||||
message: 'cancelled',
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
type: EventType.RUN_ERROR,
|
||||
}),
|
||||
{ roundId: 'r1' },
|
||||
state,
|
||||
);
|
||||
|
||||
expect(items[0]).toMatchObject({
|
||||
label: '已停止调用 合同审查助手',
|
||||
status: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes a running Skill as incomplete when the run finishes without its terminal event', () => {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
const state = createAguiTimelineProjectionState();
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
name: easyFlowAguiCustomEvent.skillInvocationStatus,
|
||||
type: EventType.CUSTOM,
|
||||
value: {
|
||||
skillDisplayName: '合同审查助手',
|
||||
status: 'RUNNING',
|
||||
statusKey: 'skill-invocation:r1:101',
|
||||
},
|
||||
}),
|
||||
{ roundId: 'r1' },
|
||||
state,
|
||||
);
|
||||
applyAguiEventToTimeline(
|
||||
items,
|
||||
EventSchemas.parse({
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
type: EventType.RUN_FINISHED,
|
||||
}),
|
||||
{ roundId: 'r1' },
|
||||
state,
|
||||
);
|
||||
|
||||
expect(items[0]).toMatchObject({
|
||||
label: '调用 合同审查助手 未完成',
|
||||
status: 'incomplete',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
import type {
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineSkillInvocationStatus,
|
||||
ChatTimelineToolStatus,
|
||||
} from '@easyflow/common-ui';
|
||||
|
||||
import type { AguiEvent } from './client';
|
||||
|
||||
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||
|
||||
import { EventType } from '@ag-ui/client';
|
||||
|
||||
import { projectArtifactPayload } from './artifact-projection';
|
||||
import { easyFlowAguiCustomEvent } from './custom-events';
|
||||
|
||||
export interface AguiTimelineProjectionOptions {
|
||||
finishedAt?: number;
|
||||
onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void;
|
||||
roundId?: string;
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
export interface AguiTimelineProjectionState {
|
||||
startedAt?: number;
|
||||
toolArgs: Map<string, string>;
|
||||
toolNames: Map<string, string>;
|
||||
}
|
||||
|
||||
export function createAguiTimelineProjectionState(
|
||||
startedAt?: number,
|
||||
): AguiTimelineProjectionState {
|
||||
return {
|
||||
startedAt,
|
||||
toolArgs: new Map(),
|
||||
toolNames: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function metadata(
|
||||
options: AguiTimelineProjectionOptions,
|
||||
state: AguiTimelineProjectionState,
|
||||
messageId?: string,
|
||||
): Partial<ChatTimelineMessageItem> {
|
||||
return {
|
||||
...(messageId ? { id: messageId } : {}),
|
||||
...(options.roundId ? { roundId: options.roundId } : {}),
|
||||
...(state.startedAt ? { turnStartedAt: state.startedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolArgs(value: string) {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKnowledgeItems(
|
||||
payload: Record<string, unknown>,
|
||||
): ChatTimelineKnowledgeHit[] {
|
||||
const source =
|
||||
payload.items ??
|
||||
payload.hits ??
|
||||
payload.documents ??
|
||||
payload.knowledgeReferences ??
|
||||
payload.knowledgeCitations ??
|
||||
[];
|
||||
return asArray(source)
|
||||
.map((value, index) => {
|
||||
const item = asRecord(value);
|
||||
const itemMetadata = asRecord(item.metadata);
|
||||
const documentName = asText(
|
||||
item.documentName ?? item.documentTitle ?? item.title,
|
||||
);
|
||||
const sourceFileName = asText(
|
||||
item.sourceFileName ?? itemMetadata.sourceFileName,
|
||||
);
|
||||
const chunkContent = asText(
|
||||
item.chunkContent ?? item.content ?? item.text ?? item.summary,
|
||||
);
|
||||
const rawScore = item.score ?? item.similarity;
|
||||
const score =
|
||||
typeof rawScore === 'number' || typeof rawScore === 'string'
|
||||
? rawScore
|
||||
: undefined;
|
||||
return {
|
||||
...item,
|
||||
chunkContent,
|
||||
chunkId: asText(item.chunkId ?? itemMetadata.chunkId),
|
||||
content: asText(item.content ?? item.text ?? item.summary),
|
||||
documentId: asText(item.documentId ?? itemMetadata.documentId),
|
||||
documentName,
|
||||
id: asText(item.id ?? item.chunkId ?? index),
|
||||
knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId),
|
||||
knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName),
|
||||
metadata: itemMetadata,
|
||||
score,
|
||||
sourceFileName,
|
||||
sourceUri: asText(item.sourceUri ?? itemMetadata.sourceUri),
|
||||
title: documentName || sourceFileName || asText(item.source),
|
||||
} satisfies ChatTimelineKnowledgeHit;
|
||||
})
|
||||
.filter((item) => item.chunkContent || item.title || item.documentName);
|
||||
}
|
||||
|
||||
function asyncToolStatus(
|
||||
payload: Record<string, unknown>,
|
||||
): ChatTimelineToolStatus {
|
||||
const status = asText(payload.status).toUpperCase();
|
||||
if (status === 'SUCCEEDED') return 'success';
|
||||
if (['CANCELLED', 'FAILED', 'TIMEOUT'].includes(status)) return 'error';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function statusKey(
|
||||
payload: Record<string, unknown>,
|
||||
options: AguiTimelineProjectionOptions,
|
||||
fallback: string,
|
||||
) {
|
||||
const value = asText(payload.statusKey) || fallback;
|
||||
return options.roundId ? `${value}:${options.roundId}` : value;
|
||||
}
|
||||
|
||||
const skillInvocationStatuses = new Set<ChatTimelineSkillInvocationStatus>([
|
||||
'CANCELLED',
|
||||
'FAILED',
|
||||
'INCOMPLETE',
|
||||
'RUNNING',
|
||||
'SUCCESS',
|
||||
]);
|
||||
|
||||
function normalizeSkillInvocationStatus(
|
||||
value: unknown,
|
||||
): ChatTimelineSkillInvocationStatus | undefined {
|
||||
const status = asText(value).trim().toUpperCase();
|
||||
return skillInvocationStatuses.has(
|
||||
status as ChatTimelineSkillInvocationStatus,
|
||||
)
|
||||
? (status as ChatTimelineSkillInvocationStatus)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function skillInvocationStatusKey(
|
||||
payload: Record<string, unknown>,
|
||||
options: AguiTimelineProjectionOptions,
|
||||
) {
|
||||
const supplied = asText(payload.statusKey).trim();
|
||||
if (supplied) {
|
||||
return supplied;
|
||||
}
|
||||
const identity =
|
||||
asText(payload.skillId).trim() ||
|
||||
asText(payload.skillName).trim() ||
|
||||
'unknown';
|
||||
return `skill-invocation:${options.roundId || 'draft'}:${identity}`;
|
||||
}
|
||||
|
||||
function applyCustomEvent(
|
||||
items: ChatTimelineItem[],
|
||||
event: Extract<AguiEvent, { type: EventType.CUSTOM }>,
|
||||
options: AguiTimelineProjectionOptions,
|
||||
state: AguiTimelineProjectionState,
|
||||
) {
|
||||
const payload = asRecord(event.value);
|
||||
const turnMetadata = metadata(options, state);
|
||||
if (event.name === easyFlowAguiCustomEvent.inputAccepted) {
|
||||
void options.onInputAccepted?.(payload);
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.artifactPublished) {
|
||||
projectArtifactPayload(items, payload, turnMetadata);
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.toolApprovalRequired) {
|
||||
ChatTimelineBuilder.appendToolApproval(
|
||||
items,
|
||||
{
|
||||
approvalId: asText(payload.approvalId),
|
||||
expiresAt: asText(payload.expiresAt),
|
||||
input: payload.input,
|
||||
metadata: payload.metadata,
|
||||
toolCallId: asText(payload.toolCallId),
|
||||
toolDisplayName: asText(payload.toolDisplayName),
|
||||
toolName: asText(payload.toolName) || '工具调用',
|
||||
toolType: asText(payload.toolType),
|
||||
},
|
||||
turnMetadata,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.toolApprovalResolved) {
|
||||
const toolCallId = asText(payload.toolCallId);
|
||||
if (asText(payload.status).toUpperCase() === 'APPROVED') {
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...turnMetadata,
|
||||
approvalId: asText(payload.approvalId),
|
||||
status: 'running',
|
||||
toolCallId,
|
||||
});
|
||||
} else {
|
||||
ChatTimelineBuilder.markToolRejected(items, {
|
||||
...turnMetadata,
|
||||
approvalId: asText(payload.approvalId),
|
||||
reason: asText(payload.reason),
|
||||
toolCallId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.toolMetadata) {
|
||||
const toolCallId = asText(payload.toolCallId);
|
||||
const toolDisplayName = asText(payload.toolDisplayName);
|
||||
if (toolCallId && toolDisplayName) {
|
||||
state.toolNames.set(toolCallId, toolDisplayName);
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...turnMetadata,
|
||||
status: 'running',
|
||||
toolCallId,
|
||||
toolName: toolDisplayName,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.knowledgeCitations) {
|
||||
ChatTimelineBuilder.appendKnowledge(
|
||||
items,
|
||||
normalizeKnowledgeItems(payload),
|
||||
turnMetadata,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.asyncToolStatus) {
|
||||
const input = asRecord(payload.input ?? payload.toolInput);
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...turnMetadata,
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output:
|
||||
payload.summary ??
|
||||
payload.label ??
|
||||
payload.output ??
|
||||
payload.result ??
|
||||
payload.text,
|
||||
sourceToolCallId: asText(
|
||||
payload.sourceToolCallId ?? payload.source_tool_call_id,
|
||||
),
|
||||
status: asyncToolStatus(payload),
|
||||
statusKey: statusKey(payload, options, 'knowledge-retrieval'),
|
||||
taskId: asText(payload.taskId ?? input.taskId ?? input.task_id),
|
||||
toolCallId: asText(payload.toolCallId ?? payload.taskId ?? payload.id),
|
||||
toolName: asText(
|
||||
payload.toolDisplayName ?? payload.toolName ?? payload.name,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) {
|
||||
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
||||
items,
|
||||
asText(payload.status).toLowerCase() === 'running' ? 'running' : 'done',
|
||||
statusKey(payload, options, 'knowledge-retrieval'),
|
||||
turnMetadata,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.skillInvocationStatus) {
|
||||
const status = normalizeSkillInvocationStatus(payload.status);
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
|
||||
...turnMetadata,
|
||||
displayName:
|
||||
asText(payload.skillDisplayName).trim() ||
|
||||
asText(payload.skillName).trim() ||
|
||||
'技能',
|
||||
status,
|
||||
statusKey: skillInvocationStatusKey(payload, options),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.name === easyFlowAguiCustomEvent.runtimeContextStatus) {
|
||||
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
|
||||
...turnMetadata,
|
||||
compressed:
|
||||
typeof payload.compressed === 'boolean'
|
||||
? payload.compressed
|
||||
: undefined,
|
||||
label: asText(payload.label),
|
||||
phase: asText(payload.phase),
|
||||
status: asText(payload.status),
|
||||
statusKey: statusKey(payload, options, 'memory-compression'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAguiEventToTimeline(
|
||||
items: ChatTimelineItem[],
|
||||
event: AguiEvent,
|
||||
options: AguiTimelineProjectionOptions = {},
|
||||
state = createAguiTimelineProjectionState(),
|
||||
) {
|
||||
state.startedAt ??= options.startedAt ?? Date.now();
|
||||
switch (event.type) {
|
||||
case EventType.CUSTOM: {
|
||||
applyCustomEvent(items, event, options, state);
|
||||
return;
|
||||
}
|
||||
case EventType.MESSAGES_SNAPSHOT: {
|
||||
const assistantMessage = [...event.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant');
|
||||
if (assistantMessage?.content !== undefined) {
|
||||
ChatTimelineBuilder.replaceMessageContent(
|
||||
items,
|
||||
assistantMessage.content,
|
||||
metadata(options, state, assistantMessage.id),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case EventType.REASONING_MESSAGE_CONTENT: {
|
||||
ChatTimelineBuilder.appendThinkingDelta(
|
||||
items,
|
||||
event.delta,
|
||||
metadata(options, state, event.messageId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case EventType.RUN_ERROR: {
|
||||
if (event.code === 'RUN_CANCELLED') {
|
||||
ChatTimelineBuilder.finalize(
|
||||
items,
|
||||
{
|
||||
...metadata(options, state),
|
||||
turnFinishedAt: options.finishedAt ?? Date.now(),
|
||||
turnSucceeded: false,
|
||||
},
|
||||
{
|
||||
runningSkillStatus: 'cancelled',
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
ChatTimelineBuilder.appendError(
|
||||
items,
|
||||
event.message || '请求失败',
|
||||
metadata(options, state),
|
||||
);
|
||||
ChatTimelineBuilder.finalize(items, {
|
||||
...metadata(options, state),
|
||||
turnFinishedAt: options.finishedAt ?? Date.now(),
|
||||
turnSucceeded: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case EventType.RUN_FINISHED: {
|
||||
ChatTimelineBuilder.finalize(items, {
|
||||
...metadata(options, state),
|
||||
roundCompleted: true,
|
||||
turnFinishedAt: options.finishedAt ?? Date.now(),
|
||||
turnSucceeded: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case EventType.RUN_STARTED: {
|
||||
ChatTimelineBuilder.ensureAssistantTurn(
|
||||
items,
|
||||
metadata(
|
||||
options,
|
||||
state,
|
||||
options.roundId ? `turn-${options.roundId}` : undefined,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case EventType.TEXT_MESSAGE_CONTENT: {
|
||||
ChatTimelineBuilder.appendMessageDelta(
|
||||
items,
|
||||
event.delta,
|
||||
metadata(options, state, event.messageId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case EventType.TOOL_CALL_ARGS: {
|
||||
const args = `${state.toolArgs.get(event.toolCallId) || ''}${event.delta}`;
|
||||
state.toolArgs.set(event.toolCallId, args);
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...metadata(options, state),
|
||||
input: parseToolArgs(args),
|
||||
status: 'running',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: state.toolNames.get(event.toolCallId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case EventType.TOOL_CALL_RESULT: {
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...metadata(options, state),
|
||||
output: event.content,
|
||||
status: 'success',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: state.toolNames.get(event.toolCallId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case EventType.TOOL_CALL_START: {
|
||||
state.toolNames.set(event.toolCallId, event.toolCallName);
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
...metadata(options, state),
|
||||
status: 'running',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolCallName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ export interface OfflineImpactCheck {
|
||||
canProceed: boolean;
|
||||
hasAgentBindings: boolean;
|
||||
hasPluginBindings: boolean;
|
||||
hasSkillBindings: boolean;
|
||||
hasWorkflowUsages: boolean;
|
||||
agentBindings: OfflineImpactBinding[];
|
||||
pluginBindings: OfflineImpactBinding[];
|
||||
skillBindings: OfflineImpactBinding[];
|
||||
workflowUsages: OfflineImpactBinding[];
|
||||
message?: string;
|
||||
}
|
||||
@@ -21,7 +23,10 @@ function resolveTitle(item: OfflineImpactBinding) {
|
||||
}
|
||||
|
||||
export function joinOfflineImpactTitles(items: OfflineImpactBinding[] = []) {
|
||||
return items.map(resolveTitle).filter(Boolean).join('、');
|
||||
return items
|
||||
.map((item) => resolveTitle(item))
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
}
|
||||
|
||||
export function buildOfflineImpactMessage(
|
||||
|
||||
@@ -16,6 +16,9 @@ describe('skill studio contract', () => {
|
||||
);
|
||||
expect(detailSource).not.toContain('能力绑定');
|
||||
expect(detailSource).not.toContain('SkillCapabilityPanel');
|
||||
expect(detailSource).toContain('command="tools"');
|
||||
expect(detailSource).toContain('<SkillToolBindingDialog');
|
||||
expect(detailSource).toContain('@saved="handleToolBindingsSaved"');
|
||||
});
|
||||
|
||||
it('keeps the detail shell compact and uses the neutral content surface', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Connection,
|
||||
Delete,
|
||||
Edit,
|
||||
MoreFilled,
|
||||
@@ -61,6 +62,7 @@ import { isSkillAccessDeniedError } from './skill-api-error';
|
||||
import { readFrontmatterScalar, splitSkillMarkdown } from './skill-markdown';
|
||||
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
|
||||
import SkillSettingsDialog from './SkillSettingsDialog.vue';
|
||||
import SkillToolBindingDialog from './SkillToolBindingDialog.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -74,6 +76,7 @@ const loadError = ref('');
|
||||
const loadAccessDenied = ref(false);
|
||||
const resourceDirty = ref(false);
|
||||
const settingsDialogOpen = ref(false);
|
||||
const toolBindingDialogOpen = ref(false);
|
||||
const publishDialogOpen = ref(false);
|
||||
const publishReason = ref('');
|
||||
const categories = ref<SkillCategory[]>([]);
|
||||
@@ -118,6 +121,9 @@ const canManage = computed(
|
||||
const canEditFiles = computed(
|
||||
() => canManage.value && hasPermission(['/api/v1/skill/file']),
|
||||
);
|
||||
const canManageTools = computed(
|
||||
() => canManage.value && hasPermission(['/api/v1/skill/save']),
|
||||
);
|
||||
const canSubmitPublish = computed(
|
||||
() =>
|
||||
canManage.value &&
|
||||
@@ -305,8 +311,11 @@ async function confirmPublish() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreCommand(command: 'delete' | 'offline' | 'settings') {
|
||||
function handleMoreCommand(
|
||||
command: 'delete' | 'offline' | 'settings' | 'tools',
|
||||
) {
|
||||
if (command === 'settings') settingsDialogOpen.value = true;
|
||||
if (command === 'tools') toolBindingDialogOpen.value = true;
|
||||
if (command === 'offline') void offline();
|
||||
if (command === 'delete') void remove();
|
||||
}
|
||||
@@ -360,6 +369,18 @@ function handleSettingsSaved(next: SkillInfo) {
|
||||
Object.assign(skill, next);
|
||||
}
|
||||
|
||||
function handleToolBindingsSaved(
|
||||
bindings: NonNullable<SkillInfo['toolBindings']>,
|
||||
) {
|
||||
skill.toolBindings = bindings;
|
||||
skill.toolCount = bindings.reduce(
|
||||
(total, binding) =>
|
||||
total + (binding.toolType === 'MCP' ? binding.mcpToolCount || 0 : 1),
|
||||
0,
|
||||
);
|
||||
skill.hasToolUpdate = true;
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!operationLocked.value && !resourceDirty.value) return;
|
||||
event.preventDefault();
|
||||
@@ -452,6 +473,13 @@ function handleSaveShortcut(event: KeyboardEvent) {
|
||||
<ElDropdownItem v-if="canManage" command="settings" :icon="Edit">
|
||||
基本信息
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="canManageTools"
|
||||
command="tools"
|
||||
:icon="Connection"
|
||||
>
|
||||
工具绑定
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="canSubmitOffline"
|
||||
command="offline"
|
||||
@@ -508,6 +536,15 @@ function handleSaveShortcut(event: KeyboardEvent) {
|
||||
@saved="handleSettingsSaved"
|
||||
/>
|
||||
|
||||
<SkillToolBindingDialog
|
||||
v-if="canManageTools"
|
||||
v-model="toolBindingDialogOpen"
|
||||
:bindings="skill.toolBindings"
|
||||
:has-tool-update="skill.hasToolUpdate"
|
||||
:skill-id="skillId"
|
||||
@saved="handleToolBindingsSaved"
|
||||
/>
|
||||
|
||||
<ElDialog
|
||||
v-model="publishDialogOpen"
|
||||
width="min(520px, calc(100vw - 32px))"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillToolBindingDialog from './SkillToolBindingDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSkillMcpTools: vi.fn(),
|
||||
getSkillToolOptions: vi.fn(),
|
||||
updateSkillToolBindings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
vi.mock('@easyflow/common-ui', () => ({
|
||||
EasyFlowPanelModal: defineComponent({
|
||||
name: 'EasyFlowPanelModal',
|
||||
props: {
|
||||
open: Boolean,
|
||||
title: { default: '', type: String },
|
||||
},
|
||||
emits: ['update:open'],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h('section', { 'data-testid': 'modal' }, [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
function optionResponse(toolType = 'WORKFLOW') {
|
||||
return {
|
||||
data: {
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
records: [
|
||||
{
|
||||
available: true,
|
||||
description: '用于执行验收流程',
|
||||
knownToolCount: 1,
|
||||
targetId: toolType === 'MCP' ? 20 : 10,
|
||||
title: toolType === 'MCP' ? '企业 MCP' : '合同审批流',
|
||||
toolType,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
errorCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mountDialog(props: Record<string, unknown>) {
|
||||
return mount(SkillToolBindingDialog, {
|
||||
attachTo: document.body,
|
||||
global: { directives: { loading: {} } },
|
||||
props: props as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe('skill tool binding dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.getSkillToolOptions.mockResolvedValue(optionResponse());
|
||||
apiMocks.getSkillMcpTools.mockResolvedValue({
|
||||
data: {
|
||||
manifestHash: 'manifest-v2',
|
||||
toolCount: 2,
|
||||
tools: [
|
||||
{ description: '查询订单', name: 'query_order' },
|
||||
{ description: '创建订单', name: 'create_order' },
|
||||
],
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.updateSkillToolBindings.mockResolvedValue({
|
||||
data: [],
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a workflow and sends only binding fields', async () => {
|
||||
const wrapper = mountDialog({ modelValue: true, skillId: 101 });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('.skill-tools__candidate-content').trigger('click');
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '保存绑定')
|
||||
?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.updateSkillToolBindings).toHaveBeenCalledWith(101, [
|
||||
expect.objectContaining({
|
||||
hitlEnabled: false,
|
||||
targetId: 10,
|
||||
toolType: 'WORKFLOW',
|
||||
}),
|
||||
]);
|
||||
const submitted = apiMocks.updateSkillToolBindings.mock.calls[0]?.[1]?.[0];
|
||||
expect(submitted).not.toHaveProperty('resourceSnapshot');
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('binds one MCP as a whole service and exposes its child tools on demand', async () => {
|
||||
apiMocks.getSkillToolOptions.mockImplementation(
|
||||
({ toolType }: { toolType: string }) =>
|
||||
Promise.resolve(optionResponse(toolType)),
|
||||
);
|
||||
const wrapper = mountDialog({ modelValue: true, skillId: 101 });
|
||||
await flushPromises();
|
||||
|
||||
const mcpTab = wrapper
|
||||
.findAll('[role="tab"]')
|
||||
.find((tab) => tab.text().trim() === 'MCP');
|
||||
await mcpTab?.trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.skill-tools__candidate-content').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getSkillMcpTools).toHaveBeenCalledWith(20);
|
||||
expect(wrapper.text()).toContain('2 个工具');
|
||||
const expand = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('查看工具'));
|
||||
await expand?.trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('query_order');
|
||||
expect(wrapper.text()).toContain('create_order');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('requires explicit confirmation when a bound MCP manifest changes', async () => {
|
||||
const wrapper = mountDialog({
|
||||
bindings: [
|
||||
{
|
||||
hitlEnabled: true,
|
||||
mcpToolCount: 1,
|
||||
mcpToolManifestHash: 'manifest-v1',
|
||||
resourceSummary: {
|
||||
approvalRequired: true,
|
||||
available: true,
|
||||
title: '企业 MCP',
|
||||
toolCount: 1,
|
||||
},
|
||||
targetId: 20,
|
||||
toolType: 'MCP',
|
||||
},
|
||||
],
|
||||
modelValue: true,
|
||||
skillId: 101,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('清单变化');
|
||||
const saveButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '保存绑定');
|
||||
expect((saveButton?.element as HTMLButtonElement).disabled).toBe(true);
|
||||
const accept = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '确认最新清单');
|
||||
await accept?.trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain('清单变化');
|
||||
expect(wrapper.text()).toContain('保存后随下一次发布生效');
|
||||
expect((saveButton?.element as HTMLButtonElement).disabled).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps invalid bindings visible and removable', async () => {
|
||||
const wrapper = mountDialog({
|
||||
bindings: [
|
||||
{
|
||||
resourceSummary: {
|
||||
available: false,
|
||||
title: '已删除工作流',
|
||||
},
|
||||
targetId: 99,
|
||||
toolType: 'WORKFLOW',
|
||||
},
|
||||
],
|
||||
modelValue: true,
|
||||
skillId: 101,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('已删除工作流');
|
||||
expect(wrapper.text()).toContain('请移除已失效的绑定');
|
||||
const remove = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '移除');
|
||||
await remove?.trigger('click');
|
||||
expect(wrapper.text()).toContain('从左侧选择工具');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
1066
easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue
Normal file
1066
easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,10 @@ import type {
|
||||
SkillImportConfirmPayload,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillMcpToolManifest,
|
||||
SkillToolBinding,
|
||||
SkillToolOptionPage,
|
||||
SkillToolType,
|
||||
SkillValidationResult,
|
||||
} from './types';
|
||||
|
||||
@@ -22,6 +26,45 @@ export function getSkillDetail(id: number | string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getSkillToolOptions(params: {
|
||||
keyword?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
toolType?: SkillToolType;
|
||||
}) {
|
||||
return api.get<RequestResult<SkillToolOptionPage>>(
|
||||
'/api/v1/skill/toolOptions',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillMcpTools(mcpId: number | string) {
|
||||
return api.get<RequestResult<SkillMcpToolManifest>>(
|
||||
'/api/v1/skill/mcpTools',
|
||||
{ params: { mcpId } },
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSkillToolBindings(
|
||||
skillId: number | string,
|
||||
bindings: SkillToolBinding[],
|
||||
) {
|
||||
return api.post<RequestResult<SkillToolBinding[]>>(
|
||||
'/api/v1/skill/toolBinding/update',
|
||||
{
|
||||
bindings: bindings.map((binding, sortNo) => ({
|
||||
hitlEnabled: Boolean(binding.hitlEnabled),
|
||||
mcpToolManifestHash:
|
||||
binding.toolType === 'MCP' ? binding.mcpToolManifestHash : undefined,
|
||||
sortNo,
|
||||
targetId: binding.targetId,
|
||||
toolType: binding.toolType,
|
||||
})),
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>(
|
||||
'/api/v1/skill/save',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getSkillMcpTools,
|
||||
getSkillToolOptions,
|
||||
updateSkillToolBindings,
|
||||
} from './api';
|
||||
|
||||
const requestMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/request', () => ({ api: requestMocks }));
|
||||
|
||||
describe('skill tool binding api', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('uses the safe candidate and MCP manifest endpoints', () => {
|
||||
getSkillToolOptions({
|
||||
keyword: '合同',
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
toolType: 'WORKFLOW',
|
||||
});
|
||||
getSkillMcpTools(20);
|
||||
|
||||
expect(requestMocks.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/skill/toolOptions',
|
||||
{
|
||||
params: {
|
||||
keyword: '合同',
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
toolType: 'WORKFLOW',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(requestMocks.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/v1/skill/mcpTools',
|
||||
{ params: { mcpId: 20 } },
|
||||
);
|
||||
});
|
||||
|
||||
it('submits only the binding whitelist', () => {
|
||||
updateSkillToolBindings(101, [
|
||||
{
|
||||
hitlEnabled: true,
|
||||
mcpToolCount: 2,
|
||||
mcpToolManifestHash: 'manifest-v2',
|
||||
resourceSummary: {
|
||||
available: true,
|
||||
description: '不应回传',
|
||||
title: '企业 MCP',
|
||||
toolCount: 2,
|
||||
},
|
||||
sortNo: 9,
|
||||
targetId: 20,
|
||||
toolType: 'MCP',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestMocks.post).toHaveBeenCalledWith(
|
||||
'/api/v1/skill/toolBinding/update',
|
||||
{
|
||||
bindings: [
|
||||
{
|
||||
hitlEnabled: true,
|
||||
mcpToolManifestHash: 'manifest-v2',
|
||||
sortNo: 0,
|
||||
targetId: 20,
|
||||
toolType: 'MCP',
|
||||
},
|
||||
],
|
||||
skillId: 101,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ export interface RequestResult<T = unknown> {
|
||||
|
||||
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
||||
export type SkillVisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
||||
export type SkillToolType = 'MCP' | 'PLUGIN' | 'WORKFLOW';
|
||||
|
||||
export interface SkillInfo {
|
||||
approvalPending?: boolean;
|
||||
@@ -28,9 +29,59 @@ export interface SkillInfo {
|
||||
resources?: SkillResource[];
|
||||
skillContent?: string;
|
||||
snapshotHash?: string;
|
||||
hasToolUpdate?: boolean;
|
||||
toolBindings?: SkillToolBinding[];
|
||||
toolCount?: number;
|
||||
visibilityScope?: SkillVisibilityScope;
|
||||
}
|
||||
|
||||
export interface SkillToolBinding {
|
||||
hitlEnabled?: boolean;
|
||||
id?: number | string;
|
||||
mcpToolCount?: number;
|
||||
mcpToolManifestHash?: string;
|
||||
resourceSummary?: {
|
||||
approvalRequired?: boolean;
|
||||
available?: boolean;
|
||||
description?: string;
|
||||
title?: string;
|
||||
toolCount?: number;
|
||||
};
|
||||
sortNo?: number;
|
||||
targetId: number | string;
|
||||
toolType: SkillToolType;
|
||||
}
|
||||
|
||||
export interface SkillToolOption {
|
||||
approvalRequired?: boolean;
|
||||
available: boolean;
|
||||
description?: string;
|
||||
knownToolCount?: number;
|
||||
targetId: number | string;
|
||||
title: string;
|
||||
toolType: SkillToolType;
|
||||
}
|
||||
|
||||
export interface SkillToolOptionPage {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
records: SkillToolOption[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SkillMcpTool {
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
name: string;
|
||||
outputSchema?: unknown;
|
||||
}
|
||||
|
||||
export interface SkillMcpToolManifest {
|
||||
manifestHash: string;
|
||||
toolCount: number;
|
||||
tools: SkillMcpTool[];
|
||||
}
|
||||
|
||||
export interface SkillResource {
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
|
||||
@@ -1037,15 +1037,19 @@ async function submitOfflineAction(row: any) {
|
||||
}
|
||||
try {
|
||||
const sections = [];
|
||||
let offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundAgentsFooter');
|
||||
if (impactRes.data?.hasAgentBindings) {
|
||||
sections.push(
|
||||
buildOfflineImpactMessage(
|
||||
$t('aiWorkflow.offlineImpactBoundAgentsIntro'),
|
||||
impactRes.data.agentBindings,
|
||||
impactRes.data?.hasPluginBindings
|
||||
? undefined
|
||||
: $t('aiWorkflow.offlineImpactBoundAgentsFooter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (impactRes.data?.hasSkillBindings) {
|
||||
sections.push(
|
||||
buildOfflineImpactMessage(
|
||||
$t('aiWorkflow.offlineImpactBoundSkillsIntro'),
|
||||
impactRes.data.skillBindings,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1054,29 +1058,37 @@ async function submitOfflineAction(row: any) {
|
||||
buildOfflineImpactMessage(
|
||||
$t('aiWorkflow.offlineImpactBoundPluginsIntro'),
|
||||
impactRes.data.pluginBindings,
|
||||
impactRes.data?.hasAgentBindings
|
||||
? undefined
|
||||
: $t('aiWorkflow.offlineImpactBoundPluginsFooter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (impactRes.data?.hasAgentBindings && impactRes.data?.hasPluginBindings) {
|
||||
offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundMixedFooter');
|
||||
} else if (impactRes.data?.hasPluginBindings) {
|
||||
offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundPluginsFooter');
|
||||
if (!impactRes.data?.canProceed) {
|
||||
const blockedMessage =
|
||||
sections.length > 0
|
||||
? h('div', [
|
||||
...sections,
|
||||
h(
|
||||
'p',
|
||||
{ style: 'margin-top: 12px;' },
|
||||
$t('aiWorkflow.offlineImpactBlockedFooter'),
|
||||
),
|
||||
])
|
||||
: impactRes.data?.message ||
|
||||
$t('aiWorkflow.offlineImpactBlockedFooter');
|
||||
await ElMessageBox.alert(blockedMessage, $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const impactMessage =
|
||||
sections.length > 0
|
||||
? h('div', [
|
||||
...sections,
|
||||
h('p', { style: 'margin-top: 12px;' }, offlineImpactFooter),
|
||||
])
|
||||
: $t('aiWorkflow.submitOfflineApprovalConfirm');
|
||||
await ElMessageBox.confirm(impactMessage, $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
});
|
||||
await ElMessageBox.confirm(
|
||||
$t('aiWorkflow.submitOfflineApprovalConfirm'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user