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

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