feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ export interface AiKnowledgeHit {
|
||||
}
|
||||
|
||||
export interface AiToolApprovalPayload {
|
||||
requestId: string;
|
||||
resumeToken: string;
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
toolDisplayName?: string;
|
||||
toolCallId?: string;
|
||||
|
||||
Reference in New Issue
Block a user