Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue
陈子默 4e8640dcaf feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

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

- 增加受控工作区、内置工具和私有 Artifact 生命周期
2026-08-19 22:13:41 +08:00

445 lines
12 KiB
Vue

<script setup lang="ts">
import type {
ChatDocumentAttachment,
ChatImageAttachment,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from '@easyflow/common-ui';
import type {
AgentInfo,
AgentKnowledgeBinding,
AgentSkillBinding,
AgentToolBinding,
} from '../types';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
CHAT_DOCUMENT_SUPPORTED_FORMATS,
ChatTimeline,
defaultAssistantAvatar,
} from '@easyflow/common-ui';
import { BrushCleaning } from '@easyflow/icons';
import { copyTextToClipboard } from '@easyflow/utils';
import { ElButton, ElMessage } from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
import {
createAgentArtifactLoader,
loadAgentChatDocument,
loadAgentChatImage,
} from '#/components/ai-chat/mediaApi';
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
import { CHAT_IMAGE_SUPPORTED_FORMATS } from '#/components/ai-chat/useChatImageUploads';
import { approveAgentRun, rejectAgentRun } from '../api';
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
import { resolveInteractionDisplay } from '../interaction-config';
import AgentWelcomeState from './AgentWelcomeState.vue';
const props = defineProps<{
agent: AgentInfo;
imageEnabled?: boolean;
knowledgeBindings: AgentKnowledgeBinding[];
skillBindings: AgentSkillBinding[];
toolBindings: AgentToolBinding[];
}>();
const emit = defineEmits<{ close: [] }>();
const {
loading,
clearDraftSession,
dispose,
markToolApproving,
markToolRejected,
copyMessageText,
selectVariant,
syncDraftContext,
timelineItems,
sendDraft,
stop,
} = 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),
);
function getDraftContext() {
return {
agent: props.agent,
skillBindings: props.skillBindings,
toolBindings: props.toolBindings,
knowledgeBindings: props.knowledgeBindings,
};
}
function syncCurrentDraftContext(restore = false) {
syncDraftContext(getDraftContext(), restore, composer.sessionId.value);
}
async function activateComposer() {
const agentId = String(props.agent.id || '');
if (!agentId) return;
try {
await composer.activate(agentId, `agent-draft-${agentId}`);
syncCurrentDraftContext(true);
} catch (error) {
ElMessage.warning(
error instanceof Error ? error.message : '输入草稿恢复失败',
);
}
}
onMounted(() => void activateComposer());
onBeforeUnmount(() => {
dispose();
});
watch(
() => [props.agent.id, props.agent.localId],
() => void activateComposer(),
);
watch(
() => [
props.agent,
props.knowledgeBindings,
props.skillBindings,
props.toolBindings,
],
() => {
syncCurrentDraftContext();
},
{ deep: true },
);
async function handleSend(prompt: string) {
if (loading.value || approvalLoading.value) return;
if (composer.images.uploading.value) {
ElMessage.warning('图片上传完成后再发送');
return;
}
if (composer.documents.processing.value) {
ElMessage.warning('文档读取完成后再发送');
return;
}
const failedImage = composer.images.items.value.find(
(item) => item.status === 'error',
);
if (failedImage) {
ElMessage.error(failedImage.error || '请处理上传失败的图片');
return;
}
const failedDocument = composer.documents.items.value.find(
(item) => item.status === 'error',
);
if (failedDocument) {
ElMessage.error(failedDocument.error || '请处理读取失败的文档');
return;
}
await composer.flush();
await sendDraft({
...getDraftContext(),
documentUploadIds: composer.documents.uploadIds.value,
documents: composer.documents.readyItems.value.map((item) => ({
...item,
})),
prompt,
imageUploadIds: composer.images.uploadIds.value,
images: composer.images.readyItems.value.map((item) => ({ ...item })),
sessionId: composer.sessionId.value,
onAccepted: () =>
composer.markAccepted().catch(() => {
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
}),
});
}
function handleDraftTextInput() {
composer.scheduleSave();
}
function handleSuggestedQuestion(question: string) {
void handleSend(question);
}
function canCopyMessage(item: ChatTimelineMessageItem) {
if (item.role === 'user') {
return Boolean(copyMessageText(item).trim());
}
return Boolean(
item.roundId && item.roundCompleted && copyMessageText(item).trim(),
);
}
function canRegenerateMessage(item: ChatTimelineMessageItem) {
return (
item.role === 'assistant' &&
Boolean(item.roundId) &&
Boolean(item.roundCompleted)
);
}
async function handleCopyMessage(item: ChatTimelineMessageItem) {
const text = copyMessageText(item).trim();
if (!text) {
return false;
}
try {
await copyTextToClipboard(text);
return true;
} catch (error) {
console.error('复制消息失败:', error);
ElMessage.error('复制失败');
return false;
}
}
async function handleRegenerateMessage(item: ChatTimelineMessageItem) {
void item;
}
function handleSelectPreviousVariant(item: ChatTimelineMessageItem) {
selectVariant(item, 'previous');
}
function handleSelectNextVariant(item: ChatTimelineMessageItem) {
selectVariant(item, 'next');
}
async function handleClearSession() {
try {
await clearDraftSession();
await composer.clear();
await activateComposer();
ElMessage.success('已清理会话');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
}
}
async function handleAddImageFiles(files: File[]) {
if (!props.agent.id) {
ElMessage.warning('请先保存智能体');
return;
}
await composer.ensureSession();
const { rejectedCount, unsupportedCount } = await composer.images.addFiles(
files,
{
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
},
);
if (unsupportedCount > 0) {
ElMessage.warning({
grouping: true,
message: `不支持该图片格式,支持:${CHAT_IMAGE_SUPPORTED_FORMATS}`,
});
}
if (rejectedCount > 0) {
ElMessage.warning('每次最多添加 5 张图片');
}
composer.scheduleSave();
}
async function handleAddDocumentFiles(files: File[]) {
if (!props.agent.id) {
ElMessage.warning('请先保存智能体');
return;
}
await composer.ensureSession();
const { rejectedCount, unsupportedCount } = await composer.documents.addFiles(
files,
{
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
},
);
if (unsupportedCount > 0) {
ElMessage.warning({
grouping: true,
message: `不支持该文件格式,支持:${CHAT_DOCUMENT_SUPPORTED_FORMATS}`,
});
}
if (rejectedCount > 0) {
ElMessage.warning('每轮最多添加 3 份文档');
}
composer.scheduleSave();
}
async function handleRetryImage(item: ChatImageAttachment) {
await composer.images.retry(item, {
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
}
async function handleRemoveImage(item: ChatImageAttachment) {
try {
await composer.images.remove(item);
composer.scheduleSave();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
}
}
async function handleRetryDocument(item: ChatDocumentAttachment) {
await composer.documents.retry(item, {
agentId: String(props.agent.id),
mode: 'DRAFT',
sessionId: composer.sessionId.value,
});
composer.scheduleSave();
}
async function handleRemoveDocument(item: ChatDocumentAttachment) {
try {
await composer.documents.remove(item);
composer.scheduleSave();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文档删除失败');
}
}
function handleStop() {
if (!loading.value) {
return;
}
stop();
}
async function handleApprove(payload: ChatTimelineToolApprovalPayload) {
approvalLoading.value = true;
markToolApproving(payload);
try {
const res = await approveAgentRun(payload.approvalId);
if (res.errorCode === 0) {
ElMessage.success('已批准');
}
} catch (error) {
markToolRejected({
...payload,
reason: error instanceof Error ? error.message : '批准失败',
});
throw error;
} finally {
approvalLoading.value = false;
}
}
async function handleReject(payload: ChatTimelineToolApprovalPayload) {
approvalLoading.value = true;
markToolRejected({
...payload,
reason: '用户拒绝执行',
});
try {
await rejectAgentRun(payload.approvalId, '用户拒绝执行');
} finally {
approvalLoading.value = false;
}
}
</script>
<template>
<AiChatPanel
v-model="composer.text.value"
:title="agent.name || '草稿试运行'"
empty-text="输入问题试运行当前智能体"
closable
:messages="[]"
:loading="loading"
:documents="composer.documents.items.value"
:document-loader="loadAgentChatDocument"
:images="composer.images.items.value"
:image-enabled="imageEnabled"
:image-loader="loadAgentChatImage"
:placeholder="interactionDisplay.inputPlaceholder"
:approval-loading="approvalLoading"
@send="handleSend"
@update:model-value="handleDraftTextInput"
@add-files="handleAddImageFiles"
@add-document-files="handleAddDocumentFiles"
@remove-document="handleRemoveDocument"
@remove-image="handleRemoveImage"
@retry-document="handleRetryDocument"
@retry-image="handleRetryImage"
@stop="handleStop"
@approve="handleApprove"
@reject="handleReject"
@close="emit('close')"
>
<template #headerActions>
<ElButton
:icon="BrushCleaning"
circle
text
:disabled="approvalLoading"
aria-label="清理会话"
title="清理会话"
@click="handleClearSession"
/>
</template>
<div class="agent-tryout__conversation">
<AgentWelcomeState
v-if="timelineItems.length === 0"
:agent-name="agent.name || '智能体'"
:avatar="agent.avatar"
:disabled="loading || approvalLoading"
:suggested-questions="interactionDisplay.suggestedQuestions"
:welcome-message="interactionDisplay.welcomeMessage"
@select-question="handleSuggestedQuestion"
/>
<ChatTimeline
v-else
:assistant-avatar="agent.avatar || defaultAssistantAvatar"
:artifact-loader="loadDraftAgentArtifact"
:items="timelineItems"
:document-loader="loadAgentChatDocument"
:image-loader="loadAgentChatImage"
empty-text="输入问题试运行当前智能体"
:approval-loading="approvalLoading"
:copy-action="handleCopyMessage"
:copyable="canCopyMessage"
:regenerable="canRegenerateMessage"
:regenerate-disabled="true"
@approve="handleApprove"
@regenerate-message="handleRegenerateMessage"
@reject="handleReject"
@select-next-variant="handleSelectNextVariant"
@select-previous-variant="handleSelectPreviousVariant"
/>
</div>
</AiChatPanel>
</template>
<style scoped>
.agent-tryout__conversation {
display: flex;
flex: 1;
min-height: 0;
padding: 0 var(--space-4);
overflow: auto;
overscroll-behavior: contain;
}
.agent-tryout__conversation :deep(.chat-timeline) {
width: 100%;
min-height: 0;
}
</style>