feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -13,6 +13,7 @@
"#/*": "./src/*"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-python": "^6.2.1",

View File

@@ -166,6 +166,32 @@ export interface SseOptions {
onError?: (err: any) => void;
onFinished?: () => void;
}
export function resolveApiUrl(url: string) {
return apiURL + url;
}
export function createEventStreamHeaders(
requestUrl: string,
extraHeaders?: HeadersInit,
) {
const accessStore = useAccessStore();
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'easyflow-token': accessStore.accessToken || '',
};
if (extraHeaders) {
new Headers(extraHeaders).forEach((value, key) => {
headers[key] = value;
});
}
return withWorkflowShareHeader(headers, {
requestMethod: 'POST',
requestUrl,
});
}
export class SseClient {
private controller: AbortController | null = null;
private currentRequestId = 0;
@@ -270,21 +296,7 @@ export class SseClient {
}
private getHeaders(requestUrl: string, extraHeaders?: HeadersInit) {
const accessStore = useAccessStore();
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'easyflow-token': accessStore.accessToken || '',
};
if (extraHeaders) {
new Headers(extraHeaders).forEach((value, key) => {
headers[key] = value;
});
}
return withWorkflowShareHeader(headers, {
requestMethod: 'POST',
requestUrl,
});
return createEventStreamHeaders(requestUrl, extraHeaders);
}
}

View File

@@ -43,8 +43,7 @@ const emit = defineEmits<{
/>
<AiToolApprovalCard
v-else-if="part.type === 'tool_approval'"
:request-id="part.requestId"
:resume-token="part.resumeToken"
:approval-id="part.approvalId"
:tool-name="part.toolName"
:tool-display-name="part.toolDisplayName"
:tool-call-id="part.toolCallId"

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import type {AiToolApprovalPayload} from './types';
import type { AiToolApprovalPayload } from './types';
import {Check, Close, Key} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
import { Check, Close, Key } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
const props = defineProps<AiToolApprovalPayload & { loading?: boolean }>();
@@ -13,8 +13,7 @@ const emit = defineEmits<{
function payload(): AiToolApprovalPayload {
return {
requestId: props.requestId,
resumeToken: props.resumeToken,
approvalId: props.approvalId,
toolName: props.toolName,
toolDisplayName: props.toolDisplayName,
toolCallId: props.toolCallId,

View File

@@ -1,8 +1,11 @@
import type { ChatDocumentAttachment } from '@easyflow/common-ui';
import type {
ChatArtifactAttachment,
ChatDocumentAttachment,
} from '@easyflow/common-ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadAgentChatDocument } from './mediaApi';
import { createAgentArtifactLoader, loadAgentChatDocument } from './mediaApi';
const requestApi = vi.hoisted(() => ({
download: vi.fn(),
@@ -19,6 +22,14 @@ const documentAttachment: ChatDocumentAttachment = {
status: 'ready',
};
const artifactAttachment: ChatArtifactAttachment = {
artifactId: '01JARTIFACT',
downloadUrl:
'https://evil.example/report?agentId=999&mode=DRAFT&runtimeSessionId=forged',
fileName: '结果.txt',
status: 'available',
};
describe('agent chat document download', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -63,3 +74,68 @@ describe('agent chat document download', () => {
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
});
});
describe('agent Artifact download', () => {
beforeEach(() => {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:artifact');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
() => undefined,
);
});
afterEach(() => {
vi.restoreAllMocks();
requestApi.download.mockReset();
});
it('用正式聊天的可信上下文构建同源下载地址', async () => {
requestApi.download.mockResolvedValue(new Blob(['artifact']));
const loader = createAgentArtifactLoader(() => ({
agentId: '42',
mode: 'FORMAL',
sessionId: '10001',
}));
await loader(artifactAttachment);
expect(requestApi.download).toHaveBeenCalledWith(
'/api/v1/agent/artifacts/01JARTIFACT/content?agentId=42&mode=FORMAL&sessionId=10001',
);
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
});
it('草稿模式只传 runtimeSessionId 且忽略事件伪造 URL', async () => {
requestApi.download.mockResolvedValue(new Blob(['artifact']));
const loader = createAgentArtifactLoader(() => ({
agentId: '43',
mode: 'DRAFT',
runtimeSessionId: 'agent-draft-43',
}));
await loader(artifactAttachment);
expect(requestApi.download).toHaveBeenCalledWith(
'/api/v1/agent/artifacts/01JARTIFACT/content?agentId=43&mode=DRAFT&runtimeSessionId=agent-draft-43',
);
expect(requestApi.download.mock.calls[0]?.[0]).not.toContain('sessionId=');
expect(requestApi.download.mock.calls[0]?.[0]).not.toContain(
'evil.example',
);
});
it.each([
undefined,
{ agentId: '', mode: 'FORMAL' as const, sessionId: '10001' },
{ agentId: '42', mode: 'FORMAL' as const, sessionId: '' },
{ agentId: '42', mode: 'DRAFT' as const, runtimeSessionId: '' },
])('上下文缺失时拒绝请求: %o', async (context) => {
const loader = createAgentArtifactLoader(() => context);
await expect(loader(artifactAttachment)).rejects.toThrow(
'产物下载上下文无效',
);
expect(requestApi.download).not.toHaveBeenCalled();
});
});

View File

@@ -1,14 +1,29 @@
import type {
ChatArtifactLoader,
ChatDocumentAttachment,
ChatImageAttachment,
} from '@easyflow/common-ui';
import { downloadFileFromBlob } from '@easyflow/utils';
import { api } from '#/api/request';
const DOCUMENT_URL_REVOKE_DELAY_MS = 1000;
export type AgentComposerMode = 'DRAFT' | 'FORMAL';
export type AgentArtifactDownloadContext =
| {
agentId: string;
mode: 'DRAFT';
runtimeSessionId: string;
}
| {
agentId: string;
mode: 'FORMAL';
sessionId: string;
};
export interface AgentMediaUpload extends ChatImageAttachment {
expiresAt?: string;
height: number;
@@ -177,3 +192,50 @@ export async function loadAgentChatDocument(item: ChatDocumentAttachment) {
}, DOCUMENT_URL_REVOKE_DELAY_MS);
}
}
export function createAgentArtifactLoader(
resolveContext: () => AgentArtifactDownloadContext | undefined,
): ChatArtifactLoader {
return async (item) => {
const downloadUrl = buildAgentArtifactDownloadUrl(
item.artifactId,
resolveContext(),
);
const blob = await api.download<Blob>(downloadUrl);
if (!(blob instanceof Blob)) {
throw new TypeError('产物下载失败,请重试');
}
downloadFileFromBlob({ fileName: item.fileName || '产物', source: blob });
};
}
function buildAgentArtifactDownloadUrl(
artifactIdValue: string,
context: AgentArtifactDownloadContext | undefined,
) {
const artifactId = String(artifactIdValue || '').trim();
if (!artifactId || !context) {
throw new TypeError('产物下载上下文无效');
}
const agentId = String(context.agentId || '').trim();
if (!/^[1-9]\d*$/.test(agentId)) {
throw new TypeError('产物下载上下文无效');
}
const params = new URLSearchParams({ agentId, mode: context.mode });
if (context.mode === 'DRAFT') {
const runtimeSessionId = String(context.runtimeSessionId || '').trim();
if (!runtimeSessionId) {
throw new TypeError('产物下载上下文无效');
}
params.set('runtimeSessionId', runtimeSessionId);
} else {
const sessionId = String(context.sessionId || '').trim();
if (!sessionId) {
throw new TypeError('产物下载上下文无效');
}
params.set('sessionId', sessionId);
}
return `/api/v1/agent/artifacts/${encodeURIComponent(artifactId)}/content?${params.toString()}`;
}

View File

@@ -11,8 +11,7 @@ export interface AiKnowledgeHit {
}
export interface AiToolApprovalPayload {
requestId: string;
resumeToken: string;
approvalId: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;

View File

@@ -97,10 +97,12 @@
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
"submitDeleteApprovalConfirm": "Delete the current workflow?",
"offlineImpactBoundAgentsIntro": "This workflow is currently bound to the following agents:",
"offlineImpactBoundAgentsFooter": "After the workflow goes offline, the system will automatically remove it from these agents.",
"offlineImpactBoundAgentsFooter": "Remove this workflow from these agents first.",
"offlineImpactBoundSkillsIntro": "This workflow is currently bound to the following Skills:",
"offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:",
"offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.",
"offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from agents and mark the related plugins as unavailable.",
"offlineImpactBoundPluginsFooter": "Update the plugins that reference this workflow first.",
"offlineImpactBoundMixedFooter": "Remove all references before taking this workflow offline.",
"offlineImpactBlockedFooter": "Remove the references above before taking this workflow offline.",
"publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check",

View File

@@ -97,10 +97,12 @@
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
"offlineImpactBoundAgentsIntro": "当前工作流被以下智能体绑定:",
"offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该工作流。",
"offlineImpactBoundAgentsFooter": "请先从这些智能体中移除该工作流。",
"offlineImpactBoundSkillsIntro": "当前工作流被以下 Skill 绑定:",
"offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:",
"offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。",
"offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从智能体中解绑该工作流,同时让相关插件进入不可用状态。",
"offlineImpactBoundPluginsFooter": "请先调整引用该工作流的插件。",
"offlineImpactBoundMixedFooter": "请先取消所有引用后再下线。",
"offlineImpactBlockedFooter": "请先取消以上引用后再下线。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查",

View File

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

View File

@@ -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 ?? '请求失败',
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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),
),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`,

View File

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

View File

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

View File

@@ -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('调用失败');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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: [],
});
}
}

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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', () => {

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,11 @@ export const LOCAL_ICON_DATA: Record<string, IconifyIcon> = {
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7v14m4-9h2m-2-4h2M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4a4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3a3 3 0 0 0-3-3zm3-6h2M6 8h2"/>',
},
'lucide:notebook-tabs': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2 6h4m-4 4h4m-4 4h4m-4 4h4"/><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M15 2v20m0-15h5m-5 5h5m-5 5h5"/></g>',
},
'lucide:copyright': {
width: 24,
height: 24,

View File

@@ -57,6 +57,7 @@ export {
Minimize,
Minimize2,
MoonStar,
NotebookTabs,
Palette,
PanelLeft,
PanelRight,
@@ -78,3 +79,8 @@ export {
UserRoundPen,
X,
} from 'lucide-vue-next';
export {
BookOpenText as KnowledgeIcon,
NotebookTabs as SkillIcon,
} from 'lucide-vue-next';

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { ChatArtifactLoader, ChatTimelineArtifactItem } from './types';
import { computed, ref } from 'vue';
const props = defineProps<{
artifactLoader?: ChatArtifactLoader;
item: ChatTimelineArtifactItem;
}>();
const downloading = ref(false);
const downloadError = ref('');
const downloadable = computed(
() =>
props.item.status === 'available' &&
Boolean(props.item.downloadUrl) &&
Boolean(props.artifactLoader),
);
function formatSize(value?: number) {
const bytes = Number(value || 0);
if (bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function fileType() {
const extension = props.item.fileName.trim().split('.').pop()?.toUpperCase();
if (extension && extension !== props.item.fileName.toUpperCase()) {
return extension;
}
const mime = String(props.item.mimeType || '')
.split('/')
.pop();
return mime ? mime.toUpperCase() : '文件';
}
const statusText = computed(() => {
if (downloading.value) return '下载中';
if (downloadError.value) return `${downloadError.value},点击重试`;
if (props.item.status === 'expired') return '已过期';
if (props.item.status === 'delete_failed') return '删除失败';
if (props.item.status === 'unavailable') return '不可用';
return [fileType(), formatSize(props.item.size)].filter(Boolean).join(' · ');
});
function downloadTitle() {
const hash = props.item.sha256 ? `\nSHA-256: ${props.item.sha256}` : '';
if (downloadable.value) {
return `${downloadError.value ? '重试下载' : '下载'} ${props.item.fileName}${hash}`;
}
return `${props.item.fileName}${statusText.value}${hash}`;
}
function errorMessage(error: unknown) {
return error instanceof Error && error.message.trim()
? error.message
: '下载失败,请重试';
}
async function download() {
if (!downloadable.value || downloading.value || !props.artifactLoader) {
return;
}
downloading.value = true;
downloadError.value = '';
try {
await props.artifactLoader(props.item);
} catch (error) {
downloadError.value = errorMessage(error);
} finally {
downloading.value = false;
}
}
</script>
<template>
<article
class="chat-artifact"
:class="[
`is-${item.status.replaceAll('_', '-')}`,
{ 'has-download-error': Boolean(downloadError) },
]"
>
<button
type="button"
class="chat-artifact__main"
:aria-label="
downloadable
? `${downloadError ? '重试下载' : '下载'} ${item.fileName}`
: `${item.fileName} ${statusText}`
"
:disabled="!downloadable || downloading"
:title="downloadTitle()"
@click="download"
>
<span class="chat-artifact__icon-box" aria-hidden="true">
<span v-if="downloading" class="chat-artifact__spinner"></span>
<svg v-else viewBox="0 0 24 24">
<path d="M6.5 3.5h7l4 4v13h-11v-17Zm7 0v4h4" />
<path d="M9 16.5h6M12 10v4m0 0 2-2m-2 2-2-2" />
</svg>
</span>
<span class="chat-artifact__meta">
<span class="chat-artifact__name">{{ item.fileName }}</span>
<span class="chat-artifact__state">{{ statusText }}</span>
</span>
<span
v-if="downloadable && !downloading"
class="chat-artifact__action"
aria-hidden="true"
>
{{ downloadError ? '重试' : '下载' }}
</span>
</button>
</article>
</template>
<style scoped>
.chat-artifact {
width: min(320px, 100%);
min-width: 0;
overflow: hidden;
background: hsl(var(--surface-elevated));
border-radius: var(--radius-toolbar);
box-shadow: inset 0 0 0 1px hsl(var(--line-subtle));
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
}
.chat-artifact.is-available:hover {
background: hsl(var(--surface-subtle));
box-shadow: inset 0 0 0 1px hsl(var(--border));
}
.chat-artifact.is-delete-failed,
.chat-artifact.has-download-error {
box-shadow: inset 0 0 0 1px hsl(var(--destructive) / 46%);
}
.chat-artifact__main {
display: flex;
gap: var(--space-2);
align-items: center;
width: 100%;
min-width: 0;
min-height: 64px;
padding: var(--space-2);
color: inherit;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.chat-artifact__main:disabled {
cursor: default;
}
.chat-artifact__main:active:not(:disabled) {
background: hsl(var(--surface-contrast-soft));
}
.chat-artifact__main:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: -2px;
}
.chat-artifact__icon-box {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: hsl(var(--document-icon-foreground));
background: hsl(var(--document-icon-generic));
border-radius: var(--radius-control);
}
.chat-artifact__icon-box svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentcolor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-artifact__spinner {
width: 18px;
height: 18px;
border: 2px solid hsl(var(--document-icon-foreground) / 38%);
border-top-color: hsl(var(--document-icon-foreground));
border-radius: 50%;
animation: chat-artifact-spin 0.9s linear infinite;
}
.chat-artifact__meta {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.chat-artifact__name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-artifact__state {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 18px;
color: hsl(var(--text-muted));
white-space: nowrap;
}
.is-expired .chat-artifact__state,
.is-delete-failed .chat-artifact__state,
.has-download-error .chat-artifact__state {
color: hsl(var(--destructive));
}
.chat-artifact__action {
flex: 0 0 auto;
min-width: 48px;
padding-inline: var(--space-2);
font-size: 13px;
font-weight: 500;
line-height: 32px;
color: hsl(var(--primary));
text-align: center;
}
@keyframes chat-artifact-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.chat-artifact {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-artifact,
.chat-artifact__spinner {
transition: none;
animation: none;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem as ChatTimelineItemType,
@@ -11,9 +12,11 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
import ChatTimelineTurn from './ChatTimelineTurn.vue';
const props = defineProps<{
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantAvatar?: string;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
@@ -39,18 +42,59 @@ const emit = defineEmits<{
const containerRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedScrollTop: number | undefined;
let preservedAnchor: undefined | { element: HTMLElement; relativeTop: number };
const bottomThreshold = 24;
let scrollFrame = 0;
const assistantActionAnchorIds = computed(() => {
const assistantActionAnchorByRound = computed(() => {
const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) {
if (item.type === 'message' && item.role === 'assistant' && item.roundId) {
latestAssistantByRound.set(item.roundId, item.id);
}
}
return new Set(latestAssistantByRound.values());
return latestAssistantByRound;
});
const assistantActionAnchorIds = computed(
() => new Set(assistantActionAnchorByRound.value.values()),
);
type TimelineDisplayEntry =
| { id: string; item: ChatTimelineItemType; type: 'item' }
| {
id: string;
items: ChatTimelineItemType[];
roundId: string;
type: 'turn';
};
const displayEntries = computed<TimelineDisplayEntry[]>(() => {
const entries: TimelineDisplayEntry[] = [];
const turns = new Map<
string,
Extract<TimelineDisplayEntry, { type: 'turn' }>
>();
for (const item of props.items) {
if (!item.roundId || (item.type === 'message' && item.role === 'user')) {
entries.push({ id: item.id, item, type: 'item' });
continue;
}
const existing = turns.get(item.roundId);
if (existing) {
existing.items.push(item);
continue;
}
const turn: Extract<TimelineDisplayEntry, { type: 'turn' }> = {
id: `turn-${item.roundId}`,
items: [item],
roundId: item.roundId,
type: 'turn',
};
turns.set(item.roundId, turn);
entries.push(turn);
}
return entries;
});
function isNearBottom(container: HTMLElement) {
@@ -65,6 +109,9 @@ function updatePinnedState() {
if (!container) {
return;
}
if (suppressNextAutoScroll.value && preservedAnchor) {
return;
}
isPinnedToBottom.value = isNearBottom(container);
}
@@ -86,11 +133,75 @@ function handleTimelineScroll() {
updatePinnedState();
}
function handleThinkingToggle() {
preservedScrollTop = containerRef.value?.scrollTop;
function handleLayoutToggle(roundId: string) {
const container = containerRef.value;
if (!container) {
return;
}
if (suppressNextAutoScroll.value && preservedAnchor) {
return;
}
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
scrollFrame = 0;
}
const turn = [
...container.querySelectorAll<HTMLElement>('[data-round-id]'),
].find((element) => element.dataset.roundId === roundId);
const header = turn?.querySelector<HTMLElement>(
'.chat-timeline-turn__header',
);
const contentAnchor = turn?.querySelector<HTMLElement>(
'[data-chat-turn-final], [data-chat-turn-live-anchor]',
);
const containerRect = container.getBoundingClientRect();
const headerRect = header?.getBoundingClientRect();
const anchor =
headerRect &&
headerRect.bottom >= containerRect.top &&
headerRect.top <= containerRect.bottom
? header
: (contentAnchor ?? header ?? turn);
if (anchor) {
preservedAnchor = {
element: anchor,
relativeTop: anchor.getBoundingClientRect().top - containerRect.top,
};
}
suppressNextAutoScroll.value = true;
}
async function handleLayoutChanged() {
await nextTick();
const container = containerRef.value;
const anchor = preservedAnchor;
if (container && anchor?.element.isConnected) {
const relativeTop =
anchor.element.getBoundingClientRect().top -
container.getBoundingClientRect().top;
container.scrollTop += relativeTop - anchor.relativeTop;
}
preservedAnchor = undefined;
suppressNextAutoScroll.value = false;
updatePinnedState();
}
function handleLegacyLayoutToggle() {
const container = containerRef.value;
if (!container) {
return;
}
const scrollTop = container.scrollTop;
suppressNextAutoScroll.value = true;
void nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = scrollTop;
}
suppressNextAutoScroll.value = false;
updatePinnedState();
});
}
function canCopyMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
@@ -122,13 +233,6 @@ watch(
() => props.items,
async () => {
if (suppressNextAutoScroll.value) {
suppressNextAutoScroll.value = false;
await nextTick();
if (preservedScrollTop !== undefined && containerRef.value) {
containerRef.value.scrollTop = preservedScrollTop;
}
preservedScrollTop = undefined;
updatePinnedState();
return;
}
if (isPinnedToBottom.value) {
@@ -161,32 +265,61 @@ watch(
</div>
</div>
<template v-else>
<template v-for="item in items" :key="item.id">
<template v-for="entry in displayEntries" :key="entry.id">
<ChatTimelineTurn
v-if="entry.type === 'turn'"
:action-anchor-id="assistantActionAnchorByRound.get(entry.roundId)"
:artifact-loader="artifactLoader"
:approval-loading="approvalLoading"
:assistant-avatar="assistantAvatar"
:copy-action="copyAction"
:copyable="copyable"
:document-loader="documentLoader"
:image-loader="imageLoader"
:items="entry.items"
:regenerable="regenerable"
:regenerate-disabled="regenerateDisabled"
:round-id="entry.roundId"
:variant-loading="variantLoading"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@layout-changed="handleLayoutChanged"
@layout-toggle="handleLayoutToggle"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
>
<template #custom-item="{ item }">
<slot name="custom-item" :item="item"></slot>
</template>
</ChatTimelineTurn>
<slot
v-if="item.type === 'custom'"
v-else-if="entry.item.type === 'custom'"
name="custom-item"
:item="item"
:item="entry.item"
></slot>
<ChatTimelineItem
v-else
:assistant-actions-visible="isAssistantActionAnchor(item)"
:assistant-actions-visible="isAssistantActionAnchor(entry.item)"
:artifact-loader="artifactLoader"
:assistant-avatar="assistantAvatar"
:item="item"
:item="entry.item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:copyable="canCopyMessage(entry.item)"
:regenerable="canRegenerateMessage(entry.item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
:variant-loading="isVariantLoading(entry.item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleThinkingToggle"
@thinking-toggle="handleLegacyLayoutToggle"
/>
</template>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem,
@@ -11,6 +12,7 @@ import type {
import { computed, ref } from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatArtifactAttachment from './ChatArtifactAttachment.vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatDocumentAttachments from './ChatDocumentAttachments.vue';
import ChatErrorNotice from './ChatErrorNotice.vue';
@@ -23,6 +25,7 @@ import ChatToolCard from './ChatToolCard.vue';
const props = defineProps<{
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantActionsVisible?: boolean;
assistantAvatar?: string;
copyable?: boolean;
@@ -256,6 +259,11 @@ function handleCopyAction() {
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<ChatArtifactAttachment
v-else-if="item.type === 'artifact'"
:artifact-loader="artifactLoader"
:item="item"
/>
<ChatKnowledgeCard
v-else-if="item.type === 'knowledge'"
:items="item.items"

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import type {ChatTimelineStatusItem} from './types';
import type { ChatTimelineStatusItem } from './types';
import {computed} from 'vue';
import { computed } from 'vue';
import {BookOpenText} from '@easyflow/icons';
import { KnowledgeIcon, SkillIcon } from '@easyflow/icons';
import {ChatEventLabel} from '../chat-status';
import { ChatEventLabel } from '../chat-status';
defineOptions({
name: 'ChatTimelineStatusRow',
@@ -27,20 +27,27 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
`is-${item.tone || 'muted'}`,
{ 'is-separator': isSeparator },
]"
:aria-label="item.label"
:title="item.label"
>
<span
v-if="isSeparator"
class="chat-timeline-status-row__line"
aria-hidden="true"
/>
></span>
<ChatEventLabel
class="chat-timeline-status-row__content"
:active="isRunning"
:text="item.label"
>
<template #icon>
<BookOpenText
v-if="item.icon !== 'none'"
<SkillIcon
v-if="item.icon === 'skill'"
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>
<KnowledgeIcon
v-else-if="item.icon !== 'none'"
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>
@@ -50,7 +57,7 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
v-if="isSeparator"
class="chat-timeline-status-row__line"
aria-hidden="true"
/>
></span>
</div>
</template>
@@ -59,14 +66,31 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
display: inline-flex;
gap: 4px;
align-items: center;
max-width: min(78%, 680px);
min-width: 0;
max-width: 100%;
padding: 2px 0;
color: var(--el-text-color-placeholder);
}
.chat-timeline-status-row__content {
min-width: 0;
max-width: 100%;
}
.chat-timeline-status-row__content :deep(.chat-event-label) {
align-items: flex-start;
white-space: normal;
}
.chat-timeline-status-row__content :deep(.chat-event-label__icon) {
margin-top: var(--space-1);
}
.chat-timeline-status-row__content :deep(.chat-shimmer-text) {
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
white-space: normal;
}
.chat-timeline-status-row__icon {
@@ -75,16 +99,36 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
color: var(--el-text-color-placeholder);
}
.chat-timeline-status-row.is-done {
.chat-timeline-status-row.is-cancelled,
.chat-timeline-status-row.is-done,
.chat-timeline-status-row.is-error,
.chat-timeline-status-row.is-incomplete {
opacity: 0.84;
}
.chat-timeline-status-row.is-error,
.chat-timeline-status-row.is-danger {
color: var(--el-color-danger);
}
.chat-timeline-status-row.is-error .chat-timeline-status-row__icon,
.chat-timeline-status-row.is-danger .chat-timeline-status-row__icon {
color: var(--el-color-danger);
}
.chat-timeline-status-row.is-error :deep(.chat-event-label),
.chat-timeline-status-row.is-danger :deep(.chat-event-label),
.chat-timeline-status-row.is-error :deep(.chat-shimmer-text),
.chat-timeline-status-row.is-danger :deep(.chat-shimmer-text) {
color: var(--el-color-danger);
}
.chat-timeline-status-row.is-separator {
display: flex;
width: 100%;
max-width: 100%;
gap: 8px;
justify-content: center;
width: 100%;
max-width: 100%;
}
.chat-timeline-status-row.is-separator .chat-timeline-status-row__line {

View File

@@ -0,0 +1,523 @@
<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineStatusItem,
ChatTimelineToolApprovalPayload,
} from './types';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItemView from './ChatTimelineItem.vue';
const props = defineProps<{
actionAnchorId?: string;
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantAvatar?: string;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
imageLoader?: ChatImageLoader;
items: ChatTimelineItem[];
regenerable?: (item: ChatTimelineMessageItem) => boolean;
regenerateDisabled?: boolean;
roundId: string;
variantLoading?: (item: ChatTimelineMessageItem) => boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
layoutChanged: [];
layoutToggle: [roundId: string];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
}>();
const turnSucceeded = computed(() =>
props.items.some((item) => item.turnSucceeded === true),
);
const latestAssistantContentSource = computed(() =>
[...props.items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(item.parts.some((part) => part.type === 'text' && part.content) ||
Boolean(item.knowledgeItems?.length)),
),
);
const finalMessageSource = computed(() =>
turnSucceeded.value ? latestAssistantContentSource.value : undefined,
);
const finalMessage = computed<ChatTimelineMessageItem | undefined>(() => {
const item = finalMessageSource.value;
if (!item) {
return undefined;
}
return {
...item,
parts: item.parts.filter((part) => part.type === 'text'),
};
});
const processItems = computed<ChatTimelineItem[]>(() => {
const finalSource = finalMessageSource.value;
return props.items.flatMap((item) => {
if (item.type === 'artifact') {
return [];
}
if (
item.type === 'message' &&
item.role === 'assistant' &&
item.parts.length === 0 &&
!item.knowledgeItems?.length &&
!item.images?.length &&
!item.documents?.length
) {
return [];
}
if (item !== finalSource) {
return [item];
}
const thinkingParts = item.parts.filter((part) => part.type === 'thinking');
if (thinkingParts.length === 0) {
return [];
}
return [
{
...item,
id: `${item.id}-process`,
knowledgeItems: undefined,
parts: thinkingParts,
roundCompleted: false,
},
];
});
});
const artifactItems = computed(() =>
props.items.filter((item) => item.type === 'artifact'),
);
const turnStartedAt = computed(() => {
const starts = props.items
.map((item) => item.turnStartedAt)
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return starts.length > 0 ? Math.min(...starts) : undefined;
});
const turnFinishedAt = computed(() => {
const finishes = props.items
.map((item) => item.turnFinishedAt)
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return finishes.length > 0 ? Math.max(...finishes) : undefined;
});
const turnFinished = computed(() => turnFinishedAt.value !== undefined);
const turnActive = computed(() => !turnSucceeded.value && !turnFinished.value);
const hasPendingApproval = computed(() =>
props.items.some(
(item) =>
item.type === 'tool' &&
(item.status === 'approving' || item.status === 'pending_approval'),
),
);
const hasActiveProcessIndicator = computed(() =>
props.items.some((item) => {
if (item.type === 'tool') {
return ['approving', 'pending_approval', 'running'].includes(item.status);
}
if (item.type === 'status') {
return item.status === 'running';
}
return (
item.type === 'message' &&
item.parts.some(
(part) => part.type === 'thinking' && part.status === 'thinking',
)
);
}),
);
const continuingStatus = computed<ChatTimelineStatusItem | undefined>(() => {
if (!turnActive.value || hasActiveProcessIndicator.value) {
return undefined;
}
return {
id: `chat-turn-continuing-${props.roundId}`,
icon: 'none',
label: '正在继续处理',
presentation: 'inline',
roundId: props.roundId,
status: 'running',
statusKey: `chat-turn-continuing:${props.roundId}`,
turnStartedAt: turnStartedAt.value,
type: 'status',
};
});
const canCollapse = computed(
() =>
turnSucceeded.value &&
Boolean(finalMessage.value) &&
!hasPendingApproval.value,
);
const processId = computed(() => `chat-turn-process-${props.roundId}`);
const expanded = ref(!canCollapse.value);
watch(
canCollapse,
(value, previous) => {
if (value && !previous) {
if (previous === false) {
emit('layoutToggle', props.roundId);
}
expanded.value = false;
if (processItems.value.length === 0) {
void nextTick(() => emit('layoutChanged'));
}
} else if (!value) {
expanded.value = true;
}
},
{ immediate: true },
);
const observedStartedAt = Date.now();
const clockNow = ref(observedStartedAt);
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
function stopElapsedTimer() {
if (elapsedTimer === undefined) {
return;
}
clearInterval(elapsedTimer);
elapsedTimer = undefined;
}
function syncElapsedTimer() {
clockNow.value = Date.now();
stopElapsedTimer();
if (!turnActive.value) {
return;
}
elapsedTimer = setInterval(() => {
clockNow.value = Date.now();
}, 1000);
}
watch([turnStartedAt, turnActive], syncElapsedTimer, { immediate: true });
onBeforeUnmount(stopElapsedTimer);
const durationLabel = computed(() => {
const startedAt =
turnStartedAt.value ?? (turnActive.value ? observedStartedAt : undefined);
const finishedAt =
turnFinishedAt.value ?? (turnActive.value ? clockNow.value : undefined);
if (startedAt === undefined || finishedAt === undefined) {
return '';
}
const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000));
if (seconds < 60) {
return `${seconds}`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return `${minutes}${remainingSeconds}`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours} 小时 ${remainingMinutes}${remainingSeconds}`;
});
const summaryLabel = computed(() => {
if (turnSucceeded.value) {
return durationLabel.value ? `已处理 ${durationLabel.value}` : '已处理';
}
if (turnFinished.value) {
return '处理未完成';
}
return `已处理 ${durationLabel.value || '1 秒'}`;
});
function isAssistantActionAnchor(item: ChatTimelineItem) {
return item.type === 'message' && item.id === props.actionAnchorId;
}
function canCopyMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
function canRegenerateMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function isVariantLoading(item: ChatTimelineItem) {
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
}
function isLiveContentAnchor(item: ChatTimelineItem) {
return !turnSucceeded.value && item === latestAssistantContentSource.value;
}
function toggleProcess() {
if (!canCollapse.value) {
return;
}
emit('layoutToggle', props.roundId);
expanded.value = !expanded.value;
if (processItems.value.length === 0) {
void nextTick(() => emit('layoutChanged'));
}
}
function handleNestedLayoutToggle() {
emit('layoutToggle', props.roundId);
void nextTick(() => emit('layoutChanged'));
}
</script>
<template>
<section class="chat-timeline-turn" :data-round-id="roundId">
<header class="chat-timeline-turn__header">
<span class="chat-timeline-turn__avatar">
<ChatAssistantAvatar :src="assistantAvatar" />
</span>
<button
class="chat-timeline-turn__summary"
:class="{ 'is-toggleable': canCollapse }"
:disabled="!canCollapse"
type="button"
:aria-controls="canCollapse ? processId : undefined"
:aria-expanded="canCollapse ? expanded : undefined"
@click="toggleProcess"
>
<span>{{ summaryLabel }}</span>
<svg
v-if="canCollapse"
class="chat-timeline-turn__chevron"
:class="{ 'is-expanded': expanded }"
aria-hidden="true"
viewBox="0 0 16 16"
>
<path d="M5.5 3.5 10 8l-4.5 4.5" />
</svg>
</button>
</header>
<div class="chat-timeline-turn__content">
<Transition
name="chat-turn-process"
@after-enter="emit('layoutChanged')"
@after-leave="emit('layoutChanged')"
>
<div
v-if="expanded && (processItems.length > 0 || continuingStatus)"
:id="processId"
class="chat-timeline-turn__process"
>
<template v-for="item in processItems" :key="item.id">
<slot
v-if="item.type === 'custom'"
name="custom-item"
:item="item"
></slot>
<ChatTimelineItemView
v-else
:assistant-actions-visible="isAssistantActionAnchor(item)"
:artifact-loader="artifactLoader"
:data-chat-turn-live-anchor="
isLiveContentAnchor(item) ? '' : undefined
"
:item="item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleNestedLayoutToggle"
/>
</template>
<ChatTimelineItemView
v-if="continuingStatus"
:item="continuingStatus"
/>
</div>
</Transition>
<div v-if="finalMessage" data-chat-turn-final>
<ChatTimelineItemView
:assistant-actions-visible="isAssistantActionAnchor(finalMessage)"
:artifact-loader="artifactLoader"
:item="finalMessage"
:document-loader="documentLoader"
:image-loader="imageLoader"
:copy-action="copyAction"
:copyable="canCopyMessage(finalMessage)"
:regenerable="canRegenerateMessage(finalMessage)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(finalMessage)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
/>
</div>
<div
v-if="artifactItems.length > 0"
class="chat-timeline-turn__artifacts"
>
<ChatTimelineItemView
v-for="item in artifactItems"
:key="item.id"
:artifact-loader="artifactLoader"
:item="item"
/>
</div>
</div>
</section>
</template>
<style scoped>
.chat-timeline-turn {
--chat-turn-avatar-size: 28px;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
min-width: 0;
}
.chat-timeline-turn__header {
display: flex;
gap: var(--space-2);
align-items: center;
min-height: var(--chat-turn-avatar-size);
}
.chat-timeline-turn__avatar {
flex: 0 0 var(--chat-turn-avatar-size);
width: var(--chat-turn-avatar-size);
height: var(--chat-turn-avatar-size);
overflow: hidden;
border-radius: var(--radius-control);
}
.chat-timeline-turn__summary {
display: inline-flex;
gap: var(--space-1);
align-items: center;
min-width: 0;
padding: 0;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 20px;
color: var(--el-text-color-secondary);
appearance: none;
cursor: default;
background: transparent;
border: 0;
}
.chat-timeline-turn__summary.is-toggleable {
cursor: pointer;
}
.chat-timeline-turn__summary.is-toggleable:hover {
color: var(--el-text-color-primary);
}
.chat-timeline-turn__summary.is-toggleable:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 3px;
border-radius: var(--radius-control);
}
.chat-timeline-turn__summary:disabled {
opacity: 1;
}
.chat-timeline-turn__chevron {
width: 14px;
height: 14px;
fill: none;
stroke: currentcolor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.chat-timeline-turn__chevron.is-expanded {
transform: rotate(90deg);
}
.chat-timeline-turn__content {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
padding-inline-start: calc(var(--chat-turn-avatar-size) + var(--space-2));
}
.chat-timeline-turn__process {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.chat-timeline-turn__artifacts {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.chat-turn-process-enter-active,
.chat-turn-process-leave-active {
transition:
opacity var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-standard);
}
.chat-turn-process-enter-from,
.chat-turn-process-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.chat-timeline-turn__chevron,
.chat-turn-process-enter-active,
.chat-turn-process-leave-active {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,65 @@
import type { ChatTimelineArtifactItem } from '../types';
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import ChatArtifactAttachment from '../ChatArtifactAttachment.vue';
function artifact(
status: ChatTimelineArtifactItem['status'] = 'available',
): ChatTimelineArtifactItem {
return {
artifactId: '01JTESTARTIFACT',
downloadUrl:
status === 'available'
? '/api/v1/agent/artifacts/01JTESTARTIFACT/content'
: undefined,
fileName: '项目报告.pdf',
id: 'artifact:01JTESTARTIFACT',
mimeType: 'application/pdf',
sha256: 'a'.repeat(64),
size: 2048,
status,
type: 'artifact',
};
}
describe('chat Artifact attachment', () => {
it('支持鉴权下载、失败提示和原位重试', async () => {
const loader = vi
.fn()
.mockRejectedValueOnce(new Error('网络中断'))
.mockResolvedValueOnce(undefined);
const wrapper = mount(ChatArtifactAttachment, {
props: { artifactLoader: loader, item: artifact() },
});
await wrapper.get('button').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('网络中断,点击重试');
expect(wrapper.get('button').attributes('aria-label')).toContain(
'重试下载',
);
await wrapper.get('button').trigger('click');
await flushPromises();
expect(loader).toHaveBeenCalledTimes(2);
expect(wrapper.text()).toContain('PDF · 2 KB');
});
it.each([
['expired', '已过期'],
['delete_failed', '删除失败'],
['unavailable', '不可用'],
] as const)('展示 %s 状态并禁用下载', (status, label) => {
const loader = vi.fn();
const wrapper = mount(ChatArtifactAttachment, {
props: { artifactLoader: loader, item: artifact(status) },
});
expect(wrapper.text()).toContain(label);
expect(wrapper.get('button').attributes('disabled')).toBeDefined();
expect(wrapper.html()).not.toMatch(/MinIO|bucket|objectKey|workspace/i);
});
});

View File

@@ -1,12 +1,14 @@
import type {ChatTimelineStatusItem} from '../types';
import type { ChatTimelineStatusItem } from '../types';
import {mount} from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import { SkillIcon } from '@easyflow/icons';
import { describe, expect, it } from 'vitest';
import ChatTimelineStatusRow from '../ChatTimelineStatusRow.vue';
describe('ChatTimelineStatusRow', () => {
describe('chatTimelineStatusRow', () => {
it('uses shimmer text while running and static text after done', async () => {
const item: ChatTimelineStatusItem = {
id: 'knowledge-retrieval',
@@ -20,7 +22,9 @@ describe('ChatTimelineStatusRow', () => {
});
expect(wrapper.text()).toContain('正在检索知识库');
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(
false,
);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
@@ -33,7 +37,9 @@ describe('ChatTimelineStatusRow', () => {
});
expect(wrapper.text()).toContain('已检索知识库');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain(
'is-active',
);
});
it('renders memory compression status as a separator row', () => {
@@ -52,7 +58,9 @@ describe('ChatTimelineStatusRow', () => {
expect(wrapper.classes()).toContain('is-separator');
expect(wrapper.text()).toContain('正在整理上下文');
expect(wrapper.findAll('.chat-timeline-status-row__line')).toHaveLength(2);
expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(true);
expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(
true,
);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
});
@@ -71,7 +79,9 @@ describe('ChatTimelineStatusRow', () => {
expect(wrapper.find('.chat-event-label').exists()).toBe(true);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain(
'is-active',
);
});
it('can render a plain inline status without the context icon', () => {
@@ -88,7 +98,53 @@ describe('ChatTimelineStatusRow', () => {
});
expect(wrapper.text()).toContain('运行完成');
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(
false,
);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(
false,
);
});
it('uses the shared Skill icon, full accessible label and failure tone', () => {
const label = `调用 ${'超长技能名称'.repeat(20)} 失败`;
const item: ChatTimelineStatusItem = {
icon: 'skill',
id: 'skill-invocation:r1:101',
label,
status: 'error',
statusKey: 'skill-invocation:r1:101',
tone: 'danger',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.attributes('aria-label')).toBe(label);
expect(wrapper.attributes('title')).toBe(label);
expect(wrapper.classes()).toContain('is-error');
expect(wrapper.classes()).toContain('is-danger');
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain(
'is-active',
);
});
it('uses the knowledge status shimmer for a running Skill', () => {
const item: ChatTimelineStatusItem = {
icon: 'skill',
id: 'skill-invocation:r1:101',
label: '正在调用 合同审查助手',
status: 'running',
statusKey: 'skill-invocation:r1:101',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
expect(wrapper.findComponent(SkillIcon).exists()).toBe(true);
});
});

View File

@@ -0,0 +1,464 @@
import type { ChatTimelineItem } from '../types';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ChatTimeline from '../ChatTimeline.vue';
import ChatTimelineTurn from '../ChatTimelineTurn.vue';
function completedTurnItems(): ChatTimelineItem[] {
return [
{
id: 'reasoning-1',
parts: [
{
content: '先检索资料',
id: 'thinking-1',
status: 'end',
type: 'thinking',
},
],
role: 'assistant',
roundCompleted: true,
roundId: 'round-1',
status: 'done',
turnFinishedAt: 19_000,
turnStartedAt: 1000,
turnSucceeded: true,
type: 'message',
},
{
id: 'tool-1',
input: { query: 'AG-UI' },
mode: 'auto',
roundCompleted: true,
roundId: 'round-1',
status: 'success',
toolCallId: 'tool-1',
toolName: 'Context7 查询',
turnFinishedAt: 19_000,
turnStartedAt: 1000,
turnSucceeded: true,
type: 'tool',
},
{
id: 'assistant-final',
parts: [
{ content: 'AG-UI 是智能体交互协议。', id: 'text-1', type: 'text' },
],
role: 'assistant',
roundCompleted: true,
roundId: 'round-1',
status: 'done',
turnFinishedAt: 19_000,
turnStartedAt: 1000,
turnSucceeded: true,
type: 'message',
},
];
}
describe('chat timeline turn', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(19_000);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it('shows one running header for an empty RUN_STARTED placeholder', () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [
{
id: 'turn-round-started',
parts: [],
role: 'assistant',
roundId: 'round-started',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
],
},
});
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1);
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 18 秒',
);
expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(0);
expect(wrapper.text()).toContain('正在继续处理');
});
it('keeps continuous feedback around a fast automatic tool call', async () => {
const assistant: ChatTimelineItem = {
id: 'assistant-fast-tool',
parts: [
{
content: '开始生成文件。',
id: 'text-fast-tool',
type: 'text',
},
],
role: 'assistant',
roundId: 'round-fast-tool',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
};
const runningTool: ChatTimelineItem = {
id: 'tool-fast-write',
mode: 'auto',
roundId: 'round-fast-tool',
status: 'running',
toolCallId: 'tool-fast-write',
toolName: 'write_text_file',
turnStartedAt: 1000,
type: 'tool',
};
const wrapper = mount(ChatTimelineTurn, {
props: {
items: [assistant],
roundId: 'round-fast-tool',
},
});
expect(wrapper.text()).toContain('正在继续处理');
await wrapper.setProps({ items: [assistant, runningTool] });
expect(wrapper.text()).toContain('调用中');
expect(wrapper.text()).not.toContain('正在继续处理');
await wrapper.setProps({
items: [assistant, { ...runningTool, status: 'success' }],
});
expect(wrapper.text()).toContain('已完成');
expect(wrapper.text()).toContain('正在继续处理');
});
it('updates the running duration every second and freezes it on success', async () => {
vi.setSystemTime(1000);
const runningItem: ChatTimelineItem = {
id: 'turn-round-live',
parts: [],
role: 'assistant',
roundId: 'round-live',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
};
const wrapper = mount(ChatTimelineTurn, {
props: {
items: [runningItem],
roundId: 'round-live',
},
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 秒',
);
expect(vi.getTimerCount()).toBe(1);
vi.advanceTimersByTime(64_000);
await nextTick();
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 分 4 秒',
);
await wrapper.setProps({
items: [
{
...runningItem,
roundCompleted: true,
status: 'done',
turnFinishedAt: 65_000,
turnSucceeded: true,
},
],
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 分 4 秒',
);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(10_000);
await nextTick();
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 1 分 4 秒',
);
});
it('switches the running timer to the incomplete terminal state', async () => {
vi.setSystemTime(5000);
const runningItem: ChatTimelineItem = {
id: 'turn-round-error',
parts: [],
role: 'assistant',
roundId: 'round-error',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
};
const wrapper = mount(ChatTimelineTurn, {
props: {
items: [runningItem],
roundId: 'round-error',
},
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 4 秒',
);
await wrapper.setProps({
items: [
{
...runningItem,
status: 'error',
turnFinishedAt: 5000,
turnSucceeded: false,
},
],
});
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'处理未完成',
);
expect(vi.getTimerCount()).toBe(0);
});
it('keeps partial assistant text in event order while the turn is active', () => {
const items: ChatTimelineItem[] = [
{
id: 'assistant-partial',
parts: [
{
content: '先检查依赖',
id: 'thinking-1',
status: 'end',
type: 'thinking',
},
{ content: '正文 A', id: 'text-1', type: 'text' },
],
role: 'assistant',
roundId: 'round-live-order',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
{
id: 'tool-completed',
mode: 'auto',
roundId: 'round-live-order',
status: 'success',
toolCallId: 'tool-completed',
toolName: '已执行工具',
turnStartedAt: 1000,
type: 'tool',
},
{
id: 'assistant-reasoning',
parts: [
{
content: '继续思考',
id: 'thinking-2',
status: 'thinking',
type: 'thinking',
},
],
role: 'assistant',
roundId: 'round-live-order',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
{
approval: {
approvalId: 'approval-1',
toolCallId: 'tool-pending',
toolName: '待审批工具',
},
id: 'tool-pending',
mode: 'approval',
roundId: 'round-live-order',
status: 'pending_approval',
toolCallId: 'tool-pending',
toolName: '待审批工具',
turnStartedAt: 1000,
type: 'tool',
},
];
const wrapper = mount(ChatTimelineTurn, {
props: { items, roundId: 'round-live-order' },
});
const rendered = wrapper.text();
expect(wrapper.find('[data-chat-turn-final]').exists()).toBe(false);
expect(rendered.indexOf('正文 A')).toBeLessThan(
rendered.indexOf('已执行工具'),
);
expect(rendered.indexOf('已执行工具')).toBeLessThan(
rendered.indexOf('继续思考'),
);
expect(rendered.indexOf('继续思考')).toBeLessThan(
rendered.indexOf('待审批工具'),
);
});
it('renders one avatar and collapses process after a successful turn', async () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: completedTurnItems(),
},
});
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1);
expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(0);
expect(wrapper.find('.chat-timeline-turn__summary').text()).toContain(
'已处理 18 秒',
);
expect(wrapper.text()).toContain('AG-UI 是智能体交互协议。');
expect(wrapper.text()).not.toContain('Context7 查询');
expect(wrapper.text()).not.toContain('先检索资料');
await wrapper.find('.chat-timeline-turn__summary').trigger('click');
expect(wrapper.text()).toContain('Context7 查询');
expect(wrapper.text()).toContain('已思考');
expect(wrapper.text()).toContain('AG-UI 是智能体交互协议。');
});
it('preserves the final-message scroll anchor while auto-collapsing', async () => {
const runningItems = completedTurnItems().map((item) => {
const {
roundCompleted: _roundCompleted,
turnFinishedAt: _turnFinishedAt,
turnSucceeded: _turnSucceeded,
...runningItem
} = item;
return runningItem;
});
const wrapper = mount(ChatTimeline, {
attachTo: document.body,
global: { stubs: { Transition: false } },
props: {
assistantAvatar: '/assistant.svg',
items: runningItems,
},
});
const container = wrapper.find('.chat-timeline').element as HTMLElement;
Object.defineProperties(container, {
clientHeight: { configurable: true, value: 500 },
scrollHeight: { configurable: true, value: 1200 },
scrollTop: { configurable: true, value: 400, writable: true },
});
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
bottom: 500,
top: 0,
} as DOMRect);
const header = wrapper.find('.chat-timeline-turn__header')
.element as HTMLElement;
vi.spyOn(header, 'getBoundingClientRect').mockReturnValue({
bottom: -72,
top: -100,
} as DOMRect);
const liveMessage = wrapper.find('[data-chat-turn-live-anchor]')
.element as HTMLElement;
vi.spyOn(liveMessage, 'getBoundingClientRect')
.mockReturnValueOnce({ bottom: 700, top: 600 } as DOMRect)
.mockReturnValue({ bottom: 400, top: 300 } as DOMRect);
await wrapper.find('.chat-timeline').trigger('scroll');
wrapper.findComponent(ChatTimelineTurn).vm.$emit('layoutToggle', 'round-1');
await wrapper.setProps({ items: completedTurnItems() });
await nextTick();
wrapper.findComponent(ChatTimelineTurn).vm.$emit('layoutChanged');
await nextTick();
expect(container.scrollTop).toBe(100);
wrapper.unmount();
});
it('keeps running and approval content expanded in one turn', () => {
const items: ChatTimelineItem[] = [
{
id: 'reasoning-1',
parts: [
{
content: '准备调用工具',
id: 'thinking-1',
status: 'thinking',
type: 'thinking',
},
],
role: 'assistant',
roundId: 'round-1',
status: 'streaming',
turnStartedAt: 1000,
type: 'message',
},
{
approval: {
approvalId: 'approval-1',
toolCallId: 'tool-1',
toolName: 'context7',
},
id: 'tool-1',
mode: 'approval',
roundId: 'round-1',
status: 'pending_approval',
toolCallId: 'tool-1',
toolName: 'Context7 查询',
turnStartedAt: 1000,
type: 'tool',
},
];
const wrapper = mount(ChatTimeline, {
props: { assistantAvatar: '/assistant.svg', items },
});
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1);
expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe(
'已处理 18 秒',
);
expect(wrapper.text()).toContain('准备调用工具');
expect(wrapper.text()).toContain('Context7 查询');
expect(
wrapper.find('.chat-timeline-turn__summary').attributes('disabled'),
).toBeDefined();
});
it('renders one avatar for each of multiple turns', () => {
const secondTurn = completedTurnItems().map((item) => ({
...item,
id: `${item.id}-2`,
roundId: 'round-2',
}));
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [...completedTurnItems(), ...secondTurn],
},
});
expect(wrapper.findAll('.chat-timeline-turn')).toHaveLength(2);
expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(2);
});
});

View File

@@ -1,8 +1,8 @@
import type {ChatTimelineItem} from '../types';
import type { ChatTimelineItem } from '../types';
import {describe, expect, it} from 'vitest';
import { describe, expect, it } from 'vitest';
import {ChatTimelineBuilder} from '../builder';
import { ChatTimelineBuilder } from '../builder';
describe('chat timeline builder', () => {
it('keeps streamed thinking, text, tool and following text in timeline order', () => {
@@ -156,6 +156,72 @@ describe('chat timeline builder', () => {
}
});
it('updates one Skill invocation row in place and preserves Skill order', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
displayName: '合同审查助手',
status: 'RUNNING',
statusKey: 'skill-invocation:r1:101',
});
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
displayName: '数据分析助手',
status: 'RUNNING',
statusKey: 'skill-invocation:r1:102',
});
ChatTimelineBuilder.upsertSkillInvocationStatus(items, {
displayName: '合同审查助手',
status: 'SUCCESS',
statusKey: 'skill-invocation:r1:101',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
icon: 'skill',
label: '已调用 合同审查助手',
status: 'done',
});
expect(items[1]).toMatchObject({
icon: 'skill',
label: '正在调用 数据分析助手',
status: 'running',
});
});
it('never turns an unterminated Skill invocation into fake success', () => {
const incompleteItems: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertSkillInvocationStatus(incompleteItems, {
displayName: '合同审查助手',
roundId: 'r1',
status: 'RUNNING',
statusKey: 'skill-invocation:r1:101',
});
ChatTimelineBuilder.finalize(incompleteItems, { roundId: 'r1' });
expect(incompleteItems[0]).toMatchObject({
label: '调用 合同审查助手 未完成',
status: 'incomplete',
});
const cancelledItems: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertSkillInvocationStatus(cancelledItems, {
displayName: '合同审查助手',
roundId: 'r2',
status: 'RUNNING',
statusKey: 'skill-invocation:r2:101',
});
ChatTimelineBuilder.finalize(
cancelledItems,
{ roundId: 'r2' },
{ runningSkillStatus: 'cancelled' },
);
expect(cancelledItems[0]).toMatchObject({
label: '已停止调用 合同审查助手',
status: 'cancelled',
});
});
it('removes memory compression status when compression produced no compressed event', () => {
const items: ChatTimelineItem[] = [];
@@ -352,15 +418,13 @@ describe('chat timeline builder', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
toolName: '审批工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.markToolApproving(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
});
ChatTimelineBuilder.upsertToolCall(items, {
@@ -379,7 +443,7 @@ describe('chat timeline builder', () => {
if (items[0]?.type === 'tool') {
expect(items[0].mode).toBe('approval');
expect(items[0].status).toBe('success');
expect(items[0].approval?.requestId).toBe('request-1');
expect(items[0].approval?.approvalId).toBe('approval-1');
expect(items[0].input).toEqual({ keyword: 'EasyFlow' });
expect(items[0].output).toEqual({ result: 'ok' });
}
@@ -389,8 +453,7 @@ describe('chat timeline builder', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'submit-call-1',
toolName: '文档生成',
input: { user_input: '写一篇小作文' },
@@ -452,14 +515,13 @@ describe('chat timeline builder', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
toolName: '审批工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.markToolRejected(items, {
requestId: 'request-1',
approvalId: 'approval-1',
toolCallId: 'call-1',
reason: '用户拒绝执行',
});
@@ -482,8 +544,7 @@ describe('chat timeline builder', () => {
input: { keyword: 'before approval' },
});
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
approvalId: 'approval-1',
toolCallId: 'call-2',
toolName: '查询工具',
input: { keyword: 'approval' },
@@ -498,7 +559,7 @@ describe('chat timeline builder', () => {
expect(items[1].toolCallId).toBe('call-2');
expect(items[1].mode).toBe('approval');
expect(items[1].status).toBe('pending_approval');
expect(items[1].approval?.requestId).toBe('request-1');
expect(items[1].approval?.approvalId).toBe('approval-1');
expect(items[1].input).toEqual({ keyword: 'approval' });
}
});

View File

@@ -1,8 +1,12 @@
import type {
ChatArtifactAttachment,
ChatTimelineArtifactItem,
ChatTimelineItem,
ChatTimelineItemBase,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,
@@ -13,6 +17,17 @@ import type {
ChatTimelineToolStatus,
} from './types';
type ChatTimelineTurnMetadata = Partial<
Pick<
ChatTimelineItemBase,
| 'roundCompleted'
| 'roundId'
| 'turnFinishedAt'
| 'turnStartedAt'
| 'turnSucceeded'
>
>;
function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
@@ -73,6 +88,24 @@ function ensureMessageTail(
Object.assign(last, metadata);
return last;
}
const placeholder =
role === 'assistant' && metadata?.roundId
? [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
item.roundId === metadata.roundId &&
item.status !== 'done' &&
item.parts.length === 0,
)
: undefined;
if (placeholder) {
placeholder.status = status;
Object.assign(placeholder, metadata);
return placeholder;
}
const item: ChatTimelineMessageItem = {
id: createId(role),
role,
@@ -138,19 +171,18 @@ function updateThinkingStatus(
);
}
function finishLastAssistantMessage(items: ChatTimelineItem[]) {
finishAssistantMessage(items, true);
}
function finishAssistantMessage(
items: ChatTimelineItem[],
roundCompleted: boolean,
roundId?: string,
) {
const lastMessage = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
item.type === 'message' &&
item.role === 'assistant' &&
(!roundId || item.roundId === roundId),
);
if (!lastMessage) {
return;
@@ -167,6 +199,7 @@ function findToolItem(
toolCallId?: string,
taskId?: string,
sourceToolCallId?: string,
approvalId?: string,
) {
const identities = new Set(
[toolCallId, sourceToolCallId]
@@ -174,13 +207,16 @@ function findToolItem(
.filter(Boolean),
);
const normalizedTaskId = normalizeText(taskId).trim();
if (identities.size === 0 && !normalizedTaskId) {
const normalizedApprovalId = normalizeText(approvalId).trim();
if (identities.size === 0 && !normalizedTaskId && !normalizedApprovalId) {
return undefined;
}
return items.find(
(item): item is ChatTimelineToolItem =>
item.type === 'tool' &&
((Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
((Boolean(normalizedApprovalId) &&
item.approval?.approvalId === normalizedApprovalId) ||
(Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
(item.toolCallId ? identities.has(item.toolCallId) : false)),
);
}
@@ -211,9 +247,33 @@ function doneStatusLabel(item: ChatTimelineStatusItem) {
return item.label.replace(/^正在/, '已');
}
function finishRunningStatusItems(items: ChatTimelineItem[]) {
function skillTerminalLabel(
label: string,
status: Extract<ChatTimelineStatusStatus, 'cancelled' | 'incomplete'>,
) {
const name = label.replace(/^正在调用\s*/, '').trim() || '技能';
return status === 'cancelled' ? `已停止调用 ${name}` : `调用 ${name} 未完成`;
}
function finishRunningStatusItems(
items: ChatTimelineItem[],
roundId?: string,
skillTerminalStatus: Extract<
ChatTimelineStatusStatus,
'cancelled' | 'incomplete'
> = 'incomplete',
) {
items.forEach((item) => {
if (item.type !== 'status' || item.status !== 'running') {
if (
item.type !== 'status' ||
item.status !== 'running' ||
(roundId && item.roundId !== roundId)
) {
return;
}
if (item.icon === 'skill') {
item.status = skillTerminalStatus;
item.label = skillTerminalLabel(item.label, skillTerminalStatus);
return;
}
item.status = 'done';
@@ -221,9 +281,36 @@ function finishRunningStatusItems(items: ChatTimelineItem[]) {
});
}
function skillStatusPresentation(status: ChatTimelineSkillInvocationStatus) {
switch (status) {
case 'CANCELLED': {
return {
prefix: '已停止调用',
status: 'cancelled' as const,
};
}
case 'FAILED': {
return { prefix: '调用', status: 'error' as const, suffix: '失败' };
}
case 'INCOMPLETE': {
return {
prefix: '调用',
status: 'incomplete' as const,
suffix: '未完成',
};
}
case 'RUNNING': {
return { prefix: '正在调用', status: 'running' as const };
}
case 'SUCCESS': {
return { prefix: '已调用', status: 'done' as const };
}
}
}
function upsertStatus(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
label: string;
presentation?: ChatTimelineStatusItem['presentation'];
status: ChatTimelineStatusStatus;
@@ -237,6 +324,7 @@ function upsertStatus(
found.presentation = payload.presentation ?? found.presentation;
found.status = payload.status;
found.tone = payload.tone ?? found.tone;
applyTurnMetadata(found, payload);
return found;
}
const item: ChatTimelineStatusItem = {
@@ -249,20 +337,20 @@ function upsertStatus(
tone: payload.tone ?? 'muted',
type: 'status',
};
applyTurnMetadata(item, payload);
items.push(item);
return item;
}
function upsertTool(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
approval?: ChatTimelineToolApprovalPayload;
approvalId?: string;
input?: unknown;
mode?: ChatTimelineToolMode;
output?: unknown;
rejectReason?: string;
requestId?: string;
resumeToken?: string;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus;
taskId?: string;
@@ -279,6 +367,7 @@ function upsertTool(
toolCallId,
taskId,
payload.sourceToolCallId,
payload.approvalId ?? payload.approval?.approvalId,
);
const approval = payload.approval ?? found?.approval;
const mode =
@@ -310,6 +399,7 @@ function upsertTool(
found.taskId = taskId || found.taskId;
found.toolCallId = toolCallId || found.toolCallId;
found.toolName = toolName || found.toolName;
applyTurnMetadata(found, payload);
return found;
}
@@ -330,11 +420,49 @@ function upsertTool(
toolName: toolName || '工具调用',
type: 'tool',
};
applyTurnMetadata(toolItem, payload);
items.push(toolItem);
return toolItem;
}
function applyTurnMetadata(
item: ChatTimelineItemBase,
metadata?: ChatTimelineTurnMetadata,
) {
if (!metadata) {
return;
}
if (metadata.roundId) {
item.roundId = metadata.roundId;
}
if (metadata.roundCompleted !== undefined) {
item.roundCompleted = metadata.roundCompleted;
}
if (metadata.turnSucceeded !== undefined) {
item.turnSucceeded = metadata.turnSucceeded;
}
if (metadata.turnStartedAt !== undefined) {
item.turnStartedAt = Math.min(
item.turnStartedAt ?? metadata.turnStartedAt,
metadata.turnStartedAt,
);
}
if (metadata.turnFinishedAt !== undefined) {
item.turnFinishedAt = Math.max(
item.turnFinishedAt ?? metadata.turnFinishedAt,
metadata.turnFinishedAt,
);
}
}
export const ChatTimelineBuilder = {
ensureAssistantTurn(
items: ChatTimelineItem[],
metadata?: Partial<ChatTimelineMessageItem>,
) {
ensureMessageTail(items, 'assistant', 'streaming', metadata);
},
appendUserMessage(
items: ChatTimelineItem[],
content?: unknown,
@@ -410,12 +538,45 @@ export const ChatTimelineBuilder = {
appendTextPart(message, text);
},
replaceMessageContent(items: ChatTimelineItem[], content?: unknown) {
replaceMessageContent(
items: ChatTimelineItem[],
content?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(content);
if (!text) {
return;
const message =
(metadata?.id
? items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.id === metadata.id,
)
: undefined) ||
[...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
) ||
ensureMessageTail(items, 'assistant', 'done', metadata);
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index];
if (
item === message ||
item?.type !== 'message' ||
item.role !== 'assistant' ||
(metadata?.roundId && item.roundId !== metadata.roundId)
) {
continue;
}
item.parts = item.parts.filter((part) => part.type !== 'text');
if (item.parts.length === 0) {
items.splice(index, 1);
}
}
const message = ensureMessageTail(items, 'assistant', 'done');
Object.assign(message, metadata);
message.status = 'done';
updateThinkingStatus(message, 'end');
replaceTextPart(message, text);
},
@@ -423,8 +584,10 @@ export const ChatTimelineBuilder = {
appendToolApproval(
items: ChatTimelineItem[],
payload: ChatTimelineToolApprovalPayload,
metadata?: ChatTimelineTurnMetadata,
) {
upsertTool(items, {
...metadata,
approval: payload,
input: payload.input,
mode: 'approval',
@@ -436,7 +599,8 @@ export const ChatTimelineBuilder = {
upsertToolCall(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
input?: unknown;
output?: unknown;
sourceToolCallId?: string;
@@ -452,6 +616,7 @@ export const ChatTimelineBuilder = {
items,
payload.status === 'success' ? 'done' : 'running',
payload.statusKey,
payload,
);
return;
}
@@ -466,9 +631,11 @@ export const ChatTimelineBuilder = {
items: ChatTimelineItem[],
status: ChatTimelineStatusStatus,
statusKey?: string,
metadata?: ChatTimelineTurnMetadata,
) {
finishAssistantMessage(items, false);
finishAssistantMessage(items, false, metadata?.roundId);
upsertStatus(items, {
...metadata,
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
status,
statusKey: knowledgeRetrievalStatusKey(statusKey),
@@ -476,9 +643,32 @@ export const ChatTimelineBuilder = {
});
},
upsertSkillInvocationStatus(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
displayName?: string;
status: ChatTimelineSkillInvocationStatus;
statusKey: string;
},
) {
const displayName = normalizeText(payload.displayName).trim() || '技能';
const presentation = skillStatusPresentation(payload.status);
const label = [presentation.prefix, displayName, presentation.suffix]
.filter(Boolean)
.join(' ');
finishAssistantMessage(items, false, payload.roundId);
upsertStatus(items, {
...payload,
label,
status: presentation.status,
statusKey: payload.statusKey,
tone: payload.status === 'FAILED' ? 'danger' : 'muted',
}).icon = 'skill';
},
upsertMemoryCompressionStatus(
items: ChatTimelineItem[],
payload?: {
payload?: ChatTimelineTurnMetadata & {
compressed?: boolean;
label?: string;
phase?: string;
@@ -491,7 +681,7 @@ export const ChatTimelineBuilder = {
? 'done'
: 'running';
const statusKey = payload?.statusKey || 'memory-compression';
finishAssistantMessage(items, false);
finishAssistantMessage(items, false, payload?.roundId);
if (status === 'done' && payload?.compressed === false) {
removeStatusItem(items, statusKey);
return;
@@ -501,6 +691,7 @@ export const ChatTimelineBuilder = {
? payload?.label || '正在整理上下文'
: payload?.label || '已整理上下文';
upsertStatus(items, {
...payload,
label,
status,
statusKey,
@@ -511,9 +702,8 @@ export const ChatTimelineBuilder = {
markToolApproving(
items: ChatTimelineItem[],
payload: {
requestId?: string;
resumeToken?: string;
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
toolCallId?: string;
},
) {
@@ -526,10 +716,9 @@ export const ChatTimelineBuilder = {
markToolRejected(
items: ChatTimelineItem[],
payload: {
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
reason?: string;
requestId?: string;
resumeToken?: string;
toolCallId?: string;
},
) {
@@ -544,6 +733,7 @@ export const ChatTimelineBuilder = {
appendKnowledge(
items: ChatTimelineItem[],
knowledgeItems: ChatTimelineKnowledgeHit[],
metadata?: ChatTimelineTurnMetadata,
) {
if (knowledgeItems.length === 0) {
return;
@@ -552,9 +742,12 @@ export const ChatTimelineBuilder = {
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
);
if (lastAssistantMessage) {
applyTurnMetadata(lastAssistantMessage, metadata);
lastAssistantMessage.knowledgeItems = [
...(lastAssistantMessage.knowledgeItems || []),
...knowledgeItems,
@@ -562,36 +755,119 @@ export const ChatTimelineBuilder = {
return;
}
const last = items[items.length - 1];
if (last?.type === 'knowledge') {
if (
last?.type === 'knowledge' &&
(!metadata?.roundId || last.roundId === metadata.roundId)
) {
applyTurnMetadata(last, metadata);
last.items.push(...knowledgeItems);
return;
}
items.push({
const item = {
id: createId('knowledge'),
createdAt: Date.now(),
items: knowledgeItems,
type: 'knowledge',
});
type: 'knowledge' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
appendError(items: ChatTimelineItem[], message?: unknown) {
upsertArtifact(
items: ChatTimelineItem[],
artifact: ChatArtifactAttachment,
metadata?: ChatTimelineTurnMetadata,
) {
const artifactId = normalizeText(artifact.artifactId).trim();
const fileName = normalizeText(artifact.fileName).trim();
if (!artifactId || !fileName) {
return;
}
const existing = items.find(
(item): item is ChatTimelineArtifactItem =>
item.type === 'artifact' && item.artifactId === artifactId,
);
if (existing) {
existing.downloadUrl = artifact.downloadUrl;
existing.fileName = fileName;
existing.mimeType = artifact.mimeType;
existing.sha256 = artifact.sha256;
existing.size = artifact.size;
existing.status = artifact.status;
applyTurnMetadata(existing, metadata);
return;
}
const item = {
artifactId,
createdAt: Date.now(),
downloadUrl: artifact.downloadUrl,
fileName,
id: `artifact:${artifactId}`,
mimeType: artifact.mimeType,
sha256: artifact.sha256,
size: artifact.size,
status: artifact.status,
type: 'artifact' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
appendError(
items: ChatTimelineItem[],
message?: unknown,
metadata?: ChatTimelineTurnMetadata,
) {
const text = normalizeText(message) || '请求失败';
const last = items[items.length - 1];
if (last?.type === 'message' && last.role === 'assistant') {
const last = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
);
if (last) {
updateThinkingStatus(last, 'error');
last.status = 'error';
}
items.push({
const item = {
id: createId('error'),
createdAt: Date.now(),
message: text,
type: 'error',
});
type: 'error' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
finalize(items: ChatTimelineItem[]) {
finishRunningStatusItems(items);
finishLastAssistantMessage(items);
finalize(
items: ChatTimelineItem[],
metadata?: ChatTimelineTurnMetadata,
options?: {
runningSkillStatus?: Extract<
ChatTimelineStatusStatus,
'cancelled' | 'incomplete'
>;
},
) {
finishRunningStatusItems(
items,
metadata?.roundId,
options?.runningSkillStatus,
);
finishAssistantMessage(
items,
metadata?.turnSucceeded ?? true,
metadata?.roundId,
);
if (metadata?.roundId) {
for (const item of items) {
if (item.roundId === metadata.roundId) {
applyTurnMetadata(item, metadata);
}
}
}
},
replaceRoundAssistant(

View File

@@ -1,5 +1,6 @@
export { defaultAssistantAvatar } from './assistantAvatar';
export { ChatTimelineBuilder } from './builder';
export { default as ChatArtifactCard } from './ChatArtifactAttachment.vue';
export { default as ChatAssistantAvatar } from './ChatAssistantAvatar.vue';
export { default as ChatDocumentAttachments } from './ChatDocumentAttachments.vue';
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
@@ -14,10 +15,14 @@ export { default as ChatToolApprovalCard } from './ChatToolApprovalCard.vue';
export { default as ChatToolCard } from './ChatToolCard.vue';
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
export type {
ChatArtifactAttachment,
ChatArtifactLoader,
ChatArtifactStatus,
ChatDocumentAttachment,
ChatDocumentLoader,
ChatImageAttachment,
ChatImageLoader,
ChatTimelineArtifactItem,
ChatTimelineCustomItem,
ChatTimelineErrorItem,
ChatTimelineItem,
@@ -27,6 +32,7 @@ export type {
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineRole,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,

View File

@@ -9,8 +9,38 @@ export type ChatTimelineToolStatus =
| 'rejected'
| 'running'
| 'success';
export type ChatTimelineStatusStatus = 'done' | 'running';
export type ChatTimelineStatusTone = 'muted';
export type ChatTimelineStatusStatus =
| 'cancelled'
| 'done'
| 'error'
| 'incomplete'
| 'running';
export type ChatTimelineStatusTone = 'danger' | 'muted';
export type ChatTimelineSkillInvocationStatus =
| 'CANCELLED'
| 'FAILED'
| 'INCOMPLETE'
| 'RUNNING'
| 'SUCCESS';
export type ChatArtifactStatus =
| 'available'
| 'delete_failed'
| 'expired'
| 'unavailable';
export interface ChatArtifactAttachment {
artifactId: string;
downloadUrl?: string;
fileName: string;
mimeType?: string;
sha256?: string;
size?: number;
status: ChatArtifactStatus;
}
export type ChatArtifactLoader = (
artifact: ChatArtifactAttachment,
) => Promise<void>;
export interface ChatImageAttachment {
error?: string;
@@ -47,8 +77,7 @@ export type ChatDocumentLoader = (
) => Promise<void>;
export interface ChatTimelineToolApprovalPayload {
requestId: string;
resumeToken: string;
approvalId: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;
@@ -81,6 +110,11 @@ export interface ChatTimelineKnowledgeHit {
export interface ChatTimelineItemBase {
createdAt?: number;
id: string;
roundCompleted?: boolean;
roundId?: string;
turnFinishedAt?: number;
turnStartedAt?: number;
turnSucceeded?: boolean;
}
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
@@ -90,8 +124,6 @@ export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
parts: ChatTimelineMessagePart[];
regenerable?: boolean;
role: ChatTimelineRole;
roundId?: string;
roundCompleted?: boolean;
roundNo?: number;
status?: ChatTimelineItemStatus;
selectedVariantIndex?: number;
@@ -127,8 +159,14 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
type: 'knowledge';
}
export interface ChatTimelineArtifactItem
extends ChatArtifactAttachment,
ChatTimelineItemBase {
type: 'artifact';
}
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
icon?: 'book' | 'none';
icon?: 'book' | 'none' | 'skill';
label: string;
presentation?: 'inline' | 'separator';
status: ChatTimelineStatusStatus;
@@ -149,6 +187,7 @@ export interface ChatTimelineCustomItem extends ChatTimelineItemBase {
}
export type ChatTimelineItem =
| ChatTimelineArtifactItem
| ChatTimelineCustomItem
| ChatTimelineErrorItem
| ChatTimelineKnowledgeItem

View File

@@ -589,6 +589,9 @@ importers:
app:
dependencies:
'@ag-ui/client':
specifier: 0.0.57
version: 0.0.57
'@codemirror/commands':
specifier: ^6.10.2
version: 6.10.2
@@ -1862,6 +1865,18 @@ importers:
packages:
'@ag-ui/client@0.0.57':
resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==}
'@ag-ui/core@0.0.57':
resolution: {integrity: sha512-gho1OWjNE6E3Rl7ZEZ1wr2CEpUHjLFU0FqzCZZk439TicLu+BfLCMkMokB07bMGlRmbJ60hM6LW60iOVauCx+Q==}
'@ag-ui/encoder@0.0.57':
resolution: {integrity: sha512-ifD9NctR4xyPDR58xF9GK1bj/S8oECFkTeDfuYD8tXdbcOstIJ2TOqU2zhiCKnw7Vw+zR9Qv3TbsM9E7Gi9X3Q==}
'@ag-ui/proto@0.0.57':
resolution: {integrity: sha512-pPENOZt0P6ibH8sCTgq05wLYXi5t3P9B5r/1bWYehXjUxtyOdnukSlWM++SsCIwUXsQdm/b3aBgGjEeTF7RenA==}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -2482,6 +2497,9 @@ packages:
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
'@bufbuild/protobuf@2.14.0':
resolution: {integrity: sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==}
'@cacheable/memoize@2.0.3':
resolution: {integrity: sha512-hl9wfQgpiydhQEIv7fkjEzTGE+tcosCXLKFDO707wYJ/78FVOlowb36djex5GdbSyeHnG62pomYLMuV/OT8Pbw==}
@@ -3997,6 +4015,10 @@ packages:
'@poppinss/exception@1.2.2':
resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==}
'@protobuf-ts/protoc@2.11.1':
resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==}
hasBin: true
'@publint/pack@0.1.2':
resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==}
engines: {node: '>=18'}
@@ -4616,6 +4638,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
@@ -6914,6 +6939,9 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-json-patch@3.1.1:
resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -9920,6 +9948,9 @@ packages:
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -10919,6 +10950,9 @@ packages:
uploadthing:
optional: true
untruncate-json@0.0.1:
resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==}
untun@0.1.3:
resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==}
hasBin: true
@@ -10953,6 +10987,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
@@ -11514,6 +11552,34 @@ packages:
snapshots:
'@ag-ui/client@0.0.57':
dependencies:
'@ag-ui/core': 0.0.57
'@ag-ui/encoder': 0.0.57
'@ag-ui/proto': 0.0.57
'@types/uuid': 10.0.0
compare-versions: 6.1.1
fast-json-patch: 3.1.1
rxjs: 7.8.1
untruncate-json: 0.0.1
uuid: 11.1.1
zod: 3.25.76
'@ag-ui/core@0.0.57':
dependencies:
zod: 3.25.76
'@ag-ui/encoder@0.0.57':
dependencies:
'@ag-ui/core': 0.0.57
'@ag-ui/proto': 0.0.57
'@ag-ui/proto@0.0.57':
dependencies:
'@ag-ui/core': 0.0.57
'@bufbuild/protobuf': 2.14.0
'@protobuf-ts/protoc': 2.11.1
'@alloc/quick-lru@5.2.0': {}
'@antfu/install-pkg@1.1.0':
@@ -12278,6 +12344,8 @@ snapshots:
'@braintree/sanitize-url@7.1.2': {}
'@bufbuild/protobuf@2.14.0': {}
'@cacheable/memoize@2.0.3':
dependencies:
'@cacheable/utils': 2.2.0
@@ -14428,6 +14496,8 @@ snapshots:
'@poppinss/exception@1.2.2': {}
'@protobuf-ts/protoc@2.11.1': {}
'@publint/pack@0.1.2': {}
'@rolldown/pluginutils@1.0.0-beta.29': {}
@@ -15087,6 +15157,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/uuid@10.0.0': {}
'@types/web-bluetooth@0.0.16': {}
'@types/web-bluetooth@0.0.21': {}
@@ -17802,6 +17874,8 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
fast-json-patch@3.1.1: {}
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
@@ -21174,6 +21248,10 @@ snapshots:
rw@1.3.3: {}
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -22304,6 +22382,8 @@ snapshots:
db0: 0.3.4
ioredis: 5.8.2
untruncate-json@0.0.1: {}
untun@0.1.3:
dependencies:
citty: 0.1.6
@@ -22356,6 +22436,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@11.1.1: {}
uuid@14.0.0: {}
vee-validate@4.15.1(vue@3.5.24(typescript@5.9.3)):