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

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