feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能 - 增加智能体聊天,并对接持久化
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import type {ChatTimelineMessageItem} from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
applyAgentSseEnvelope,
|
||||
parseAgentSseMessage,
|
||||
recordsToTimelineItems,
|
||||
} from './agentTimelineAdapter';
|
||||
|
||||
describe('agentTimelineAdapter', () => {
|
||||
it('projects history records to chat timeline items', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
id: '1',
|
||||
senderRole: 'user',
|
||||
contentText: '帮我查一下',
|
||||
roundId: 'r1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
senderRole: 'assistant',
|
||||
contentText: '查到了',
|
||||
roundId: 'r1',
|
||||
contentPayload: {
|
||||
agentResult: {
|
||||
reasoning: '先检索',
|
||||
text: '查到了',
|
||||
knowledgeReferences: [
|
||||
{
|
||||
documentName: '手册',
|
||||
chunkContent: '内容片段',
|
||||
},
|
||||
],
|
||||
},
|
||||
chains: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'search',
|
||||
status: 'TOOL_RESULT',
|
||||
arguments: { q: 'EasyFlow' },
|
||||
result: 'ok',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
items.some((item) => item.type === 'message' && item.role === 'user'),
|
||||
).toBe(true);
|
||||
const assistant = items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
);
|
||||
expect(assistant?.parts.some((part) => part.type === 'thinking')).toBe(
|
||||
true,
|
||||
);
|
||||
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('手册');
|
||||
});
|
||||
|
||||
it('keeps stable ids when history has reasoning, tools and final text', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
id: '42',
|
||||
senderRole: 'assistant',
|
||||
contentText: '最终回答',
|
||||
roundId: 'r2',
|
||||
contentPayload: {
|
||||
agentResult: {
|
||||
text: '最终回答',
|
||||
},
|
||||
chains: [
|
||||
{
|
||||
reasoning_content: '先思考',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'search',
|
||||
status: 'TOOL_RESULT',
|
||||
result: 'ok',
|
||||
},
|
||||
],
|
||||
messageChain: [
|
||||
{
|
||||
role: 'assistant',
|
||||
reasoningContent: '中间思考',
|
||||
toolCalls: [{ id: 'tool-2', name: 'search', arguments: '{}' }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
toolCallId: 'tool-2',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const ids = items.map((item) => item.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(
|
||||
items.filter(
|
||||
(item) => item.type === 'message' && item.role === 'assistant',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
items.some((item) => item.type === 'tool' && item.status === 'success'),
|
||||
).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('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 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,
|
||||
{
|
||||
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('准备调用工具');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
|
||||
import type {
|
||||
ChatTimelineItem,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
|
||||
import type {AgentChatMessageRecord} from '../api';
|
||||
|
||||
export interface AgentSseEnvelope {
|
||||
domain: string;
|
||||
payload: Record<string, any>;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, any> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, any>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): any[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asTimestamp(value: unknown) {
|
||||
if (!value) {
|
||||
return Date.now();
|
||||
}
|
||||
const timestamp = new Date(String(value)).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): 'assistant' | 'system' | 'user' {
|
||||
const role = asText(value).toLowerCase();
|
||||
if (role === 'assistant' || role === 'system' || role === 'user') {
|
||||
return role;
|
||||
}
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
function normalizeToolName(value: unknown) {
|
||||
return asText(value).trim();
|
||||
}
|
||||
|
||||
function normalizeToolCallId(payload: Record<string, any>) {
|
||||
return asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id);
|
||||
}
|
||||
|
||||
function normalizeMetadata(record: AgentChatMessageRecord) {
|
||||
return {
|
||||
createdAt: asTimestamp(record.created),
|
||||
id: `history-${record.id || record.roundId || Date.now()}`,
|
||||
roundId: asText(record.roundId),
|
||||
roundNo: record.roundNo,
|
||||
selectedVariantIndex: record.selectedVariantIndex,
|
||||
switchable: false,
|
||||
variantCount: record.variantCount,
|
||||
variantIndex: record.variantIndex,
|
||||
} satisfies Partial<ChatTimelineMessageItem>;
|
||||
}
|
||||
|
||||
function assistantMetadata(
|
||||
record: AgentChatMessageRecord,
|
||||
suffix?: string,
|
||||
): Partial<ChatTimelineMessageItem> {
|
||||
const metadata = normalizeMetadata(record);
|
||||
return suffix ? { ...metadata, id: `${metadata.id}-${suffix}` } : metadata;
|
||||
}
|
||||
|
||||
function normalizeKnowledgeItems(payload: Record<string, any>) {
|
||||
const rawItems =
|
||||
asArray(payload.items).length > 0
|
||||
? asArray(payload.items)
|
||||
: asArray(payload.knowledgeReferences).length > 0
|
||||
? asArray(payload.knowledgeReferences)
|
||||
: asArray(payload.knowledgeCitations);
|
||||
return rawItems
|
||||
.map((item, index): ChatTimelineKnowledgeHit => {
|
||||
const source = asRecord(item);
|
||||
const metadata = asRecord(source.metadata);
|
||||
const documentName = asText(
|
||||
source.documentName ?? source.title ?? metadata.documentName,
|
||||
);
|
||||
const sourceFileName = asText(
|
||||
source.sourceFileName ?? metadata.sourceFileName,
|
||||
);
|
||||
const chunkContent = asText(
|
||||
source.chunkContent ?? source.content ?? source.text ?? source.summary,
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
id: asText(source.id ?? source.chunkId ?? index),
|
||||
chunkContent,
|
||||
content: asText(source.content ?? source.text ?? source.summary),
|
||||
documentId: asText(source.documentId ?? metadata.documentId),
|
||||
documentName,
|
||||
knowledgeId: asText(source.knowledgeId ?? payload.knowledgeId),
|
||||
knowledgeName: asText(source.knowledgeName ?? payload.knowledgeName),
|
||||
metadata,
|
||||
score: source.score ?? source.similarity,
|
||||
sourceFileName,
|
||||
sourceUri: asText(source.sourceUri ?? metadata.sourceUri),
|
||||
title: documentName || sourceFileName || asText(source.source),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.chunkContent || item.title || item.documentName);
|
||||
}
|
||||
|
||||
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,
|
||||
content: unknown,
|
||||
suffix?: string,
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const text = asText(content);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
ChatTimelineBuilder.appendMessageDelta(
|
||||
items,
|
||||
text,
|
||||
{
|
||||
...assistantMetadata(record, suffix),
|
||||
...metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function appendAssistantThinking(
|
||||
items: ChatTimelineItem[],
|
||||
record: AgentChatMessageRecord,
|
||||
content: unknown,
|
||||
suffix?: string,
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const text = asText(content);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
ChatTimelineBuilder.appendThinkingDelta(
|
||||
items,
|
||||
text,
|
||||
{
|
||||
...assistantMetadata(record, suffix),
|
||||
...metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function projectHistoryChain(
|
||||
items: ChatTimelineItem[],
|
||||
record: AgentChatMessageRecord,
|
||||
) {
|
||||
const payload = asRecord(record.contentPayload);
|
||||
let hasAssistantText = false;
|
||||
let hasAssistantThinking = false;
|
||||
const displayChains = asArray(payload.displayChains ?? payload.chains);
|
||||
for (const chain of displayChains) {
|
||||
const item = asRecord(chain);
|
||||
const reasoning = item.reasoningContent ?? item.reasoning_content;
|
||||
if (reasoning) {
|
||||
appendAssistantThinking(items, record, reasoning, 'thinking');
|
||||
hasAssistantThinking = true;
|
||||
continue;
|
||||
}
|
||||
const toolName = normalizeToolName(item.name ?? item.toolName);
|
||||
if (toolName) {
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: item.arguments ?? item.input,
|
||||
output: item.result ?? item.output,
|
||||
status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running',
|
||||
toolCallId: asText(item.id ?? item.toolCallId),
|
||||
toolName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const messageChain = asArray(payload.messageChain);
|
||||
for (const chain of messageChain) {
|
||||
const item = asRecord(chain);
|
||||
const role = asText(item.role).toLowerCase();
|
||||
if (role === 'assistant') {
|
||||
appendAssistantThinking(items, record, item.reasoningContent, 'thinking');
|
||||
if (item.reasoningContent) {
|
||||
hasAssistantThinking = true;
|
||||
}
|
||||
if (!payload.agentResult && item.content) {
|
||||
appendAssistantText(items, record, item.content, 'text');
|
||||
hasAssistantText = true;
|
||||
}
|
||||
for (const toolCall of asArray(item.toolCalls)) {
|
||||
const tool = asRecord(toolCall);
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: tool.arguments ?? tool.input,
|
||||
status: 'running',
|
||||
toolCallId: asText(tool.id ?? tool.toolCallId),
|
||||
toolName: normalizeToolName(tool.name ?? tool.toolName),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (role === 'tool') {
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
output: item.content ?? item.result,
|
||||
status: 'success',
|
||||
toolCallId: asText(item.toolCallId ?? item.id),
|
||||
toolName: normalizeToolName(item.name ?? item.toolName) || '工具调用',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
hasAssistantText,
|
||||
hasAssistantThinking,
|
||||
};
|
||||
}
|
||||
|
||||
function appendHistoryRecord(
|
||||
items: ChatTimelineItem[],
|
||||
record: AgentChatMessageRecord,
|
||||
) {
|
||||
const role = normalizeRole(record.senderRole);
|
||||
const metadata = normalizeMetadata(record);
|
||||
if (role === 'user') {
|
||||
ChatTimelineBuilder.appendUserMessage(items, record.contentText, metadata);
|
||||
return;
|
||||
}
|
||||
if (role === 'system') {
|
||||
ChatTimelineBuilder.appendError(items, record.contentText || '系统消息');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = asRecord(record.contentPayload);
|
||||
const agentResult = asRecord(payload.agentResult);
|
||||
const chainProjection = projectHistoryChain(items, record);
|
||||
if (!chainProjection.hasAssistantThinking) {
|
||||
appendAssistantThinking(
|
||||
items,
|
||||
record,
|
||||
payload.reasoningContent ?? agentResult.reasoning,
|
||||
'thinking',
|
||||
);
|
||||
}
|
||||
if (!chainProjection.hasAssistantText) {
|
||||
appendAssistantText(
|
||||
items,
|
||||
record,
|
||||
agentResult.text ?? payload.content ?? record.contentText,
|
||||
chainProjection.hasAssistantThinking ? 'text' : undefined,
|
||||
);
|
||||
}
|
||||
const knowledgeItems = normalizeKnowledgeItems({
|
||||
...payload,
|
||||
items:
|
||||
payload.knowledgeCitations ??
|
||||
agentResult.knowledgeReferences ??
|
||||
payload.knowledgeReferences,
|
||||
});
|
||||
if (knowledgeItems.length > 0) {
|
||||
ChatTimelineBuilder.appendKnowledge(items, knowledgeItems);
|
||||
}
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
}
|
||||
|
||||
export function recordsToTimelineItems(records: AgentChatMessageRecord[] = []) {
|
||||
const items: ChatTimelineItem[] = [];
|
||||
for (const record of records) {
|
||||
appendHistoryRecord(items, record);
|
||||
}
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
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')) {
|
||||
ChatTimelineBuilder.upsertToolCall(items, {
|
||||
input: payload.input ?? payload.toolInput,
|
||||
output: payload.output ?? payload.result ?? payload.text,
|
||||
status: type === 'TOOL_RESULT' ? 'success' : 'running',
|
||||
statusKey: asText(payload.statusKey) || undefined,
|
||||
toolCallId: normalizeToolCallId(payload),
|
||||
toolName: normalizeToolName(
|
||||
payload.toolDisplayName ?? payload.toolName ?? payload.name,
|
||||
),
|
||||
});
|
||||
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: asText(payload.statusKey),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (asText(payload.statusKey) === 'knowledge-retrieval') {
|
||||
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
||||
items,
|
||||
asText(payload.status) === 'running' ? 'running' : 'done',
|
||||
asText(payload.statusKey),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (domain === 'SYSTEM' && type === 'DONE') {
|
||||
ChatTimelineBuilder.finalize(items);
|
||||
return;
|
||||
}
|
||||
if (domain === 'ERROR' || type === 'ERROR') {
|
||||
ChatTimelineBuilder.appendError(
|
||||
items,
|
||||
payload.message ?? payload.error ?? '请求失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type {ChatTimelineItem} from '@easyflow/common-ui';
|
||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||
|
||||
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
|
||||
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
|
||||
|
||||
interface RuntimeSessionState {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
items: ChatTimelineItem[];
|
||||
prompt: string;
|
||||
roundId: string;
|
||||
sending: boolean;
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface StoredRuntimeSession {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
items: ChatTimelineItem[];
|
||||
prompt: string;
|
||||
roundId: string;
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
baseItems?: ChatTimelineItem[];
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
|
||||
const LATEST_STORAGE_KEY = `${STORAGE_PREFIX}:latest`;
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
const sessions = new Map<string, RuntimeSessionState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let latestSessionId = '';
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function createRoundId() {
|
||||
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function storageKey(sessionId: string) {
|
||||
return `${STORAGE_PREFIX}:${sessionId}`;
|
||||
}
|
||||
|
||||
function safeSessionStorage() {
|
||||
try {
|
||||
return globalThis.sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function persistSession(state: RuntimeSessionState) {
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
const snapshot: StoredRuntimeSession = {
|
||||
agentId: state.agentId,
|
||||
agentName: state.agentName,
|
||||
completed: state.completed,
|
||||
error: state.error,
|
||||
items: clone(state.items),
|
||||
prompt: state.prompt,
|
||||
roundId: state.roundId,
|
||||
sessionId: state.sessionId,
|
||||
updatedAt: state.updatedAt,
|
||||
version: STORAGE_VERSION,
|
||||
};
|
||||
try {
|
||||
storage.setItem(storageKey(state.sessionId), JSON.stringify(snapshot));
|
||||
storage.setItem(LATEST_STORAGE_KEY, state.sessionId);
|
||||
} catch {
|
||||
// 缓存失败不影响正式聊天主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSession(sessionId: string) {
|
||||
const existing = sessions.get(sessionId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(storageKey(sessionId));
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as StoredRuntimeSession;
|
||||
if (parsed.version !== STORAGE_VERSION || parsed.sessionId !== sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const restored: RuntimeSessionState = {
|
||||
agentId: parsed.agentId,
|
||||
agentName: parsed.agentName,
|
||||
completed: parsed.completed,
|
||||
error: parsed.error,
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
prompt: parsed.prompt,
|
||||
roundId: parsed.roundId,
|
||||
sending: false,
|
||||
sessionId,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
sessions.set(sessionId, restored);
|
||||
return restored;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function upsertState(state: RuntimeSessionState) {
|
||||
state.updatedAt = Date.now();
|
||||
latestSessionId = state.sessionId;
|
||||
sessions.set(state.sessionId, state);
|
||||
persistSession(state);
|
||||
notify();
|
||||
}
|
||||
|
||||
function runningSession() {
|
||||
return [...sessions.values()].find((session) => session.sending);
|
||||
}
|
||||
|
||||
function restoreLatestSession() {
|
||||
const running = runningSession();
|
||||
if (running) {
|
||||
return running;
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
const storedSessionId = storage?.getItem(LATEST_STORAGE_KEY) || '';
|
||||
const sessionId = latestSessionId || storedSessionId;
|
||||
return sessionId ? restoreSession(sessionId) : undefined;
|
||||
}
|
||||
|
||||
async function resolveSessionId(sessionId?: string) {
|
||||
if (sessionId) {
|
||||
return sessionId;
|
||||
}
|
||||
const res = await generateAgentSessionId();
|
||||
if (res.errorCode !== 0 || !res.data) {
|
||||
throw new Error(res.message || '会话创建失败');
|
||||
}
|
||||
return String(res.data);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '发送失败,请稍后再试';
|
||||
}
|
||||
|
||||
export const agentChatRuntimeManager = {
|
||||
getSnapshot(sessionId?: string) {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const state = restoreSession(sessionId);
|
||||
return state ? clone(state) : undefined;
|
||||
},
|
||||
|
||||
getLatestSnapshot() {
|
||||
const state = restoreLatestSession();
|
||||
return state ? clone(state) : undefined;
|
||||
},
|
||||
|
||||
hasRunning() {
|
||||
return Boolean(runningSession());
|
||||
},
|
||||
|
||||
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
|
||||
const state = restoreSession(sessionId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.items = clone(items);
|
||||
upsertState(state);
|
||||
},
|
||||
|
||||
async start(options: StartOptions) {
|
||||
const active = runningSession();
|
||||
if (active) {
|
||||
throw new Error('当前回复完成后再发送新消息');
|
||||
}
|
||||
const sessionId = await resolveSessionId(options.sessionId);
|
||||
const roundId = createRoundId();
|
||||
const state: RuntimeSessionState = {
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
completed: false,
|
||||
items: clone(options.baseItems || []),
|
||||
prompt: options.prompt,
|
||||
roundId,
|
||||
sending: true,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
||||
roundId,
|
||||
});
|
||||
upsertState(state);
|
||||
|
||||
void sendAgentChat(
|
||||
{
|
||||
agentId: options.agentId,
|
||||
prompt: options.prompt,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
const current = sessions.get(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);
|
||||
},
|
||||
onFinished() {
|
||||
const current = sessions.get(sessionId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.sending = false;
|
||||
current.completed = true;
|
||||
ChatTimelineBuilder.finalize(current.items);
|
||||
upsertState(current);
|
||||
},
|
||||
onMessage(message) {
|
||||
const current = sessions.get(sessionId);
|
||||
if (!current || !current.sending) {
|
||||
return;
|
||||
}
|
||||
const envelope = parseAgentSseMessage(message);
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
||||
upsertState(current);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
stop(sessionId?: string) {
|
||||
const state = sessionId ? restoreSession(sessionId) : runningSession();
|
||||
if (!state || !state.sending) {
|
||||
return;
|
||||
}
|
||||
stopAgentChatStream();
|
||||
state.sending = false;
|
||||
state.completed = true;
|
||||
ChatTimelineBuilder.finalize(state.items);
|
||||
upsertState(state);
|
||||
},
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
146
easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts
Normal file
146
easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||||
|
||||
import type {AgentInfo} from '../agents/types';
|
||||
|
||||
import {api, SseClient} from '#/api/request';
|
||||
|
||||
const agentChatSseClient = new SseClient();
|
||||
|
||||
export interface RequestResult<T = any> {
|
||||
data: T;
|
||||
errorCode: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AgentChatSessionView {
|
||||
accessAt?: string;
|
||||
assistantCode?: string;
|
||||
assistantId?: number | string;
|
||||
assistantName?: string;
|
||||
continuable?: boolean;
|
||||
lastMessageAt?: string;
|
||||
lastMessagePreview?: string;
|
||||
messageCount?: number;
|
||||
readOnlyReason?: unknown;
|
||||
sessionId?: number | string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AgentChatSessionPage {
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
records?: AgentChatSessionView[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface AgentChatMessageRecord {
|
||||
assistantId?: number | string;
|
||||
contentPayload?: Record<string, any>;
|
||||
contentText?: string;
|
||||
contentType?: string;
|
||||
created?: string;
|
||||
id?: number | string;
|
||||
messageKind?: string;
|
||||
roundId?: number | string;
|
||||
roundNo?: number;
|
||||
selectedVariantIndex?: number;
|
||||
senderName?: string;
|
||||
senderRole?: string;
|
||||
sessionId?: number | string;
|
||||
switchable?: boolean;
|
||||
variantCount?: number;
|
||||
variantIndex?: number;
|
||||
}
|
||||
|
||||
export interface AgentChatConversationView {
|
||||
records?: AgentChatMessageRecord[];
|
||||
total?: number;
|
||||
variantsByRound?: Record<string, AgentChatMessageRecord[]>;
|
||||
}
|
||||
|
||||
export function getPublishedAgents() {
|
||||
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', {
|
||||
params: { publishedOnly: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateAgentSessionId() {
|
||||
return api.get<RequestResult<string>>('/api/v1/agent/session/generateId');
|
||||
}
|
||||
|
||||
export function getAgentSession(sessionId: number | string) {
|
||||
return api.get<RequestResult<AgentChatSessionView>>(
|
||||
`/api/v1/agent/session/${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentSessions(params?: {
|
||||
agentId?: number | string;
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return api.get<RequestResult<AgentChatSessionPage>>(
|
||||
'/api/v1/agent/session/list',
|
||||
{
|
||||
params: {
|
||||
pageNumber: params?.pageNumber ?? 1,
|
||||
pageSize: params?.pageSize ?? 50,
|
||||
...(params?.agentId ? { agentId: params.agentId } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentConversation(sessionId: number | string) {
|
||||
return api.get<RequestResult<AgentChatConversationView>>(
|
||||
`/api/v1/agent/session/${sessionId}/conversation`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renameAgentSession(sessionId: number | string, title: string) {
|
||||
return api.post<RequestResult>(`/api/v1/agent/session/${sessionId}/rename`, {
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
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 rejectAgentRun(
|
||||
requestId: string,
|
||||
resumeToken: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return api.post<RequestResult>('/api/v1/agent/run/reject', {
|
||||
requestId,
|
||||
resumeToken,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendAgentChat(
|
||||
data: {
|
||||
agentId: number | 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();
|
||||
}
|
||||
1027
easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue
Normal file
1027
easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user