feat: 全新智能体功能

- 基于先进智能体框架,增加智能体编排功能
- 增加智能体聊天,并对接持久化
This commit is contained in:
2026-05-25 11:42:48 +08:00
parent 6c3d98eaac
commit 72df00f25b
168 changed files with 22045 additions and 400 deletions

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import AiConversation from './AiConversation.vue';
import AiPromptInput from './AiPromptInput.vue';
defineProps<{
approvalLoading?: boolean;
closable?: boolean;
emptyText?: string;
loading?: boolean;
messages: AiChatMessage[];
subtitle?: string;
title: string;
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
close: [];
reject: [payload: AiToolApprovalPayload];
send: [text: string];
stop: [];
}>();
defineSlots<{
default?: () => any;
headerActions?: () => any;
}>();
</script>
<template>
<section class="ai-chat-panel">
<header class="ai-chat-panel__header">
<div class="ai-chat-panel__title-wrap">
<div class="ai-chat-panel__title">{{ title }}</div>
<div v-if="subtitle" class="ai-chat-panel__subtitle">
{{ subtitle }}
</div>
</div>
<div class="ai-chat-panel__actions">
<slot name="headerActions"></slot>
<ElButton
v-if="closable"
:icon="Close"
circle
text
aria-label="关闭"
@click="emit('close')"
/>
</div>
</header>
<slot>
<AiConversation
:messages="messages"
:empty-text="emptyText"
:approval-loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
</slot>
<AiPromptInput
:loading="loading"
@send="emit('send', $event)"
@stop="emit('stop')"
/>
</section>
</template>
<style scoped>
.ai-chat-panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.ai-chat-panel__header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ai-chat-panel__title-wrap {
flex: 1;
min-width: 0;
}
.ai-chat-panel__actions {
display: inline-flex;
flex: 0 0 auto;
gap: 2px;
align-items: center;
}
.ai-chat-panel__actions :deep(.el-button.is-circle) {
width: 32px;
height: 32px;
}
.ai-chat-panel__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.ai-chat-panel__subtitle {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import {ref, watch} from 'vue';
import {ChatDotRound} from '@element-plus/icons-vue';
import {ElEmpty} from 'element-plus';
import AiMessage from './AiMessage.vue';
import {useAiChatScroll} from './composables/useAiChatScroll';
const props = defineProps<{
approvalLoading?: boolean;
emptyText?: string;
messages: AiChatMessage[];
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
const containerRef = ref<HTMLElement>();
const { scrollToBottom } = useAiChatScroll(containerRef);
watch(
() => props.messages,
() => scrollToBottom(),
{ deep: true, immediate: true },
);
</script>
<template>
<div ref="containerRef" class="ai-conversation">
<ElEmpty
v-if="messages.length === 0"
:image-size="88"
:description="emptyText || '开始试用当前草稿'"
>
<template #image>
<div class="ai-conversation__empty-icon">
<ChatDotRound />
</div>
</template>
</ElEmpty>
<AiMessage
v-for="message in messages"
v-else
:key="message.id"
:message="message"
:approval-loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
</div>
</template>
<style scoped>
.ai-conversation {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow: auto;
}
.ai-conversation__empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 auto;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.ai-conversation__empty-icon svg {
width: 32px;
height: 32px;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import {WarningFilled} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
defineProps<{ message: string }>();
</script>
<template>
<div class="ai-error-notice">
<ElIcon><WarningFilled /></ElIcon>
<span>{{ message }}</span>
</div>
</template>
<style scoped>
.ai-error-notice {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
font-size: 13px;
line-height: 20px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type {AiKnowledgeHit} from './types';
defineProps<{ items: AiKnowledgeHit[] }>();
function resolveTitle(item: AiKnowledgeHit, index: number) {
return item.title || item.source || `知识片段 ${index + 1}`;
}
</script>
<template>
<div class="ai-knowledge-card">
<div class="ai-knowledge-card__title">知识检索</div>
<div
v-for="(item, index) in items"
:key="item.id || index"
class="ai-knowledge-card__item"
>
<div class="ai-knowledge-card__item-title">
{{ resolveTitle(item, index) }}
</div>
<div v-if="item.content" class="ai-knowledge-card__content">
{{ item.content }}
</div>
<div v-if="item.score" class="ai-knowledge-card__score">
相似度 {{ item.score }}
</div>
</div>
</div>
</template>
<style scoped>
.ai-knowledge-card {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-knowledge-card__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.ai-knowledge-card__item + .ai-knowledge-card__item {
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
.ai-knowledge-card__item-title {
font-size: 13px;
font-weight: 500;
}
.ai-knowledge-card__content,
.ai-knowledge-card__score {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import type {AiChatMessage, AiToolApprovalPayload} from './types';
import AiErrorNotice from './AiErrorNotice.vue';
import AiKnowledgeCard from './AiKnowledgeCard.vue';
import AiReasoningCard from './AiReasoningCard.vue';
import AiToolApprovalCard from './AiToolApprovalCard.vue';
import AiToolCallCard from './AiToolCallCard.vue';
defineProps<{
approvalLoading?: boolean;
message: AiChatMessage;
}>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
</script>
<template>
<div class="ai-message" :class="`ai-message--${message.role}`">
<div class="ai-message__bubble">
<template v-for="(part, index) in message.parts" :key="index">
<div v-if="part.type === 'text'" class="ai-message__text">
{{ part.text }}
</div>
<AiReasoningCard
v-else-if="part.type === 'reasoning'"
:text="part.text"
:collapsed="part.collapsed"
/>
<AiKnowledgeCard
v-else-if="part.type === 'knowledge'"
:items="part.items"
/>
<AiToolCallCard
v-else-if="part.type === 'tool_call'"
:tool-name="part.toolName"
:status="part.status"
:input="part.input"
:output="part.output"
/>
<AiToolApprovalCard
v-else-if="part.type === 'tool_approval'"
:request-id="part.requestId"
:resume-token="part.resumeToken"
:tool-name="part.toolName"
:tool-display-name="part.toolDisplayName"
:tool-call-id="part.toolCallId"
:tool-type="part.toolType"
:input="part.input"
:expires-at="part.expiresAt"
:metadata="part.metadata"
:loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<AiErrorNotice
v-else-if="part.type === 'error'"
:message="part.message"
/>
</template>
<span
v-if="message.status === 'streaming'"
class="ai-message__cursor"
></span>
</div>
</div>
</template>
<style scoped>
.ai-message {
display: flex;
width: 100%;
}
.ai-message--user {
justify-content: flex-end;
}
.ai-message--assistant,
.ai-message--system {
justify-content: flex-start;
}
.ai-message__bubble {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(88%, 640px);
padding: 12px 14px;
color: var(--el-text-color-primary);
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.ai-message--user .ai-message__bubble {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
}
.ai-message__text {
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
}
.ai-message__cursor {
width: 6px;
height: 16px;
margin-left: 2px;
background: var(--el-color-primary);
border-radius: 2px;
animation: ai-chat-cursor 0.9s infinite;
}
@keyframes ai-chat-cursor {
50% {
opacity: 0.2;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import AiPromptInput from './AiPromptInput.vue';
describe('AiPromptInput', () => {
it('emits send when loading is false', async () => {
const wrapper = mount(AiPromptInput, {
props: {
loading: false,
},
});
await wrapper.find('textarea').setValue('你好');
await wrapper.find('[aria-label="发送"]').trigger('click');
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('你好');
});
it('emits stop when loading is true', async () => {
const wrapper = mount(AiPromptInput, {
props: {
loading: true,
},
});
expect(wrapper.find('[aria-label="中止"]').exists()).toBe(true);
await wrapper.find('[aria-label="中止"]').trigger('click');
expect(wrapper.emitted('stop')).toBeTruthy();
});
});

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import {computed, ref} from 'vue';
import {Promotion} from '@element-plus/icons-vue';
import {ElButton, ElInput} from 'element-plus';
const props = defineProps<{
loading?: boolean;
placeholder?: string;
}>();
const emit = defineEmits<{
send: [text: string];
stop: [];
}>();
const text = ref('');
const canSend = computed(() => text.value.trim().length > 0 && !props.loading);
function send() {
const value = text.value.trim();
if (!value || props.loading) return;
emit('send', value);
text.value = '';
}
function stop() {
if (!props.loading) return;
emit('stop');
}
function handleKeydown(event: Event | KeyboardEvent) {
if (!(event instanceof KeyboardEvent)) return;
if (event.key !== 'Enter' || event.shiftKey) return;
event.preventDefault();
send();
}
</script>
<template>
<div class="ai-prompt-input">
<ElInput
v-model="text"
class="ai-prompt-input__textarea"
type="textarea"
resize="none"
:autosize="{ minRows: 1, maxRows: 5 }"
:disabled="loading"
:placeholder="placeholder || '输入消息'"
@keydown="handleKeydown"
/>
<ElButton
v-if="loading"
type="primary"
circle
aria-label="中止"
title="中止"
class="ai-prompt-input__action is-stop"
@click="stop"
/>
<ElButton
v-else
type="primary"
circle
:icon="Promotion"
:disabled="!canSend"
aria-label="发送"
class="ai-prompt-input__action"
@click="send"
/>
</div>
</template>
<style scoped>
.ai-prompt-input {
display: flex;
gap: 10px;
align-items: flex-end;
padding: 10px;
margin: 0 16px 16px;
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
box-shadow: var(--el-box-shadow-lighter);
}
.ai-prompt-input__textarea {
flex: 1;
}
.ai-prompt-input__textarea :deep(.el-textarea__inner) {
min-height: 36px !important;
padding: 8px 10px;
line-height: 20px;
background: transparent;
border: none;
box-shadow: none;
}
.ai-prompt-input__action {
width: 36px;
height: 36px;
}
.ai-prompt-input__action.is-stop::before {
display: block;
width: 12px;
height: 12px;
content: '';
background: var(--el-color-white);
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import {ref} from 'vue';
import {ArrowDown, ArrowRight} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
const props = defineProps<{
collapsed?: boolean;
text: string;
}>();
const open = ref(!props.collapsed);
</script>
<template>
<div class="ai-reasoning-card">
<ElButton class="ai-reasoning-card__toggle" link @click="open = !open">
<ElIcon>
<ArrowDown v-if="open" />
<ArrowRight v-else />
</ElIcon>
<span>思考</span>
</ElButton>
<div v-if="open" class="ai-reasoning-card__body">{{ text }}</div>
</div>
</template>
<style scoped>
.ai-reasoning-card {
padding: 10px 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-reasoning-card__toggle {
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
}
.ai-reasoning-card__body {
margin-top: 8px;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import type {AiToolApprovalPayload} from './types';
import {Check, Close, Key} from '@element-plus/icons-vue';
import {ElButton, ElIcon} from 'element-plus';
const props = defineProps<AiToolApprovalPayload & { loading?: boolean }>();
const emit = defineEmits<{
approve: [payload: AiToolApprovalPayload];
reject: [payload: AiToolApprovalPayload];
}>();
function payload(): AiToolApprovalPayload {
return {
requestId: props.requestId,
resumeToken: props.resumeToken,
toolName: props.toolName,
toolDisplayName: props.toolDisplayName,
toolCallId: props.toolCallId,
toolType: props.toolType,
input: props.input,
expiresAt: props.expiresAt,
metadata: props.metadata,
};
}
</script>
<template>
<div class="ai-tool-approval">
<div class="ai-tool-approval__head">
<ElIcon><Key /></ElIcon>
<div>
<div class="ai-tool-approval__title">
{{ toolDisplayName || toolName }}
</div>
<div class="ai-tool-approval__desc">需要确认后执行</div>
</div>
</div>
<pre v-if="input" class="ai-tool-approval__payload">{{ input }}</pre>
<div class="ai-tool-approval__actions">
<ElButton
:icon="Close"
:loading="loading"
@click="emit('reject', payload())"
>
拒绝
</ElButton>
<ElButton
type="primary"
:icon="Check"
:loading="loading"
@click="emit('approve', payload())"
>
批准
</ElButton>
</div>
</div>
</template>
<style scoped>
.ai-tool-approval {
padding: 12px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.ai-tool-approval__head {
display: flex;
gap: 8px;
align-items: center;
}
.ai-tool-approval__title {
font-size: 13px;
font-weight: 600;
}
.ai-tool-approval__desc {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ai-tool-approval__payload {
max-height: 128px;
padding: 8px;
margin: 10px 0 0;
overflow: auto;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
background: var(--el-bg-color);
border-radius: 6px;
}
.ai-tool-approval__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import {computed} from 'vue';
import {Tools} from '@element-plus/icons-vue';
import {ElIcon, ElTag} from 'element-plus';
const props = defineProps<{
input?: unknown;
output?: unknown;
status?: string;
toolName: string;
}>();
const statusText = computed(() => props.status || '执行中');
</script>
<template>
<div class="ai-tool-card">
<div class="ai-tool-card__head">
<div class="ai-tool-card__name">
<ElIcon><Tools /></ElIcon>
<span>{{ toolName }}</span>
</div>
<ElTag size="small" effect="plain">{{ statusText }}</ElTag>
</div>
<pre v-if="input" class="ai-tool-card__payload">{{ input }}</pre>
<pre v-if="output" class="ai-tool-card__payload">{{ output }}</pre>
</div>
</template>
<style scoped>
.ai-tool-card {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.ai-tool-card__head,
.ai-tool-card__name {
display: flex;
align-items: center;
}
.ai-tool-card__head {
justify-content: space-between;
}
.ai-tool-card__name {
gap: 6px;
min-width: 0;
font-size: 13px;
font-weight: 600;
}
.ai-tool-card__payload {
max-height: 128px;
padding: 8px;
margin: 10px 0 0;
overflow: auto;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
white-space: pre-wrap;
background: var(--el-bg-color);
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,13 @@
import type {Ref} from 'vue';
import {nextTick} from 'vue';
export function useAiChatScroll(containerRef: Ref<HTMLElement | undefined>) {
async function scrollToBottom() {
await nextTick();
const container = containerRef.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
}
return { scrollToBottom };
}

View File

@@ -0,0 +1,45 @@
export type AiChatMessageRole = 'assistant' | 'system' | 'user';
export type AiChatMessageStatus = 'done' | 'error' | 'pending' | 'streaming';
export interface AiKnowledgeHit {
id?: string;
title?: string;
content?: string;
score?: number | string;
source?: string;
[key: string]: any;
}
export interface AiToolApprovalPayload {
requestId: string;
resumeToken: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;
toolType?: string;
input?: unknown;
expiresAt?: string;
metadata?: unknown;
}
export type AiChatMessagePart =
| (AiToolApprovalPayload & { type: 'tool_approval' })
| { collapsed?: boolean; text: string; type: 'reasoning' }
| {
input?: unknown;
output?: unknown;
status?: string;
toolName: string;
type: 'tool_call';
}
| { items: AiKnowledgeHit[]; type: 'knowledge' }
| { message: string; type: 'error' }
| { text: string; type: 'text' };
export interface AiChatMessage {
id: string;
role: AiChatMessageRole;
status?: AiChatMessageStatus;
parts: AiChatMessagePart[];
createdAt?: number;
}

View File

@@ -1,16 +1,17 @@
<script setup lang="ts">
import type {
ChatTimeAssistantSegment,
ChatTimeAssistantTextSegment,
ChatTimeTimelineItem,
} from '@easyflow/types';
import { computed, ref } from 'vue';
import {computed, ref} from 'vue';
import { ChatThinkingBlock, ChatTimeMarkdown } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import {ChatThinkingBlock, ChatTimeMarkdown} from '@easyflow/common-ui';
import {IconifyIcon} from '@easyflow/icons';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
import {CircleCheck} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
@@ -29,7 +30,22 @@ const renderableSegments = computed(() => {
return [] as ChatTimeAssistantSegment[];
}
if (props.item.segments.length > 0) {
return props.item.segments;
const textSegments = props.item.segments.filter(
(segment): segment is ChatTimeAssistantTextSegment =>
segment.type === 'text',
);
if (textSegments.length <= 1) {
return props.item.segments;
}
const mergedTextSegment: ChatTimeAssistantTextSegment = {
content: textSegments.map((segment) => segment.content).join(''),
id: `${props.item.id}-merged-text`,
type: 'text',
};
return [
...props.item.segments.filter((segment) => segment.type !== 'text'),
mergedTextSegment,
];
}
if (!props.item.content) {
return [] as ChatTimeAssistantSegment[];
@@ -74,7 +90,11 @@ function toggleToolExpanded() {
:status="segment.status"
class="chat-thinking-block-item"
/>
<ChatTimeMarkdown v-else :content="segment.content" />
<ChatTimeMarkdown
v-else
:content="segment.content"
:streaming="item.typing"
/>
</template>
</div>
</template>
@@ -124,7 +144,7 @@ function toggleToolExpanded() {
</div>
</template>
<ChatTimeMarkdown v-else :content="item.content" />
<ChatTimeMarkdown v-else :content="item.content" :streaming="item.typing" />
</template>
<style scoped>

View File

@@ -23,6 +23,7 @@
},
"ai": {
"chat": "Chat",
"agents": "Agent",
"bots": "ChatAssistant",
"title": "AI",
"resources": "Resources",

View File

@@ -23,6 +23,7 @@
},
"ai": {
"chat": "聊天",
"agents": "智能体",
"bots": "聊天助手",
"title": "AI能力",
"resources": "素材库",

View File

@@ -0,0 +1,30 @@
import type {RouteRecordRaw} from 'vue-router';
import {$t} from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'AgentDesigner',
path: '/ai/agents/designer/:id',
component: () => import('#/views/ai/agents/AgentDesigner.vue'),
meta: {
title: $t('menus.ai.agents'),
openInNewWindow: true,
hideInMenu: true,
activePath: '/ai/agents',
},
},
{
name: 'AgentChat',
path: '/ai/agent-chat',
component: () => import('#/views/ai/agent-chat/index.vue'),
meta: {
title: '智能体聊天',
fullPathKey: false,
hideInMenu: true,
activePath: '/ai/agents',
},
},
];
export default routes;

View File

@@ -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('准备调用工具');
});
});

View File

@@ -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 ?? '请求失败',
);
}
}

View File

@@ -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);
};
},
};

View 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();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
/* cspell:ignore tryit */
import type {AgentCapabilityKind, AgentOption, AgentValidationIssue,} from './types';
import {computed, onMounted, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import {ElMessage, ElMessageBox} from 'element-plus';
import {tryit} from 'radash';
import {api} from '#/api/request';
import {
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
} from '#/views/ai/shared/publish-status';
import {
getAgentDetail,
getAgentModels,
getPublishedKnowledgeList,
saveAgent,
submitAgentOfflineApproval,
submitAgentPublishApproval,
updateAgent,
updateAgentKnowledgeBindings,
updateAgentToolBindings,
} from './api';
import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
import AgentCommandBar from './components/AgentCommandBar.vue';
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
import {useAgentDesignerState} from './composables/useAgentDesignerState';
const route = useRoute();
const router = useRouter();
const {
state,
addKnowledgeNode,
addToolNode,
buildKnowledgePayload,
buildPayloadAgent,
buildToolPayload,
markDirty,
openTryout,
removeSelectedCapability,
reset,
selectBase,
selectNode,
validate,
} = useAgentDesignerState();
const pageLoading = ref(false);
const saveLoading = ref(false);
const publishLoading = ref(false);
const issues = ref<AgentValidationIssue[]>([]);
const categories = ref<AgentOption[]>([]);
const models = ref<AgentOption[]>([]);
const knowledges = ref<AgentOption[]>([]);
const workflows = ref<AgentOption[]>([]);
const pluginTools = ref<AgentOption[]>([]);
const isNew = computed(() => String(route.params.id || '') === 'new');
const publishText = computed(() => {
if (
canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return '下线';
}
if (
canAiResourceRepublish(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return '重新发布';
}
return '发布';
});
const publishDisabled = computed(() => {
if (!state.agent.id) return true;
if (
isAiResourceApprovalPending(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
) {
return true;
}
return !(
canAiResourcePublish(
state.agent.displayPublishStatus,
state.agent.publishStatus,
) ||
canAiResourceRepublish(
state.agent.displayPublishStatus,
state.agent.publishStatus,
) ||
canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
)
);
});
onMounted(async () => {
pageLoading.value = true;
try {
await Promise.all([loadOptions(), loadAgent()]);
} finally {
pageLoading.value = false;
}
});
async function loadAgent() {
if (isNew.value) {
reset();
return;
}
const [, res] = await tryit(getAgentDetail)(String(route.params.id));
if (res?.errorCode === 0) {
reset(res.data);
}
}
async function loadOptions() {
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
await Promise.all([
api.get('/api/v1/agentCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
getAgentModels(),
getPublishedKnowledgeList(),
api.get('/api/v1/workflow/page', {
params: { pageNumber: 1, pageSize: 200 },
}),
api.get('/api/v1/plugin/pageByCategory', {
params: { pageNumber: 1, pageSize: 200, category: 0 },
}),
]);
categories.value = (categoryRes.data || []).map((item: any) => ({
label: item.categoryName || item.name,
value: String(item.id),
raw: item,
}));
models.value = (modelRes.data || []).map((item: any) => ({
label: item.title || item.name,
value: String(item.id),
raw: item,
}));
knowledges.value = (knowledgeRes.data || []).map((item: any) => ({
label: item.title || item.name,
value: String(item.id),
raw: item,
}));
workflows.value = (
(workflowRes.data?.records || workflowRes.data || []) as any[]
).map((item) => ({
label: item.title || item.name,
value: String(item.id),
raw: item,
}));
pluginTools.value = flattenPluginTools(
pluginRes.data?.records || pluginRes.data || [],
);
}
function flattenPluginTools(list: any[]): AgentOption[] {
const result: AgentOption[] = [];
list.forEach((plugin) => {
const tools = Array.isArray(plugin.tools) ? plugin.tools : [];
tools.forEach((tool: any) => {
result.push({
label: tool.name || tool.title,
value: String(tool.id),
raw: { ...tool, pluginName: plugin.name || plugin.title },
});
});
});
return result;
}
async function handleAdd(kind: AgentCapabilityKind) {
if (kind === 'knowledge') {
addKnowledgeNode();
return;
}
addToolNode(kind);
}
function handleSelectNode(nodeId: string) {
selectNode(nodeId);
}
function handleSelectIssue(nodeId: string) {
selectNode(nodeId);
}
function runValidation() {
issues.value = validate();
if (issues.value.length > 0) {
selectNode(issues.value[0]!.nodeId);
ElMessage.warning('请先完成必要配置');
return false;
}
return true;
}
async function handleSave(showMessage = true) {
if (!runValidation()) return false;
saveLoading.value = true;
try {
const agentPayload = buildPayloadAgent();
const agentRes = state.agent.id
? await updateAgent(agentPayload)
: await saveAgent(agentPayload);
if (agentRes.errorCode !== 0 || !agentRes.data?.id) {
return false;
}
const id = agentRes.data.id;
const toolBindingRes = await updateAgentToolBindings(
id,
buildToolPayload(id),
);
if (toolBindingRes.errorCode !== 0) {
return false;
}
const knowledgeBindingRes = await updateAgentKnowledgeBindings(
id,
buildKnowledgePayload(id),
);
if (knowledgeBindingRes.errorCode !== 0) {
return false;
}
state.agent = {
...state.agent,
...agentRes.data,
id,
};
state.dirty = false;
if (isNew.value) {
await router.replace(`/ai/agents/designer/${id}`);
}
if (showMessage) {
ElMessage.success('已保存');
}
return true;
} finally {
saveLoading.value = false;
}
}
async function handlePublish() {
if (!state.agent.id) return;
const saved = await handleSave(false);
if (!saved) return;
const offline = canAiResourceOffline(
state.agent.displayPublishStatus,
state.agent.publishStatus,
);
try {
await ElMessageBox.confirm(
offline ? '确认提交下线审批?' : '确认提交发布审批?',
'提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: offline ? 'warning' : 'info',
},
);
} catch {
return;
}
publishLoading.value = true;
try {
const res = offline
? await submitAgentOfflineApproval(String(state.agent.id))
: await submitAgentPublishApproval(String(state.agent.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '已提交');
await loadAgent();
}
} finally {
publishLoading.value = false;
}
}
function handleTryout() {
if (!runValidation()) return;
openTryout();
}
function handleCloseTryout() {
selectBase();
}
</script>
<template>
<div v-loading="pageLoading" class="agent-designer">
<AgentStudioCanvas
:state="state"
:knowledge-options="knowledges"
:selected-node-id="state.selectedNodeId"
@select="handleSelectNode"
/>
<AgentInspectorPanel
:state="state"
:models="models"
:categories="categories"
:knowledges="knowledges"
:workflows="workflows"
:plugin-tools="pluginTools"
:issues="issues"
@change="markDirty"
@remove-capability="removeSelectedCapability"
@close-tryout="handleCloseTryout"
@select-issue="handleSelectIssue"
/>
<AgentCommandBar
:save-loading="saveLoading"
:publish-loading="publishLoading"
:publish-disabled="publishDisabled"
:publish-text="publishText"
@add="handleAdd"
@save="handleSave()"
@publish="handlePublish"
@tryout="handleTryout"
/>
</div>
</template>
<style scoped>
.agent-designer {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background:
radial-gradient(
circle at 50% 42%,
var(--el-color-primary-light-9),
transparent 32%
),
var(--el-fill-color-extra-light);
}
</style>

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
/* cspell:ignore tryit */
import type {AgentInfo} from './types';
import type {ActionButton, CardPrimaryAction,} from '#/components/page/CardList.vue';
import CardList from '#/components/page/CardList.vue';
import {markRaw, onMounted, ref} from 'vue';
import {useRouter} from 'vue-router';
import {Delete, Edit, Plus, Promotion} from '@element-plus/icons-vue';
import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
import {tryit} from 'radash';
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {$t} from '#/locales';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import {
getAgentCategories,
submitAgentDeleteApproval,
submitAgentOfflineApproval,
submitAgentPublishApproval,
} from './api';
const router = useRouter();
const pageDataRef = ref();
const sideList = ref<any[]>([]);
const headerButtons = [
{
key: 'create',
text: '创建智能体',
icon: markRaw(Plus),
type: 'primary',
data: { action: 'create' },
permission: '/api/v1/agent/save',
},
];
const primaryAction: CardPrimaryAction = {
icon: Edit,
text: '编排',
permission: '/api/v1/agent/update',
onClick(row: AgentInfo) {
router.push(`/ai/agents/designer/${row.id}`);
},
};
const actions: ActionButton[] = [
{
icon: Promotion,
text: (row: AgentInfo) =>
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? '重新发布'
: '发布',
permission: '/api/v1/agent/save',
placement: 'inline',
visible: (row: AgentInfo) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick: handlePublishAction,
},
{
icon: Promotion,
text: '下线',
permission: '/api/v1/agent/save',
placement: 'menu',
visible: (row: AgentInfo) =>
canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick: handleOfflineAction,
},
{
icon: Delete,
text: '删除',
permission: '/api/v1/agent/remove',
placement: 'menu',
tone: 'danger',
visible: (row: AgentInfo) =>
canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick: handleDeleteAction,
},
];
onMounted(() => {
loadCategories();
});
function handleSearch(keyword: string) {
pageDataRef.value?.setQuery({
isQueryOr: true,
name: keyword,
description: keyword,
});
}
function handleButtonClick(payload: any) {
if (payload?.key === 'create' || payload?.data?.action === 'create') {
router.push('/ai/agents/designer/new');
}
}
function changeCategory(category: any) {
pageDataRef.value?.setQuery({ categoryId: category.id });
}
async function loadCategories() {
const [, res] = await tryit(getAgentCategories)();
if (res?.errorCode === 0) {
sideList.value = [
{ id: '', categoryName: $t('common.allCategories') },
...(res.data || []),
];
}
}
function resolvePublishStatusMeta(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': {
return { label: '删除中', type: 'danger' as const };
}
case 'OFFLINE': {
return { label: '已下线', type: 'info' as const };
}
case 'OFFLINE_PENDING': {
return { label: '下线中', type: 'warning' as const };
}
case 'PUBLISH_PENDING': {
return { label: '发布中', type: 'warning' as const };
}
case 'PUBLISHED': {
return { label: '已发布', type: 'success' as const };
}
default: {
return { label: '草稿', type: 'info' as const };
}
}
}
async function confirmAction(
message: string,
type: 'info' | 'warning' = 'info',
) {
try {
await ElMessageBox.confirm(message, $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type,
});
return true;
} catch {
return false;
}
}
async function handlePublishAction(row: AgentInfo) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning('当前智能体正在审批中');
return;
}
const ok = await confirmAction(
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? '确认提交重新发布审批?'
: '确认提交发布审批?',
);
if (!ok) return;
const res = await submitAgentPublishApproval(String(row.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
}
async function handleOfflineAction(row: AgentInfo) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning('当前智能体正在审批中');
return;
}
const ok = await confirmAction('确认提交下线审批?', 'warning');
if (!ok) return;
const res = await submitAgentOfflineApproval(String(row.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
}
async function handleDeleteAction(row: AgentInfo) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning('当前智能体正在审批中');
return;
}
const ok = await confirmAction('确认提交删除审批?', 'warning');
if (!ok) return;
const res = await submitAgentDeleteApproval(String(row.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
}
</script>
<template>
<div class="agent-list-page">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
<div class="agent-list-page__body">
<PageSide
label-key="categoryName"
value-key="id"
:menus="sideList"
@change="changeCategory"
/>
<div class="agent-list-page__content">
<PageData
ref="pageDataRef"
page-url="/api/v1/agent/page"
:page-sizes="[12, 18, 24]"
:page-size="12"
>
<template #default="{ pageList }">
<CardList
title-field="name"
icon-field="avatar"
:default-icon="defaultAvatar"
:data="pageList"
:primary-action="primaryAction"
:actions="actions"
>
<template #corner="{ item }">
<ElTag
size="small"
effect="plain"
round
:type="
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).type
"
>
{{
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).label
}}
</ElTag>
</template>
</CardList>
</template>
</PageData>
</div>
</div>
</div>
</template>
<style scoped>
.agent-list-page {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
padding: 24px;
}
.agent-list-page__body {
display: flex;
flex: 1;
gap: 24px;
min-height: 0;
}
.agent-list-page__content {
flex: 1;
min-width: 0;
height: calc(100vh - 192px);
overflow: auto;
}
</style>

View File

@@ -0,0 +1,111 @@
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from './types';
import {api} from '#/api/request';
export interface RequestResult<T = any> {
data: T;
errorCode: number;
message?: string;
}
export function getAgentDetail(id: number | string) {
return api.get<RequestResult<AgentInfo>>('/api/v1/agent/getDetail', {
params: { id },
});
}
export function saveAgent(agent: AgentInfo) {
return api.post<RequestResult<AgentInfo>>('/api/v1/agent/save', agent);
}
export function updateAgent(agent: AgentInfo) {
return api.post<RequestResult<AgentInfo>>('/api/v1/agent/update', agent);
}
export function removeAgent(id: number | string) {
return api.post<RequestResult>('/api/v1/agent/remove', { id });
}
export function updateAgentToolBindings(
agentId: number | string,
bindings: AgentToolBinding[],
) {
return api.post<RequestResult<AgentToolBinding[]>>(
'/api/v1/agent/toolBinding/update',
{ agentId, bindings },
);
}
export function updateAgentKnowledgeBindings(
agentId: number | string,
bindings: AgentKnowledgeBinding[],
) {
return api.post<RequestResult<AgentKnowledgeBinding[]>>(
'/api/v1/agent/knowledgeBinding/update',
{ agentId, bindings },
);
}
export function submitAgentPublishApproval(id: number | string) {
return api.post<RequestResult<number | string>>(
'/api/v1/agent/submitPublishApproval',
{ id },
);
}
export function submitAgentOfflineApproval(id: number | string) {
return api.post<RequestResult<number | string>>(
'/api/v1/agent/submitOfflineApproval',
{ id },
);
}
export function submitAgentDeleteApproval(id: number | string) {
return api.post<RequestResult<number | string>>(
'/api/v1/agent/submitDeleteApproval',
{ id },
);
}
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 clearAgentDraftSession(sessionId: string) {
return api.post<RequestResult>('/api/v1/agent/chat/draft/clear', {
sessionId,
});
}
export function getAgentCategories() {
return api.get<RequestResult<any[]>>('/api/v1/agentCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});
}
export function getAgentModels() {
return api.get<RequestResult<any[]>>('/api/v1/model/list', {
params: { modelType: 'chatModel', added: true },
});
}
export function getPublishedKnowledgeList() {
return api.get<RequestResult<any[]>>('/api/v1/documentCollection/list', {
params: { publishedOnly: true },
});
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentInfo, AgentOption} from '../types';
import {InfoFilled} from '@element-plus/icons-vue';
import {
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElTooltip,
} from 'element-plus';
defineProps<{
agent: AgentInfo;
categories: AgentOption[];
models: AgentOption[];
}>();
const emit = defineEmits<{ change: [] }>();
</script>
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem label="名称" required>
<ElInput
v-model="agent.name"
maxlength="50"
show-word-limit
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="描述">
<ElInput
v-model="agent.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="头像">
<ElInput
v-model="agent.avatar"
placeholder="图片地址"
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="分类">
<ElSelect
v-model="agent.categoryId"
clearable
filterable
@change="emit('change')"
>
<ElOption
v-for="item in categories"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="模型" required>
<ElSelect v-model="agent.modelId" filterable @change="emit('change')">
<ElOption
v-for="item in models"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="系统提示词">
<ElInput
v-model="agent.promptConfigJson!.systemPrompt"
type="textarea"
:rows="7"
@input="emit('change')"
/>
</ElFormItem>
<div class="agent-form__grid">
<ElFormItem>
<template #label>
<span class="agent-form__label">
消息数触发阈值
<ElTooltip
content="工作记忆里的消息条数达到该值时,会触发上下文压缩;用户消息和智能体消息都会计入。"
effect="light"
placement="top"
>
<ElIcon class="agent-form__info" aria-label="消息数触发阈值说明">
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="agent.memoryConfigJson!.compressionParameter.msgThreshold"
:min="1"
:max="100"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span class="agent-form__label">
最近保留消息数
<ElTooltip
content="压缩上下文时,最近这 N 条消息会尽量保留原文;更早的消息会优先被压缩。"
effect="light"
placement="top"
>
<ElIcon class="agent-form__info" aria-label="最近保留消息数说明">
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="agent.memoryConfigJson!.compressionParameter.lastKeep"
:min="0"
:max="100"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span class="agent-form__label">
最小压缩 Token 阈值
<ElTooltip
content="工作记忆的 Token 数达到该值时,会触发上下文压缩。"
effect="light"
placement="top"
>
<ElIcon
class="agent-form__info"
aria-label="最小压缩 Token 阈值说明"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</span>
</template>
<ElInputNumber
v-model="
agent.memoryConfigJson!.compressionParameter
.minCompressionTokenThreshold
"
:min="0"
:step="500"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
</div>
</ElForm>
</template>
<style scoped>
.agent-form {
padding: 16px;
}
.agent-form__grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.agent-form__label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.agent-form__info {
color: var(--el-text-color-secondary);
cursor: help;
font-size: 14px;
}
.agent-form :deep(.el-select),
.agent-form :deep(.el-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
import type {AgentCapabilityKind} from '../types';
import {onBeforeUnmount, onMounted, ref} from 'vue';
import {
Connection,
Files,
Loading,
Plus,
Promotion,
Share,
VideoPlay,
} from '@element-plus/icons-vue';
defineProps<{
publishDisabled?: boolean;
publishLoading?: boolean;
publishText: string;
saveLoading?: boolean;
tryoutDisabled?: boolean;
}>();
const emit = defineEmits<{
add: [kind: AgentCapabilityKind];
publish: [];
save: [];
tryout: [];
}>();
const capabilityOpen = ref(false);
const capabilityRef = ref<HTMLElement>();
const capabilityItems = [
{
kind: 'knowledge' as const,
title: '知识库',
desc: '检索企业知识',
icon: Files,
},
{
kind: 'workflow' as const,
title: '工作流',
desc: '调用已发布流程',
icon: Share,
},
{
kind: 'plugin' as const,
title: '插件',
desc: '执行工具能力',
icon: Connection,
},
];
function handleAdd(kind: AgentCapabilityKind) {
capabilityOpen.value = false;
emit('add', kind);
}
function handlePointerDown(event: PointerEvent) {
if (!capabilityOpen.value || !capabilityRef.value) return;
const target = event.target as Node | null;
if (target && !capabilityRef.value.contains(target)) {
capabilityOpen.value = false;
}
}
onMounted(() => {
window.addEventListener('pointerdown', handlePointerDown);
});
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', handlePointerDown);
});
</script>
<template>
<div class="agent-command-bar">
<div class="agent-command-bar__group">
<div ref="capabilityRef" class="agent-command-bar__capability">
<div v-if="capabilityOpen" class="agent-command-bar__capability-panel">
<button
v-for="item in capabilityItems"
:key="item.kind"
class="agent-command-bar__capability-item"
type="button"
@click="handleAdd(item.kind)"
>
<span class="agent-command-bar__capability-icon">
<component :is="item.icon" />
</span>
<span class="agent-command-bar__capability-meta">
<span class="agent-command-bar__capability-title">
{{ item.title }}
</span>
<span class="agent-command-bar__capability-desc">
{{ item.desc }}
</span>
</span>
</button>
</div>
<button
class="agent-command-bar__button agent-command-bar__button--add"
type="button"
:aria-expanded="capabilityOpen"
@click="capabilityOpen = !capabilityOpen"
>
<Plus />
<span>{{ capabilityOpen ? '收起能力' : '增加能力' }}</span>
</button>
</div>
<div class="agent-command-bar__divider"></div>
<button
class="agent-command-bar__button agent-command-bar__button--primary"
type="button"
:disabled="saveLoading"
@click="emit('save')"
>
<Loading v-if="saveLoading" class="is-loading" />
<span>保存</span>
</button>
<div class="agent-command-bar__divider"></div>
<button
class="agent-command-bar__button agent-command-bar__button--ghost"
type="button"
:disabled="publishDisabled || publishLoading"
@click="emit('publish')"
>
<Loading v-if="publishLoading" class="is-loading" />
<Promotion v-else />
<span>{{ publishText }}</span>
</button>
</div>
<button
class="agent-command-bar__run"
type="button"
:disabled="tryoutDisabled"
@click="emit('tryout')"
>
<VideoPlay />
<span>试运行</span>
</button>
</div>
</template>
<style scoped>
.agent-command-bar {
position: absolute;
bottom: 16px;
left: 50%;
z-index: 30;
display: flex;
gap: 8px;
align-items: center;
transform: translateX(-50%);
}
.agent-command-bar__group {
display: inline-flex;
gap: 2px;
align-items: center;
height: 40px;
padding: 0 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.agent-command-bar__button,
.agent-command-bar__run {
all: unset;
box-sizing: border-box;
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
justify-content: center;
height: 32px;
font-size: 14px;
font-weight: 600;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition:
background 0.15s,
color 0.15s,
filter 0.15s,
opacity 0.15s;
}
.agent-command-bar__button {
padding: 0 12px;
}
.agent-command-bar__capability {
position: relative;
display: inline-flex;
}
.agent-command-bar__capability-panel {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
display: flex;
flex-direction: column;
gap: 4px;
width: 224px;
padding: 8px;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color);
border-radius: 10px;
box-shadow: var(--el-box-shadow-light);
}
.agent-command-bar__capability-item {
all: unset;
box-sizing: border-box;
display: flex;
gap: 10px;
align-items: center;
padding: 8px;
cursor: pointer;
border-radius: 8px;
transition: background 0.15s;
}
.agent-command-bar__capability-item:hover {
background: var(--el-fill-color-light);
}
.agent-command-bar__capability-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 8px;
}
.agent-command-bar__capability-meta {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.agent-command-bar__capability-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.agent-command-bar__capability-desc {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.agent-command-bar__button--add {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.agent-command-bar__button--add:hover {
background: var(--el-color-primary-light-8);
}
.agent-command-bar__button--primary {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.agent-command-bar__button--ghost {
color: var(--el-text-color-secondary);
}
.agent-command-bar__button:hover:not(:disabled) {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.agent-command-bar__button--primary:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-8);
}
.agent-command-bar__divider {
width: 1px;
height: 20px;
margin: 0 4px;
background: var(--el-border-color);
}
.agent-command-bar__run {
padding: 0 14px;
color: #fff;
background: #13b33f;
}
.agent-command-bar__run:hover:not(:disabled) {
filter: brightness(0.95);
}
.agent-command-bar__button:disabled,
.agent-command-bar__run:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.agent-command-bar svg {
width: 14px;
height: 14px;
}
.agent-command-bar .is-loading {
animation: agent-command-spin 1s linear infinite;
}
@keyframes agent-command-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import type {AgentDraftState, AgentOption, AgentValidationIssue,} from '../types';
import {computed} from 'vue';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import AgentBaseForm from './AgentBaseForm.vue';
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
import AgentToolForm from './AgentToolForm.vue';
import AgentTryoutPanel from './AgentTryoutPanel.vue';
const props = defineProps<{
categories: AgentOption[];
issues: AgentValidationIssue[];
knowledges: AgentOption[];
models: AgentOption[];
pluginTools: AgentOption[];
state: AgentDraftState;
workflows: AgentOption[];
}>();
const emit = defineEmits<{
change: [];
closeTryout: [];
removeCapability: [];
selectIssue: [nodeId: string];
}>();
const selectedKnowledge = computed(() => {
if (!props.state.selectedNodeId.startsWith('knowledge:')) return;
const localId = props.state.selectedNodeId.slice('knowledge:'.length);
return props.state.knowledgeBindings.find((item) => item.localId === localId);
});
const selectedTool = computed(() => {
if (!props.state.selectedNodeId.startsWith('tool:')) return;
const localId = props.state.selectedNodeId.slice('tool:'.length);
return props.state.toolBindings.find((item) => item.localId === localId);
});
const selectedToolKind = computed(() =>
String(selectedTool.value?.toolType || '').toUpperCase() === 'WORKFLOW'
? 'workflow'
: 'plugin',
);
</script>
<template>
<aside class="agent-inspector">
<template v-if="state.panelMode === 'tryout'">
<AgentTryoutPanel
:agent="state.agent"
:tool-bindings="state.toolBindings"
:knowledge-bindings="state.knowledgeBindings"
@close="emit('closeTryout')"
/>
</template>
<template v-else>
<header class="agent-inspector__header">
<div>
<div class="agent-inspector__title">
{{ state.panelMode === 'base' ? '基座智能体' : '能力配置' }}
</div>
<div class="agent-inspector__subtitle">
{{ state.panelMode === 'base' ? '核心设定' : '绑定关系' }}
</div>
</div>
</header>
<div class="agent-inspector__body">
<div v-if="issues.length > 0" class="agent-inspector__issues">
<button
v-for="issue in issues"
:key="`${issue.nodeId}-${issue.field || issue.message}`"
class="agent-inspector__issue"
type="button"
@click="emit('selectIssue', issue.nodeId)"
>
{{ issue.message }}
</button>
</div>
<AgentBaseForm
v-if="state.panelMode === 'base'"
:agent="state.agent"
:categories="categories"
:models="models"
@change="emit('change')"
/>
<AgentKnowledgeForm
v-else-if="selectedKnowledge"
:binding="selectedKnowledge"
:knowledges="knowledges"
@change="emit('change')"
@remove="emit('removeCapability')"
/>
<AgentToolForm
v-else-if="selectedTool"
:binding="selectedTool"
:kind="selectedToolKind"
:options="selectedToolKind === 'workflow' ? workflows : pluginTools"
@change="emit('change')"
@remove="emit('removeCapability')"
/>
<div v-else class="agent-inspector__empty">
<ElButton :icon="Close" text @click="emit('closeTryout')">
返回基座
</ElButton>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.agent-inspector {
position: absolute;
top: 24px;
right: 24px;
bottom: 96px;
z-index: 20;
display: flex;
flex-direction: column;
width: min(420px, calc(100vw - 320px));
min-height: 0;
overflow: hidden;
background: color-mix(in srgb, var(--el-bg-color) 94%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
box-shadow: var(--el-box-shadow-light);
backdrop-filter: blur(16px);
}
.agent-inspector__header {
padding: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.agent-inspector__body {
flex: 1;
min-height: 0;
overflow: hidden auto;
overscroll-behavior: contain;
}
.agent-inspector__title {
font-size: 16px;
font-weight: 600;
}
.agent-inspector__subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.agent-inspector__issues {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 0;
}
.agent-inspector__issue {
padding: 8px 10px;
font-size: 12px;
color: var(--el-color-danger);
text-align: left;
cursor: pointer;
background: var(--el-color-danger-light-9);
border: 0;
border-radius: 8px;
}
.agent-inspector__empty {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentKnowledgeBinding, AgentOption} from '../types';
import {ElButton, ElForm, ElFormItem, ElInputNumber, ElOption, ElSelect,} from 'element-plus';
const props = defineProps<{
binding: AgentKnowledgeBinding;
knowledges: AgentOption[];
}>();
const retrievalModeOptions = [
{ label: '混合', value: 'HYBRID' },
{ label: '语义', value: 'VECTOR' },
{ label: '关键词', value: 'KEYWORD' },
];
const emit = defineEmits<{
change: [];
remove: [];
}>();
function handleKnowledgeChange(value: string) {
const option = props.knowledges.find(
(item) => String(item.value) === String(value),
);
props.binding.resourceSummary = {
...(option?.raw || {}),
label: option?.label,
name: option?.raw?.name || option?.label,
};
emit('change');
}
</script>
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem label="知识库" required>
<ElSelect
v-model="binding.knowledgeId"
filterable
placeholder="选择知识库"
@change="handleKnowledgeChange"
>
<ElOption
v-for="item in knowledges"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="检索方式">
<ElSelect
v-model="binding.retrievalMode"
placeholder="选择检索方式"
@change="emit('change')"
>
<ElOption
v-for="item in retrievalModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="召回数量">
<ElInputNumber
v-model="binding.optionsJson!.limit"
:min="1"
:max="20"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElFormItem label="相似度阈值">
<ElInputNumber
v-model="binding.optionsJson!.scoreThreshold"
:min="0"
:max="1"
:step="0.05"
controls-position="right"
@change="emit('change')"
/>
</ElFormItem>
<ElButton
class="agent-form__danger"
type="danger"
plain
@click="emit('remove')"
>
删除节点
</ElButton>
</ElForm>
</template>
<style scoped>
.agent-form {
padding: 16px;
}
.agent-form :deep(.el-select),
.agent-form :deep(.el-input-number) {
width: 100%;
}
.agent-form__danger {
width: 100%;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentOption, AgentToolBinding} from '../types';
import {ElButton, ElForm, ElFormItem, ElInput, ElOption, ElSelect, ElSwitch,} from 'element-plus';
const props = defineProps<{
binding: AgentToolBinding;
kind: 'plugin' | 'workflow';
options: AgentOption[];
}>();
const emit = defineEmits<{
change: [];
remove: [];
}>();
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
function isSafeToolName(name?: string) {
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
}
function resolveToolName(option?: AgentOption) {
const raw = option?.raw || {};
if (isSafeToolName(raw.englishName)) {
return String(raw.englishName);
}
if (isSafeToolName(raw.name)) {
return String(raw.name);
}
const prefix = props.kind === 'workflow' ? 'workflow' : 'plugin';
return `${prefix}_${option?.value || Date.now()}`;
}
function shouldSyncToolName() {
const name = String(props.binding.toolName || '');
if (!name) return true;
const prefix = props.kind === 'workflow' ? 'workflow_' : 'plugin_';
return name.startsWith(prefix);
}
function handleTargetChange(value: string) {
const option = props.options.find(
(item) => String(item.value) === String(value),
);
props.binding.resourceSummary = option?.raw || {};
if (shouldSyncToolName()) {
props.binding.toolName = resolveToolName(option);
}
emit('change');
}
</script>
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem :label="kind === 'workflow' ? '工作流' : '插件工具'" required>
<ElSelect
v-model="binding.targetId"
filterable
placeholder="选择资源"
@change="handleTargetChange"
>
<ElOption
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="工具名称" required>
<ElInput
v-model="binding.toolName"
placeholder="仅支持英文、数字、下划线或中划线"
@input="emit('change')"
/>
</ElFormItem>
<ElFormItem label="执行前确认">
<ElSwitch v-model="binding.hitlEnabled" @change="emit('change')" />
</ElFormItem>
<ElButton
class="agent-form__danger"
type="danger"
plain
@click="emit('remove')"
>
删除节点
</ElButton>
</ElForm>
</template>
<style scoped>
.agent-form {
padding: 16px;
}
.agent-form :deep(.el-select) {
width: 100%;
}
.agent-form__danger {
width: 100%;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import type {ChatTimelineMessageItem, ChatTimelineToolApprovalPayload,} from '@easyflow/common-ui';
import {ChatTimeline} from '@easyflow/common-ui';
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
import {onMounted, ref, watch} from 'vue';
import {BrushCleaning} from '@easyflow/icons';
import {ElButton, ElMessage} from 'element-plus';
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
import {approveAgentRun, rejectAgentRun} from '../api';
import {useAgentTryoutStream} from '../composables/useAgentTryoutStream';
const props = defineProps<{
agent: AgentInfo;
knowledgeBindings: AgentKnowledgeBinding[];
toolBindings: AgentToolBinding[];
}>();
const emit = defineEmits<{ close: [] }>();
const {
loading,
clearDraftSession,
markToolApproving,
markToolRejected,
copyMessageText,
selectVariant,
syncDraftContext,
timelineItems,
sendDraft,
stop,
} = useAgentTryoutStream();
const approvalLoading = ref(false);
function getDraftContext() {
return {
agent: props.agent,
toolBindings: props.toolBindings,
knowledgeBindings: props.knowledgeBindings,
};
}
function syncCurrentDraftContext(restore = false) {
syncDraftContext(getDraftContext(), restore);
}
onMounted(() => {
syncCurrentDraftContext(true);
});
watch(
() => [props.agent.id, props.agent.localId],
() => {
syncCurrentDraftContext(true);
},
);
watch(
() => [props.agent, props.knowledgeBindings, props.toolBindings],
() => {
syncCurrentDraftContext();
},
{ deep: true },
);
async function handleSend(prompt: string) {
await sendDraft({
...getDraftContext(),
prompt,
});
}
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;
}
try {
await navigator.clipboard.writeText(text);
ElMessage.success('已复制');
} catch {
ElMessage.error('复制失败');
}
}
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();
ElMessage.success('已清理会话');
} 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.requestId, payload.resumeToken);
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.requestId,
payload.resumeToken,
'用户拒绝执行',
);
} finally {
approvalLoading.value = false;
}
}
</script>
<template>
<AiChatPanel
:title="agent.name || '草稿试运行'"
empty-text="输入问题试运行当前智能体"
closable
:messages="[]"
:loading="loading"
:approval-loading="approvalLoading"
@send="handleSend"
@stop="handleStop"
@approve="handleApprove"
@reject="handleReject"
@close="emit('close')"
>
<template #headerActions>
<ElButton
:icon="BrushCleaning"
circle
text
:disabled="approvalLoading"
aria-label="清理会话"
title="清理会话"
@click="handleClearSession"
/>
</template>
<ChatTimeline
:items="timelineItems"
empty-text="输入问题试运行当前智能体"
:approval-loading="approvalLoading"
:copyable="canCopyMessage"
:regenerable="canRegenerateMessage"
:regenerate-disabled="true"
@approve="handleApprove"
@copy-message="handleCopyMessage"
@regenerate-message="handleRegenerateMessage"
@reject="handleReject"
@select-next-variant="handleSelectNextVariant"
@select-previous-variant="handleSelectPreviousVariant"
/>
</AiChatPanel>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type {AgentStudioNodeRenderProps} from './types';
import AgentStudioNode from './AgentStudioNode.vue';
defineProps<AgentStudioNodeRenderProps>();
</script>
<template>
<AgentStudioNode :data="data" />
</template>

View File

@@ -0,0 +1,380 @@
<script setup lang="ts">
import type {AgentDraftState, AgentOption} from '../../types';
import type {
AgentStudioCanvasSize,
AgentStudioConnectionView,
AgentStudioNodeData,
AgentStudioNodeView,
AgentStudioViewport,
} from './types';
import {
computed,
createApp,
h,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
watch,
} from 'vue';
import {usePreferences} from '@easyflow/preferences';
import {Tinyflow} from '@tinyflow-ai/vue';
import {useAgentStudioLayout} from '../../composables/agent-studio/useAgentStudioLayout';
import {useAgentStudioModel} from '../../composables/agent-studio/useAgentStudioModel';
import AgentStudioBaseNode from './AgentStudioBaseNode.vue';
import AgentStudioCapabilityNode from './AgentStudioCapabilityNode.vue';
import AgentStudioEdgeLayer from './AgentStudioEdgeLayer.vue';
import '@tinyflow-ai/vue/dist/index.css';
const props = defineProps<{
knowledgeOptions?: AgentOption[];
selectedNodeId: string;
state: AgentDraftState;
}>();
const emit = defineEmits<{
select: [nodeId: string];
}>();
const { isDark } = usePreferences();
const { capture, layout } = useAgentStudioLayout(props.state);
const canvasRef = ref<HTMLElement>();
const canvasSize = ref<AgentStudioCanvasSize>();
const canvasModel = useAgentStudioModel(
props.state,
() => props.selectedNodeId,
layout,
() => canvasSize.value,
() => props.knowledgeOptions || [],
);
const liveNodes = ref<AgentStudioNodeView[]>([]);
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });
const flowData = computed(() => {
const liveNodeMap = new Map(liveNodes.value.map((node) => [node.id, node]));
return {
edges: canvasModel.value.edges.map((edge) => ({ ...edge })),
nodes: canvasModel.value.nodes.map((node) => {
const liveNode = liveNodeMap.get(node.id);
return {
...node,
data: { ...node.data },
position: { ...(liveNode?.position || node.position) },
};
}),
viewport: layout.viewport ? { ...layout.viewport } : undefined,
};
});
const lastCaptureSignature = ref('');
const lastFlowDataSignature = ref('');
const customNodes = computed(() => ({
agentStudioBase: {
presentation: 'plain',
render: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioBaseNode, node.data);
},
onUpdate: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioBaseNode, node.data);
},
},
agentStudioCapability: {
presentation: 'plain',
render: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
},
onUpdate: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
},
},
}));
const connections = computed<AgentStudioConnectionView[]>(() => {
const nodes =
liveNodes.value.length > 0
? liveNodes.value
: (canvasModel.value.nodes as AgentStudioNodeView[]);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
return canvasModel.value.edges
.map((edge) => {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
if (!source || !target) return undefined;
return {
active: false,
id: edge.id,
path: buildConnectionPath(source, target, liveViewport.value),
sourceId: edge.source,
targetId: edge.target,
};
})
.filter(Boolean) as AgentStudioConnectionView[];
});
function renderVueNode(
parent: HTMLElement,
component: any,
data: AgentStudioNodeData,
) {
const current = (parent as any).__agentStudioNodeView as
| undefined
| {
app: ReturnType<typeof createApp>;
component: any;
data: AgentStudioNodeData;
signature: string;
};
const signature = JSON.stringify(data);
if (current && current.component === component) {
if (current.signature === signature) {
return;
}
Object.assign(current.data, data);
current.signature = signature;
return;
}
current?.app.unmount();
parent.textContent = '';
const reactiveData = reactive({ ...data }) as AgentStudioNodeData;
const app = createApp({
render: () => h(component, { data: reactiveData }),
});
app.mount(parent);
(parent as any).__agentStudioNodeView = {
app,
component,
data: reactiveData,
signature,
};
}
function getNodeSize(node: AgentStudioNodeView) {
return {
height: node.height || (node.data.kind === 'base' ? 104 : 78),
width: node.width || (node.data.kind === 'base' ? 268 : 212),
};
}
function toScreenPoint(
point: { x: number; y: number },
viewport: AgentStudioViewport,
) {
return {
x: point.x * viewport.zoom + viewport.x,
y: point.y * viewport.zoom + viewport.y,
};
}
function buildConnectionPath(
source: AgentStudioNodeView,
target: AgentStudioNodeView,
viewport: AgentStudioViewport,
) {
const sourceSize = getNodeSize(source);
const targetSize = getNodeSize(target);
const sourceCenter = {
x: source.position.x + sourceSize.width / 2,
y: source.position.y + sourceSize.height / 2,
};
const targetCenter = {
x: target.position.x + targetSize.width / 2,
y: target.position.y + targetSize.height / 2,
};
const sourceOnLeft = targetCenter.x < sourceCenter.x;
const start = {
x: source.position.x + (sourceOnLeft ? 0 : sourceSize.width),
y: sourceCenter.y,
};
const end = {
x: target.position.x + (sourceOnLeft ? targetSize.width : 0),
y: targetCenter.y,
};
const screenStart = toScreenPoint(start, viewport);
const screenEnd = toScreenPoint(end, viewport);
const midX = (screenStart.x + screenEnd.x) / 2;
return [
`M ${screenStart.x} ${screenStart.y}`,
`L ${midX} ${screenStart.y}`,
`L ${midX} ${screenEnd.y}`,
`L ${screenEnd.x} ${screenEnd.y}`,
].join(' ');
}
function handleDataChange(data: any) {
const signature = buildFlowDataSignature(data);
if (signature === lastCaptureSignature.value) {
return;
}
lastCaptureSignature.value = signature;
if (Array.isArray(data?.nodes)) {
liveNodes.value = data.nodes as AgentStudioNodeView[];
}
if (data?.viewport) {
liveViewport.value = data.viewport as AgentStudioViewport;
}
capture({
nodes: data?.nodes as AgentStudioNodeView[] | undefined,
viewport: data?.viewport as AgentStudioViewport | undefined,
});
const selected = data?.nodes?.find((node: any) => node.selected);
if (selected?.id && selected.id !== props.selectedNodeId) {
emit('select', selected.id);
}
}
function buildFlowDataSignature(data: any) {
const nodes = Array.isArray(data?.nodes)
? data.nodes.map((node: any) => ({
data: node.data,
id: node.id,
position: node.position,
selected: node.selected,
type: node.type,
}))
: [];
const edges = Array.isArray(data?.edges)
? data.edges.map((edge: any) => ({
id: edge.id,
source: edge.source,
target: edge.target,
}))
: [];
return JSON.stringify({
edges,
nodes,
viewport: data?.viewport,
});
}
const stableFlowData = shallowRef(flowData.value);
let resizeObserver: ResizeObserver | undefined;
function syncFlowData() {
const next = flowData.value;
const signature = buildFlowDataSignature(next);
if (signature === lastFlowDataSignature.value) {
return;
}
lastFlowDataSignature.value = signature;
stableFlowData.value = next;
}
syncFlowData();
watch(flowData, syncFlowData);
function updateCanvasSize() {
const rect = canvasRef.value?.getBoundingClientRect();
if (!rect) return;
const next = {
height: rect.height,
width: rect.width,
};
if (
canvasSize.value?.height === next.height &&
canvasSize.value.width === next.width
) {
return;
}
canvasSize.value = next;
}
onMounted(() => {
updateCanvasSize();
if (typeof ResizeObserver === 'undefined' || !canvasRef.value) return;
resizeObserver = new ResizeObserver(updateCanvasSize);
resizeObserver.observe(canvasRef.value);
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
</script>
<template>
<div ref="canvasRef" class="agent-studio-canvas">
<Tinyflow
class="agent-studio-canvas__flow"
:data="stableFlowData"
:theme="isDark ? 'dark' : 'light'"
:custom-nodes="customNodes"
:hide-bottom-dock="true"
:hide-edge-panel="true"
:hide-mini-map="true"
:hide-node-handles="true"
:hide-node-picker="true"
:hide-node-setting="true"
:hide-node-toolbar="true"
:hide-edge-markers="true"
:edge-animated="false"
:edge-interaction-width="0"
:nodes-draggable="true"
:nodes-connectable="false"
:elements-selectable="true"
:drop-enabled="false"
:on-data-change="handleDataChange"
/>
<AgentStudioEdgeLayer :connections="connections" />
</div>
</template>
<style scoped>
.agent-studio-canvas {
position: absolute;
inset: 0;
overflow: hidden;
}
.agent-studio-canvas__flow {
width: 100%;
height: 100%;
}
.agent-studio-canvas :deep(.tinyflow),
.agent-studio-canvas :deep(.agentsflow) {
width: 100%;
height: 100% !important;
}
.agent-studio-canvas :deep(.svelte-flow__node) {
z-index: 2 !important;
border: 0;
border-radius: 18px;
}
.agent-studio-canvas :deep(.svelte-flow__node::after) {
display: none;
}
.agent-studio-canvas :deep(.svelte-flow__node:hover),
.agent-studio-canvas :deep(.svelte-flow__node.selectable.selected) {
border: 0;
box-shadow: none;
}
.agent-studio-canvas :deep(.svelte-flow__handle),
.agent-studio-canvas :deep(.svelte-flow__minimap),
.agent-studio-canvas :deep(.tf-left-dock),
.agent-studio-canvas :deep(.tf-top-dock),
.agent-studio-canvas :deep(.tf-bottom-dock),
.agent-studio-canvas :deep(.tf-node-toolbar) {
display: none !important;
}
.agent-studio-canvas :deep(.tf-flow-line-path) {
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,
var(--el-border-color)
);
stroke-width: 1.5;
}
</style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type {AgentStudioNodeRenderProps} from './types';
import AgentStudioNode from './AgentStudioNode.vue';
defineProps<AgentStudioNodeRenderProps>();
</script>
<template>
<AgentStudioNode :data="data" />
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type {AgentStudioConnectionView} from './types';
defineProps<{
connections: AgentStudioConnectionView[];
}>();
</script>
<template>
<svg class="agent-studio-edge-layer" aria-hidden="true">
<path
v-for="connection in connections"
:key="connection.id"
class="agent-studio-edge-layer__path"
:class="{ 'is-active': connection.active }"
:d="connection.path"
:data-source-id="connection.sourceId"
:data-target-id="connection.targetId"
/>
</svg>
</template>
<style scoped>
.agent-studio-edge-layer {
position: absolute;
inset: 0;
z-index: 1;
overflow: visible;
pointer-events: none;
}
.agent-studio-edge-layer__path {
fill: none;
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,
var(--el-border-color)
);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.agent-studio-edge-layer__path.is-active {
stroke: var(--el-color-primary);
stroke-width: 2.2;
}
</style>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import type {AgentStudioNodeData} from './types';
import {computed} from 'vue';
import {Connection, Cpu, Files, Share} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
const props = defineProps<{
data: AgentStudioNodeData;
}>();
const iconComponent = computed(() => {
const icons = {
base: Cpu,
knowledge: Files,
plugin: Connection,
workflow: Share,
};
return icons[props.data.iconKey];
});
</script>
<template>
<button
class="agent-studio-node"
:class="[
`agent-studio-node--${data.kind}`,
{ 'is-selected': data.selected },
]"
type="button"
:aria-pressed="data.selected"
>
<span class="agent-studio-node__icon">
<ElIcon><component :is="iconComponent" /></ElIcon>
</span>
<span class="agent-studio-node__content">
<span class="agent-studio-node__header">
<span class="agent-studio-node__title">{{ data.title }}</span>
<span v-if="data.badge" class="agent-studio-node__badge">
{{ data.badge }}
</span>
</span>
<span v-if="data.detail" class="agent-studio-node__detail">
{{ data.detail }}
</span>
</span>
</button>
</template>
<style scoped>
.agent-studio-node {
box-sizing: border-box;
display: flex;
gap: 12px;
align-items: center;
width: 212px;
padding: 13px 14px;
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
background: color-mix(in srgb, var(--el-bg-color) 96%, transparent);
border: 1px solid var(--el-border-color-lighter);
border-radius: 16px;
box-shadow: 0 10px 28px rgb(15 23 42 / 8%);
transition:
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.agent-studio-node:hover {
background: var(--el-bg-color);
border-color: color-mix(
in srgb,
var(--el-color-primary) 26%,
var(--el-border-color-lighter)
);
box-shadow: 0 14px 32px rgb(15 23 42 / 10%);
transform: translateY(-1px);
}
.agent-studio-node:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 3px;
}
.agent-studio-node.is-selected {
background: color-mix(
in srgb,
var(--el-color-primary-light-9) 54%,
var(--el-bg-color)
);
border-color: color-mix(
in srgb,
var(--el-color-primary) 42%,
var(--el-border-color)
);
box-shadow: 0 16px 36px rgb(11 111 211 / 14%);
}
.agent-studio-node--base {
width: 268px;
min-height: 104px;
padding: 18px;
border-radius: 18px;
}
.agent-studio-node--knowledge,
.agent-studio-node--workflow,
.agent-studio-node--plugin {
min-height: 78px;
}
.agent-studio-node__icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 24px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 14px;
}
.agent-studio-node--base .agent-studio-node__icon {
width: 50px;
height: 50px;
font-size: 28px;
background: color-mix(
in srgb,
var(--el-color-primary-light-8) 74%,
var(--el-bg-color)
);
}
.agent-studio-node__content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.agent-studio-node__header {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.agent-studio-node__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 650;
line-height: 22px;
white-space: nowrap;
}
.agent-studio-node--base .agent-studio-node__title {
font-size: 18px;
line-height: 26px;
}
.agent-studio-node__badge {
flex: 0 0 auto;
padding: 2px 7px;
font-size: 11px;
line-height: 16px;
color: var(--el-color-primary);
background: color-mix(
in srgb,
var(--el-color-primary-light-9) 74%,
var(--el-bg-color)
);
border-radius: 999px;
}
.agent-studio-node__detail {
display: -webkit-box;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-regular);
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,59 @@
import type {AgentCapabilityKind} from '../../types';
export type AgentStudioNodeKind = 'base' | AgentCapabilityKind;
export interface AgentStudioNodeData {
badge?: string;
detail?: string;
iconKey: AgentStudioNodeKind;
id: string;
kind: AgentStudioNodeKind;
selected: boolean;
title: string;
}
export interface AgentStudioNodeRenderProps {
data: AgentStudioNodeData;
}
export interface AgentStudioNodeView {
id: string;
position: {
x: number;
y: number;
};
height?: number;
type: string;
width?: number;
data: AgentStudioNodeData;
}
export interface AgentStudioEdgeView {
id: string;
source: string;
target: string;
}
export interface AgentStudioConnectionView {
active?: boolean;
id: string;
path: string;
sourceId: string;
targetId: string;
}
export interface AgentStudioViewport {
x: number;
y: number;
zoom: number;
}
export interface AgentStudioCanvasSize {
height: number;
width: number;
}
export interface AgentStudioLayoutSnapshot {
nodePositions: Record<string, { x: number; y: number }>;
viewport?: AgentStudioViewport;
}

View File

@@ -0,0 +1,161 @@
import type {
AgentStudioLayoutSnapshot,
AgentStudioNodeView,
AgentStudioViewport,
} from '../../components/agent-studio/types';
import type {AgentDraftState} from '../../types';
import {computed, onBeforeUnmount, reactive, toRaw, watch} from 'vue';
const STORAGE_PREFIX = 'agent-studio-layout';
const WRITE_DELAY = 260;
function readLayout(key: string): AgentStudioLayoutSnapshot {
if (typeof window === 'undefined') {
return { nodePositions: {} };
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) return { nodePositions: {} };
const parsed = JSON.parse(raw) as AgentStudioLayoutSnapshot;
return {
nodePositions: parsed.nodePositions || {},
viewport: parsed.viewport,
};
} catch {
return { nodePositions: {} };
}
}
function isFinitePosition(
position: unknown,
): position is { x: number; y: number } {
return (
typeof position === 'object' &&
position !== null &&
Number.isFinite((position as { x?: number }).x) &&
Number.isFinite((position as { y?: number }).y)
);
}
function isFiniteViewport(viewport: unknown): viewport is AgentStudioViewport {
return (
isFinitePosition(viewport) &&
Number.isFinite((viewport as { zoom?: number }).zoom)
);
}
export function useAgentStudioLayout(state: AgentDraftState) {
const storageKey = computed(() => {
const agentId = state.agent.id ? String(state.agent.id) : 'new';
return `${STORAGE_PREFIX}:${agentId}`;
});
const snapshot = reactive<AgentStudioLayoutSnapshot>({
nodePositions: {},
});
let writeTimer: number | undefined;
function load() {
const next = readLayout(storageKey.value);
snapshot.nodePositions = { ...next.nodePositions };
snapshot.viewport = next.viewport;
}
function hasCapturedLayout() {
return (
Object.keys(snapshot.nodePositions).length > 0 ||
isFiniteViewport(snapshot.viewport)
);
}
function persistSnapshot() {
if (typeof window === 'undefined') return;
window.localStorage.setItem(
storageKey.value,
JSON.stringify(toRaw(snapshot)),
);
}
function scheduleWrite() {
if (typeof window === 'undefined') return;
if (writeTimer) {
window.clearTimeout(writeTimer);
}
writeTimer = window.setTimeout(() => {
persistSnapshot();
writeTimer = undefined;
}, WRITE_DELAY);
}
function capture(data: {
nodes?: AgentStudioNodeView[];
viewport?: AgentStudioViewport;
}) {
let changed = false;
if (Array.isArray(data.nodes)) {
const activeIds = new Set(data.nodes.map((node) => node.id));
const nextPositions: AgentStudioLayoutSnapshot['nodePositions'] = {};
for (const [key, position] of Object.entries(snapshot.nodePositions)) {
if (activeIds.has(key)) nextPositions[key] = position;
else changed = true;
}
data.nodes.forEach((node) => {
if (isFinitePosition(node.position)) {
const current = nextPositions[node.id];
if (
!current ||
current.x !== node.position.x ||
current.y !== node.position.y
) {
nextPositions[node.id] = { ...node.position };
changed = true;
}
}
});
snapshot.nodePositions = nextPositions;
}
if (
isFiniteViewport(data.viewport) &&
(!snapshot.viewport ||
snapshot.viewport.x !== data.viewport.x ||
snapshot.viewport.y !== data.viewport.y ||
snapshot.viewport.zoom !== data.viewport.zoom)
) {
snapshot.viewport = { ...data.viewport };
changed = true;
}
if (changed) {
scheduleWrite();
}
}
watch(
storageKey,
(nextKey, previousKey) => {
const persisted = readLayout(nextKey);
const shouldCarryNewDraftLayout =
previousKey?.endsWith(':new') &&
!persisted.viewport &&
Object.keys(persisted.nodePositions).length === 0 &&
hasCapturedLayout();
if (shouldCarryNewDraftLayout) {
persistSnapshot();
return;
}
load();
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (writeTimer && typeof window !== 'undefined') {
window.clearTimeout(writeTimer);
persistSnapshot();
}
});
return {
capture,
layout: snapshot,
};
}

View File

@@ -0,0 +1,59 @@
import {describe, expect, it} from 'vitest';
import {resolveCapabilityNodePosition} from './useAgentStudioModel';
describe('resolveCapabilityNodePosition', () => {
it('无视口信息时沿用默认左侧列位置', () => {
expect(
resolveCapabilityNodePosition({
fallbackIndex: 1,
nodeId: 'knowledge:new',
}),
).toEqual({ x: 70, y: 252 });
});
it('有视口信息时投放到当前可见区域左侧', () => {
expect(
resolveCapabilityNodePosition({
canvasSize: { height: 720, width: 1280 },
fallbackIndex: 0,
layout: {
nodePositions: {},
viewport: { x: 250, y: 100, zoom: 1 },
},
nodeId: 'tool:new',
}),
).toEqual({ x: -202, y: -4 });
});
it('新增位置与已有能力节点冲突时向下避让', () => {
expect(
resolveCapabilityNodePosition({
canvasSize: { height: 720, width: 1280 },
fallbackIndex: 0,
layout: {
nodePositions: {},
viewport: { x: 250, y: 100, zoom: 1 },
},
nodeId: 'tool:new',
occupiedPositions: [{ x: -202, y: -4 }],
}),
).toEqual({ x: -202, y: 100 });
});
it('已有持久化位置时优先保留用户拖拽后的布局', () => {
expect(
resolveCapabilityNodePosition({
canvasSize: { height: 720, width: 1280 },
fallbackIndex: 0,
layout: {
nodePositions: {
'tool:existing': { x: 128, y: 256 },
},
viewport: { x: 250, y: 100, zoom: 1 },
},
nodeId: 'tool:existing',
}),
).toEqual({ x: 128, y: 256 });
});
});

View File

@@ -0,0 +1,259 @@
import type {
AgentStudioCanvasSize,
AgentStudioEdgeView,
AgentStudioLayoutSnapshot,
AgentStudioNodeData,
AgentStudioNodeView,
} from '../../components/agent-studio/types';
import type {
AgentDraftState,
AgentKnowledgeBinding,
AgentOption,
AgentToolBinding,
} from '../../types';
import {computed} from 'vue';
const BASE_NODE_ID = 'agent-base';
const BASE_POSITION = { x: 430, y: 260 };
const CAPABILITY_GAP = 104;
const CAPABILITY_OFFSET_X = 360;
const CAPABILITY_START_Y = 148;
const CAPABILITY_NODE_HEIGHT = 78;
const CAPABILITY_NODE_WIDTH = 212;
const LEFT_SCREEN_PADDING = 48;
const TOP_SCREEN_PADDING = 96;
const INSPECTOR_RESERVED_WIDTH = 468;
const MIN_VISIBLE_WORKSPACE_WIDTH = 520;
function firstText(...values: unknown[]) {
const matched = values.find((value) => String(value || '').trim());
return matched ? String(matched).trim() : '';
}
function buildKnowledgeTitle(
binding: AgentKnowledgeBinding,
options: AgentOption[],
) {
const matchedOption = options.find(
(item) => String(item.value) === String(binding.knowledgeId),
);
return firstText(
binding.resourceSummary?.title,
binding.resourceSummary?.name,
binding.resourceSummary?.label,
binding.resourceSummary?.displayName,
binding.resourceSnapshot?.title,
binding.resourceSnapshot?.name,
binding.resourceSnapshot?.label,
binding.resourceSnapshot?.displayName,
binding.title,
binding.name,
binding.knowledgeName,
binding.collectionName,
matchedOption?.label,
matchedOption?.raw?.title,
matchedOption?.raw?.name,
);
}
function buildToolTitle(binding: AgentToolBinding) {
return firstText(
binding.resourceSummary?.title,
binding.resourceSummary?.name,
binding.resourceSnapshot?.title,
binding.resourceSnapshot?.name,
binding.toolName,
);
}
function buildToolDetail(binding: AgentToolBinding, fallback: string) {
const toolName = firstText(binding.toolName);
const resourceName = buildToolTitle(binding);
if (toolName && resourceName && toolName !== resourceName) {
return `${resourceName} / ${toolName}`;
}
return resourceName || toolName || fallback;
}
function toFlowPoint(
point: { x: number; y: number },
viewport: NonNullable<AgentStudioLayoutSnapshot['viewport']>,
) {
return {
x: (point.x - viewport.x) / viewport.zoom,
y: (point.y - viewport.y) / viewport.zoom,
};
}
function resolveVisibleLeftX(
layout?: AgentStudioLayoutSnapshot,
canvasSize?: AgentStudioCanvasSize,
) {
const viewport = layout?.viewport;
if (!viewport || !canvasSize?.width) {
return BASE_POSITION.x - CAPABILITY_OFFSET_X;
}
const availableWidth = Math.max(
MIN_VISIBLE_WORKSPACE_WIDTH,
canvasSize.width - INSPECTOR_RESERVED_WIDTH,
);
const screenX = Math.min(
LEFT_SCREEN_PADDING,
Math.max(24, availableWidth - CAPABILITY_NODE_WIDTH - LEFT_SCREEN_PADDING),
);
return toFlowPoint({ x: screenX, y: 0 }, viewport).x;
}
function resolveVisibleTopY(
layout?: AgentStudioLayoutSnapshot,
canvasSize?: AgentStudioCanvasSize,
) {
const viewport = layout?.viewport;
if (!viewport || !canvasSize?.height) {
return CAPABILITY_START_Y;
}
return toFlowPoint({ x: 0, y: TOP_SCREEN_PADDING }, viewport).y;
}
function hasVerticalOverlap(
position: { x: number; y: number },
occupied: Array<{ x: number; y: number }>,
) {
return occupied.some(
(item) =>
Math.abs(item.x - position.x) < CAPABILITY_NODE_WIDTH &&
Math.abs(item.y - position.y) < CAPABILITY_NODE_HEIGHT,
);
}
export function resolveCapabilityNodePosition(params: {
canvasSize?: AgentStudioCanvasSize;
fallbackIndex: number;
layout?: AgentStudioLayoutSnapshot;
nodeId: string;
occupiedPositions?: Array<{ x: number; y: number }>;
}) {
const persisted = params.layout?.nodePositions?.[params.nodeId];
if (persisted) return persisted;
const occupied = params.occupiedPositions || [];
const x = resolveVisibleLeftX(params.layout, params.canvasSize);
let y =
resolveVisibleTopY(params.layout, params.canvasSize) +
params.fallbackIndex * CAPABILITY_GAP;
while (hasVerticalOverlap({ x, y }, occupied)) {
y += CAPABILITY_GAP;
}
return { x, y };
}
export function useAgentStudioModel(
state: AgentDraftState,
selectedNodeId: () => string,
layout?: AgentStudioLayoutSnapshot,
canvasSize?: () => AgentStudioCanvasSize | undefined,
knowledgeOptions?: () => AgentOption[],
) {
return computed(() => {
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
layout?.nodePositions?.[nodeId] || fallback;
const size = canvasSize?.();
const occupiedPositions: Array<{ x: number; y: number }> = Object.values(
layout?.nodePositions || {},
);
const nodes: AgentStudioNodeView[] = [
{
id: BASE_NODE_ID,
type: 'agentStudioBase',
position: positionOf(BASE_NODE_ID, BASE_POSITION),
width: 268,
height: 104,
data: {
badge: '基座',
detail: firstText(state.agent.description, '等待配置核心提示词'),
iconKey: 'base',
id: BASE_NODE_ID,
kind: 'base',
selected: selectedNodeId() === BASE_NODE_ID,
title: firstText(state.agent.name, '未命名智能体'),
} satisfies AgentStudioNodeData,
},
];
const knowledgeNodes = state.knowledgeBindings.map((binding, index) => {
const nodeId = `knowledge:${binding.localId}`;
const title = buildKnowledgeTitle(binding, knowledgeOptions?.() || []);
const position = resolveCapabilityNodePosition({
canvasSize: size,
fallbackIndex: index,
layout,
nodeId,
occupiedPositions,
});
occupiedPositions.push(position);
return {
id: nodeId,
type: 'agentStudioCapability',
position,
width: CAPABILITY_NODE_WIDTH,
height: CAPABILITY_NODE_HEIGHT,
data: {
badge: '知识库',
detail: title || '待选择知识库',
iconKey: 'knowledge',
id: nodeId,
kind: 'knowledge',
selected: selectedNodeId() === nodeId,
title: title || '知识库',
} satisfies AgentStudioNodeData,
};
});
const toolNodes = state.toolBindings.map((binding, index) => {
const nodeId = `tool:${binding.localId}`;
const isWorkflow =
String(binding.toolType || '').toUpperCase() === 'WORKFLOW';
const fallback = isWorkflow ? '待选择工作流' : '待选择插件工具';
const detail = buildToolDetail(binding, fallback);
const position = resolveCapabilityNodePosition({
canvasSize: size,
fallbackIndex: state.knowledgeBindings.length + index,
layout,
nodeId,
occupiedPositions,
});
occupiedPositions.push(position);
return {
id: nodeId,
type: 'agentStudioCapability',
position,
width: CAPABILITY_NODE_WIDTH,
height: CAPABILITY_NODE_HEIGHT,
data: {
badge: isWorkflow ? '工作流' : '插件',
detail,
iconKey: isWorkflow ? 'workflow' : 'plugin',
id: nodeId,
kind: isWorkflow ? 'workflow' : 'plugin',
selected: selectedNodeId() === nodeId,
title:
detail === fallback ? (isWorkflow ? '工作流' : '插件') : detail,
} satisfies AgentStudioNodeData,
};
});
const capabilityNodes = [...knowledgeNodes, ...toolNodes];
const edges: AgentStudioEdgeView[] = capabilityNodes.map((node) => ({
id: `edge:${node.id}`,
source: BASE_NODE_ID,
target: node.id,
}));
nodes.push(...capabilityNodes);
return { edges, nodes };
});
}

View File

@@ -0,0 +1,371 @@
import type {
AgentCapabilityKind,
AgentDraftState,
AgentInfo,
AgentKnowledgeBinding,
AgentToolBinding,
AgentValidationIssue,
} from '../types';
import {computed, reactive} from 'vue';
const BASE_NODE_ID = 'agent-base';
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
function createLocalId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function isSafeToolName(name?: string) {
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
}
function buildFallbackToolName(prefix: string, resource?: Record<string, any>) {
const id = resource?.id ? String(resource.id) : createLocalId(prefix);
return `${prefix}_${id}`;
}
function resolveToolName(
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
resource?: Record<string, any>,
) {
if (isSafeToolName(resource?.englishName)) {
return String(resource?.englishName);
}
if (isSafeToolName(resource?.name)) {
return String(resource?.name);
}
return buildFallbackToolName(
kind === 'workflow' ? 'workflow' : 'plugin',
resource,
);
}
function normalizeBindingToolName(binding: AgentToolBinding) {
if (isSafeToolName(binding.toolName)) {
return String(binding.toolName);
}
const kind =
String(binding.toolType || '').toUpperCase() === 'WORKFLOW'
? 'workflow'
: 'plugin';
const resource = {
...(binding.resourceSnapshot || {}),
...(binding.resourceSummary || {}),
id:
binding.targetId ||
binding.resourceSummary?.id ||
binding.resourceSnapshot?.id,
};
return resolveToolName(kind, resource);
}
export function createEmptyAgent(): AgentInfo {
return {
name: '未命名智能体',
description: '',
avatar: '',
categoryId: '',
modelId: '',
promptConfigJson: { systemPrompt: '' },
memoryConfigJson: {
compressionParameter: {
enabled: true,
msgThreshold: 12,
lastKeep: 8,
minCompressionTokenThreshold: 6000,
},
},
status: 1,
};
}
function normalizeAgent(agent?: AgentInfo): AgentInfo {
const source = agent || {};
const memoryConfig = source.memoryConfigJson || {};
const compressionParameter = memoryConfig.compressionParameter || {};
const defaultCompressionParameter =
createEmptyAgent().memoryConfigJson?.compressionParameter || {};
const legacyMsgThreshold = memoryConfig.maxAttachedMessageCount;
return {
...createEmptyAgent(),
...source,
promptConfigJson: {
systemPrompt: '',
...source.promptConfigJson,
},
memoryConfigJson: {
...memoryConfig,
compressionParameter: {
enabled: true,
...compressionParameter,
msgThreshold:
compressionParameter.msgThreshold ??
legacyMsgThreshold ??
defaultCompressionParameter.msgThreshold,
lastKeep:
compressionParameter.lastKeep ?? defaultCompressionParameter.lastKeep,
minCompressionTokenThreshold:
compressionParameter.minCompressionTokenThreshold ??
defaultCompressionParameter.minCompressionTokenThreshold,
},
},
};
}
function normalizeKnowledgeBinding(
binding: AgentKnowledgeBinding,
index: number,
): AgentKnowledgeBinding {
return {
...binding,
enabled: binding.enabled !== false,
localId:
binding.localId || String(binding.id || createLocalId('knowledge')),
optionsJson: {
limit: 5,
scoreThreshold: 0.5,
...binding.optionsJson,
},
retrievalMode: binding.retrievalMode || 'HYBRID',
sortNo: binding.sortNo ?? index + 1,
};
}
function normalizeToolBinding(
binding: AgentToolBinding,
index: number,
): AgentToolBinding {
return {
...binding,
enabled: binding.enabled !== false,
hitlEnabled: Boolean(binding.hitlEnabled),
localId:
binding.localId ||
String(
binding.id ||
createLocalId(String(binding.toolType || 'tool').toLowerCase()),
),
toolName: normalizeBindingToolName(binding),
optionsJson: binding.optionsJson || {},
sortNo: binding.sortNo ?? index + 1,
};
}
export function useAgentDesignerState() {
const state = reactive<AgentDraftState>({
agent: createEmptyAgent(),
knowledgeBindings: [],
toolBindings: [],
selectedNodeId: BASE_NODE_ID,
panelMode: 'base',
dirty: false,
});
const selectedCapability = computed(() => {
if (state.selectedNodeId.startsWith('knowledge:')) {
const localId = state.selectedNodeId.slice('knowledge:'.length);
return {
kind: 'knowledge' as AgentCapabilityKind,
binding: state.knowledgeBindings.find(
(item) => item.localId === localId,
),
};
}
if (state.selectedNodeId.startsWith('tool:')) {
const localId = state.selectedNodeId.slice('tool:'.length);
const binding = state.toolBindings.find(
(item) => item.localId === localId,
);
return {
kind:
String(binding?.toolType || '').toUpperCase() === 'WORKFLOW'
? ('workflow' as AgentCapabilityKind)
: ('plugin' as AgentCapabilityKind),
binding,
};
}
return undefined;
});
function markDirty() {
state.dirty = true;
}
function reset(agent?: AgentInfo) {
state.agent = normalizeAgent(agent);
state.knowledgeBindings = (agent?.knowledgeBindings || []).map(
(binding, index) => normalizeKnowledgeBinding(binding, index),
);
state.toolBindings = (agent?.toolBindings || []).map((binding, index) =>
normalizeToolBinding(binding, index),
);
state.selectedNodeId = BASE_NODE_ID;
state.panelMode = 'base';
state.dirty = false;
}
function selectBase() {
state.selectedNodeId = BASE_NODE_ID;
state.panelMode = 'base';
}
function selectNode(nodeId: string) {
state.selectedNodeId = nodeId;
state.panelMode = nodeId === BASE_NODE_ID ? 'base' : 'capability';
}
function openTryout() {
state.panelMode = 'tryout';
}
function addKnowledgeNode(resource?: Record<string, any>) {
const binding = normalizeKnowledgeBinding(
{
knowledgeId: resource?.id ? String(resource.id) : '',
resourceSummary: resource || {},
},
state.knowledgeBindings.length,
);
state.knowledgeBindings.push(binding);
state.selectedNodeId = `knowledge:${binding.localId}`;
state.panelMode = 'capability';
markDirty();
}
function addToolNode(
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
resource?: Record<string, any>,
) {
const toolType = kind === 'workflow' ? 'WORKFLOW' : 'PLUGIN';
const binding = normalizeToolBinding(
{
toolType,
targetId: resource?.id ? String(resource.id) : '',
toolName: resolveToolName(kind, resource),
resourceSummary: resource || {},
},
state.toolBindings.length,
);
state.toolBindings.push(binding);
state.selectedNodeId = `tool:${binding.localId}`;
state.panelMode = 'capability';
markDirty();
}
function removeSelectedCapability() {
const selected = selectedCapability.value;
if (!selected?.binding?.localId) return;
if (selected.kind === 'knowledge') {
state.knowledgeBindings = state.knowledgeBindings.filter(
(item) => item.localId !== selected.binding?.localId,
);
} else {
state.toolBindings = state.toolBindings.filter(
(item) => item.localId !== selected.binding?.localId,
);
}
selectBase();
markDirty();
}
function validate(): AgentValidationIssue[] {
const issues: AgentValidationIssue[] = [];
if (!String(state.agent.name || '').trim()) {
issues.push({
nodeId: BASE_NODE_ID,
field: 'name',
message: '请填写 Agent 名称',
});
}
if (!state.agent.modelId) {
issues.push({
nodeId: BASE_NODE_ID,
field: 'modelId',
message: '请选择模型',
});
}
state.knowledgeBindings.forEach((binding) => {
if (!binding.knowledgeId) {
issues.push({
nodeId: `knowledge:${binding.localId}`,
field: 'knowledgeId',
message: '请选择知识库',
});
}
});
state.toolBindings.forEach((binding) => {
const nodeId = `tool:${binding.localId}`;
if (!binding.targetId) {
issues.push({ nodeId, field: 'targetId', message: '请选择能力资源' });
}
if (!String(binding.toolName || '').trim()) {
issues.push({ nodeId, field: 'toolName', message: '请填写工具名称' });
} else if (!isSafeToolName(binding.toolName)) {
issues.push({
nodeId,
field: 'toolName',
message: '工具名称只能包含英文、数字、下划线或中划线',
});
}
});
return issues;
}
function buildPayloadAgent(): AgentInfo {
const memoryConfigJson = state.agent.memoryConfigJson || {};
const compressionParameter = memoryConfigJson.compressionParameter || {};
const { maxAttachedMessageCount, ...restMemoryConfigJson } =
memoryConfigJson;
return {
...state.agent,
memoryConfigJson: {
...restMemoryConfigJson,
compressionParameter: {
...compressionParameter,
enabled: true,
},
},
status: state.agent.status ?? 1,
};
}
function buildKnowledgePayload(agentId?: number | string) {
return state.knowledgeBindings.map((binding, index) => ({
...binding,
agentId,
enabled: binding.enabled !== false,
sortNo: index + 1,
}));
}
function buildToolPayload(agentId?: number | string) {
return state.toolBindings.map((binding, index) => ({
...binding,
agentId,
enabled: binding.enabled !== false,
hitlEnabled: Boolean(binding.hitlEnabled),
sortNo: index + 1,
}));
}
reset();
return {
BASE_NODE_ID,
state,
selectedCapability,
addKnowledgeNode,
addToolNode,
buildKnowledgePayload,
buildPayloadAgent,
buildToolPayload,
markDirty,
openTryout,
removeSelectedCapability,
reset,
selectBase,
selectNode,
validate,
};
}

View File

@@ -0,0 +1,213 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds';
describe('useAgentTryoutRawRounds', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('按原始事件顺序生成 timeline', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-1',
});
const roundId = store.createRound('上一轮问题');
store.recordEvent(roundId, {
domain: 'LLM',
payload: { reasoning: '先思考' },
type: 'THINKING',
});
store.recordEvent(roundId, {
domain: 'LLM',
payload: { delta: '上一轮回答' },
type: 'MESSAGE',
});
store.completeRound(roundId);
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
'message',
'message',
]);
});
it('业务引用只用于展示', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-2',
});
const roundId = store.createRound('查知识库');
store.recordEvent(roundId, {
domain: 'BUSINESS',
payload: {
items: [
{
chunkContent: '知识库原文',
chunkId: 'chunk-1',
documentId: 'doc-1',
knowledgeId: 'kb-1',
},
],
},
type: 'CITATIONS',
});
store.recordEvent(roundId, {
domain: 'LLM',
payload: { delta: '引用后的回答' },
type: 'MESSAGE',
});
store.completeRound(roundId);
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
'message',
'knowledge',
'message',
]);
});
it('AgentScope fragment 工具事件不进入页面时间线', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-fragment',
});
const roundId = store.createRound('调用内部片段');
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
input: { text: 'fragment' },
toolCallId: 'fragment-1',
toolName: '__fragment__',
},
type: 'TOOL_CALL',
});
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
output: 'internal',
toolCallId: 'fragment-1',
toolName: '__fragment__',
},
type: 'TOOL_RESULT',
});
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
'message',
]);
});
it('刷新后能从 raw rounds 恢复 timeline', () => {
const first = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-5',
});
const roundId = first.createRound('问题');
first.recordEvent(roundId, {
domain: 'LLM',
payload: { delta: '回答' },
type: 'MESSAGE',
});
first.completeRound(roundId);
const restored = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-5',
});
expect(restored.buildTimelineItems().map((item) => item.type)).toEqual([
'message',
'message',
]);
});
it('错误轮次不会被 completeRound 覆盖为成功状态', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-error',
});
const roundId = store.createRound('会失败的问题');
store.recordEvent(roundId, {
domain: 'LLM',
payload: { delta: '半截回答' },
type: 'MESSAGE',
});
store.recordEvent(roundId, {
domain: 'SYSTEM',
payload: { message: '调用失败' },
type: 'ERROR',
});
store.completeRound(roundId);
const assistant = store
.buildTimelineItems()
.find((item) => item.type === 'message' && item.role === 'assistant');
expect(assistant).toMatchObject({ status: 'error' });
});
it('流式重建 timeline 时保持稳定 item id', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-stable-id',
});
const roundId = store.createRound('问题');
store.recordEvent(roundId, {
domain: 'LLM',
payload: { delta: '你' },
type: 'MESSAGE',
});
const firstIds = store.buildTimelineItems().map((item) => item.id);
store.recordEvent(roundId, {
domain: 'LLM',
payload: { delta: '好' },
type: 'MESSAGE',
});
const secondIds = store.buildTimelineItems().map((item) => item.id);
expect(secondIds).toEqual(firstIds);
});
it('审批状态作为展示事件缓存并可刷新恢复', () => {
const first = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-approval',
});
const roundId = first.createRound('审批工具');
first.recordEvent(roundId, {
domain: 'TOOL',
payload: {
requestId: 'req-1',
resumeToken: 'resume-1',
toolCallId: 'call-approval',
toolName: 'dangerous_tool',
},
type: 'FORM_REQUEST',
});
first.recordEvent(roundId, {
domain: 'TOOL',
payload: {
requestId: 'req-1',
resumeToken: 'resume-1',
toolCallId: 'call-approval',
},
type: 'FORM_APPROVING',
});
first.flush();
const restored = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-approval',
});
const tool = restored
.buildTimelineItems()
.find((item) => item.type === 'tool');
expect(tool).toMatchObject({
status: 'approving',
toolCallId: 'call-approval',
});
});
});

View File

@@ -0,0 +1,796 @@
import type {
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
interface AgentTryoutRuntimeEvent {
createdAt: number;
domain: string;
payload: Record<string, unknown>;
type: string;
}
type AgentTryoutRoundStatus = 'completed' | 'error' | 'running';
interface AgentTryoutRawVariant {
createdAt: number;
runtimeEvents: AgentTryoutRuntimeEvent[];
status: AgentTryoutRoundStatus;
updatedAt: number;
variantIndex: number;
}
interface AgentTryoutRawRound {
createdAt: number;
prompt: string;
roundId: string;
selectedVariantIndex: number;
status: AgentTryoutRoundStatus;
updatedAt: number;
variants: AgentTryoutRawVariant[];
}
interface AgentTryoutRawSessionRecord {
rounds: AgentTryoutRawRound[];
sessionId: string;
version: number;
}
const STORAGE_VERSION = 1;
const MAX_ROUNDS = 50;
const MAX_VARIANTS = 10;
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
const PERSIST_DEBOUNCE_MS = 300;
const memorySessions = new Map<string, AgentTryoutRawRound[]>();
function createRoundId() {
return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function asText(value: unknown) {
return value === null || value === undefined ? '' : String(value);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asBoolean(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return Boolean(value);
}
function normalizeToolName(value: unknown) {
return asText(value).trim().toLowerCase();
}
function isHiddenToolName(value: unknown) {
const normalizedName = normalizeToolName(value);
return normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__';
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function storageKey(mode: string, sessionId: string) {
return `${STORAGE_PREFIX}:${mode}:${sessionId}`;
}
function safeSessionStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
function createVariant(variantIndex: number): AgentTryoutRawVariant {
const now = Date.now();
return {
createdAt: now,
runtimeEvents: [],
status: 'running',
updatedAt: now,
variantIndex,
};
}
function normalizeRuntimeEvent(value: any): AgentTryoutRuntimeEvent | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const domain = asText(value.domain).toUpperCase();
const type = asText(value.type).toUpperCase();
if (!domain || !type) {
return undefined;
}
return {
createdAt: Number(value.createdAt || Date.now()),
domain,
payload: asRecord(value.payload),
type,
};
}
function normalizeVariant(value: any, index: number) {
if (!value || typeof value !== 'object') {
return createVariant(index);
}
const runtimeEvents = Array.isArray(value.runtimeEvents)
? value.runtimeEvents
.map((item: any) => normalizeRuntimeEvent(item))
.filter(
(item: AgentTryoutRuntimeEvent | undefined): item is AgentTryoutRuntimeEvent =>
Boolean(item),
)
: [];
return {
createdAt: Number(value.createdAt || Date.now()),
runtimeEvents,
status:
value.status === 'completed' || value.status === 'error'
? value.status
: 'running',
updatedAt: Number(value.updatedAt || value.createdAt || Date.now()),
variantIndex: index,
};
}
function normalizeRound(value: any): AgentTryoutRawRound | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const prompt = asText(value.prompt);
const roundId = asText(value.roundId);
if (!prompt || !roundId) {
return undefined;
}
const variants = Array.isArray(value.variants)
? value.variants
.slice(-MAX_VARIANTS)
.map((item: any, index: number) => normalizeVariant(item, index + 1))
: [];
if (variants.length === 0) {
variants.push(createVariant(1));
}
const selectedVariantIndex = Math.min(
Math.max(Number(value.selectedVariantIndex || variants.length), 1),
variants.length,
);
return {
createdAt: Number(value.createdAt || Date.now()),
prompt,
roundId,
selectedVariantIndex,
status:
value.status === 'completed' || value.status === 'error'
? value.status
: 'running',
updatedAt: Number(value.updatedAt || value.createdAt || Date.now()),
variants,
};
}
function restoreSession(mode: string, sessionId: string) {
const key = storageKey(mode, sessionId);
const memoryRecords = memorySessions.get(key);
if (memoryRecords) {
return memoryRecords.map((item) => clone(item));
}
const storage = safeSessionStorage();
if (!storage) {
return [];
}
try {
const raw = storage.getItem(key);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as AgentTryoutRawSessionRecord;
if (parsed.sessionId !== sessionId || parsed.version !== STORAGE_VERSION) {
return [];
}
const rounds = Array.isArray(parsed.rounds)
? parsed.rounds
.map((item) => normalizeRound(item))
.filter((item): item is AgentTryoutRawRound => Boolean(item))
: [];
memorySessions.set(key, rounds.map((item) => clone(item)));
return rounds;
} catch {
return [];
}
}
function persistSession(
mode: string,
sessionId: string,
rounds: AgentTryoutRawRound[],
) {
const key = storageKey(mode, sessionId);
const snapshot: AgentTryoutRawSessionRecord = {
rounds: rounds.slice(-MAX_ROUNDS).map((item) => clone(item)),
sessionId,
version: STORAGE_VERSION,
};
memorySessions.set(key, snapshot.rounds.map((item) => clone(item)));
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.setItem(key, JSON.stringify(snapshot));
} catch {
// 试运行缓存失败不影响当前聊天主流程。
}
}
function removeStoredSession(mode: string, sessionId: string) {
const key = storageKey(mode, sessionId);
memorySessions.delete(key);
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(key);
} catch {
// 清理缓存失败不影响界面重置。
}
}
function selectedVariant(round: AgentTryoutRawRound) {
return (
round.variants.find(
(variant) => variant.variantIndex === round.selectedVariantIndex,
) || round.variants.at(-1)
);
}
function visibleText(item: ChatTimelineMessageItem) {
return item.parts
.filter((part) => part.type === 'text')
.map((part) => part.content)
.join('');
}
function isUserMessage(item: ChatTimelineItem): item is ChatTimelineMessageItem {
return item.type === 'message' && item.role === 'user';
}
function isAssistantMessage(
item: ChatTimelineItem,
): item is ChatTimelineMessageItem {
return item.type === 'message' && item.role === 'assistant';
}
function findRoundResponseRange(items: ChatTimelineItem[], roundId: string) {
const userIndex = items.findIndex(
(item) => isUserMessage(item) && item.roundId === roundId,
);
if (userIndex < 0) {
return undefined;
}
const nextUserIndex = items.findIndex(
(item, index) => index > userIndex && isUserMessage(item),
);
return {
end: nextUserIndex >= 0 ? nextUserIndex : items.length,
start: userIndex + 1,
};
}
function assistantSegmentIndex(items: ChatTimelineItem[], roundId: string) {
return items.filter(
(item) => isAssistantMessage(item) && item.roundId === roundId,
).length;
}
function nextAssistantId(
items: ChatTimelineItem[],
roundId: string,
variantIndex: number,
) {
const last = items[items.length - 1];
if (
last &&
isAssistantMessage(last) &&
last.roundId === roundId &&
last.status !== 'done'
) {
return last.id;
}
return `assistant-${roundId}-${variantIndex}-${assistantSegmentIndex(items, roundId) + 1}`;
}
function normalizeAssistantPartIds(
items: ChatTimelineItem[],
roundId: string,
variantIndex: number,
) {
const segment = assistantSegmentIndex(items, roundId);
const latest = [...items]
.reverse()
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item) && item.roundId === roundId);
if (!latest) {
return;
}
latest.id = `assistant-${roundId}-${variantIndex}-${segment}`;
latest.parts.forEach((part, index) => {
part.id = `${part.type}-${roundId}-${variantIndex}-${segment}-${index + 1}`;
});
}
function normalizeLatestItemId(
items: ChatTimelineItem[],
prefix: string,
roundId: string,
variantIndex: number,
) {
const last = items[items.length - 1];
if (!last) {
return;
}
last.id = `${prefix}-${roundId}-${variantIndex}`;
}
function markRoundCompleted(
items: ChatTimelineItem[],
roundId: string,
variant: AgentTryoutRawVariant,
variantCount: number,
selectedVariantIndex: number,
) {
const range = findRoundResponseRange(items, roundId);
const source = range ? items.slice(range.start, range.end) : items;
const latest = [...source]
.reverse()
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item));
if (!latest) {
return;
}
latest.roundCompleted = true;
latest.status = latest.status === 'error' ? 'error' : 'done';
latest.regenerable = true;
latest.switchable = variantCount > 1;
latest.variantCount = variantCount;
latest.variantIndex = variant.variantIndex;
latest.selectedVariantIndex = selectedVariantIndex;
}
function normalizeKnowledgeItems(payload: Record<string, unknown>) {
const source =
payload.items ||
payload.hits ||
payload.documents ||
payload.knowledgeResults ||
[];
if (!Array.isArray(source)) {
return [];
}
const topLevelKnowledgeType = asText(payload.knowledgeType);
const topLevelFaqCollection =
payload.faqCollection === undefined
? topLevelKnowledgeType.toUpperCase() === 'FAQ'
: asBoolean(payload.faqCollection);
return source.map((item: any) => {
const metadata = asRecord(item.metadata);
const sourceFileName = asText(
item.sourceFileName ?? metadata.sourceFileName,
);
const documentName = asText(
item.documentName ?? item.documentTitle ?? item.title,
);
const chunkId = asText(item.chunkId ?? metadata.chunkId ?? item.id);
const documentId = asText(
item.documentId ?? metadata.documentId ?? item.id,
);
return {
...item,
id: asText(item.id || chunkId || documentId),
knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId),
knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName),
knowledgeType: asText(item.knowledgeType ?? payload.knowledgeType),
faqCollection:
item.faqCollection === undefined
? topLevelFaqCollection
: asBoolean(item.faqCollection),
documentId,
documentName,
chunkId,
score: item.score ?? item.similarity,
source: item.source,
sourceFileName,
sourceUri: asText(item.sourceUri ?? metadata.sourceUri),
metadata,
chunkContent: asText(
item.chunkContent ?? item.content ?? item.text ?? item.summary,
),
content: asText(item.content ?? item.text ?? item.summary),
title: documentName || sourceFileName || item.source,
} satisfies ChatTimelineKnowledgeHit;
});
}
function statusKeyForProjection(
payload: Record<string, unknown>,
roundId: string,
variantIndex: number,
fallback = 'status',
) {
const statusKey = asText(payload.statusKey) || fallback;
return `${statusKey}:${roundId}:${variantIndex}`;
}
function projectEventToTimeline(
items: ChatTimelineItem[],
event: AgentTryoutRuntimeEvent,
roundId: string,
variantIndex: number,
) {
const { domain, payload, type } = event;
if (domain === 'LLM' && type === 'MESSAGE') {
ChatTimelineBuilder.appendMessageDelta(items, payload.delta, {
id: nextAssistantId(items, roundId, variantIndex),
roundId,
});
normalizeAssistantPartIds(items, roundId, variantIndex);
return;
}
if (domain === 'LLM' && type === 'THINKING') {
const text = asText(payload.reasoning ?? payload.delta ?? payload.text);
ChatTimelineBuilder.appendThinkingDelta(items, text, {
id: nextAssistantId(items, roundId, variantIndex),
roundId,
});
normalizeAssistantPartIds(items, roundId, variantIndex);
return;
}
if (domain === 'TOOL' && type === 'FORM_REQUEST') {
ChatTimelineBuilder.appendToolApproval(items, {
expiresAt: asText(payload.expiresAt),
input: payload.input,
metadata: payload.metadata,
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
toolDisplayName: asText(payload.toolDisplayName),
toolName: asText(payload.toolName),
toolType: asText(payload.toolType),
});
if (items[items.length - 1]?.type === 'tool') {
normalizeLatestItemId(items, 'tool-approval', roundId, variantIndex);
}
return;
}
if (domain === 'TOOL' && type === 'FORM_APPROVING') {
ChatTimelineBuilder.markToolApproving(items, {
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
});
return;
}
if (domain === 'TOOL' && type === 'FORM_REJECTED') {
ChatTimelineBuilder.markToolRejected(items, {
reason: asText(payload.reason),
requestId: asText(payload.requestId),
resumeToken: asText(payload.resumeToken),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
});
return;
}
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
const rawToolName = asText(payload.toolName ?? payload.name);
const normalizedToolName = normalizeToolName(rawToolName);
if (!normalizedToolName && type === 'TOOL_CALL') {
return;
}
const displayToolName = asText(
payload.toolDisplayName ?? rawToolName ?? '工具',
);
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: payload.output ?? payload.result ?? payload.text,
status: type === 'TOOL_RESULT' ? 'success' : 'running',
statusKey: statusKeyForProjection(
payload,
roundId,
variantIndex,
'knowledge-retrieval',
),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
toolName: isHiddenToolName(rawToolName) ? rawToolName : displayToolName,
});
return;
}
if (domain === 'BUSINESS' && type === 'CITATIONS') {
const itemsToAppend = normalizeKnowledgeItems(payload);
if (itemsToAppend.length > 0) {
ChatTimelineBuilder.appendKnowledge(items, itemsToAppend);
if (items[items.length - 1]?.type === 'knowledge') {
normalizeLatestItemId(items, 'knowledge', roundId, variantIndex);
}
}
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: statusKeyForProjection(payload, roundId, variantIndex),
});
return;
}
if (asText(payload.statusKey) === 'knowledge-retrieval') {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
asText(payload.status) === 'running' ? 'running' : 'done',
statusKeyForProjection(payload, roundId, variantIndex),
);
}
return;
}
if (type === 'ERROR' || domain === 'ERROR') {
ChatTimelineBuilder.appendError(
items,
payload.message ?? payload.error ?? '试运行失败',
);
normalizeLatestItemId(items, 'error', roundId, variantIndex);
}
}
function sortedRounds(rounds: Map<string, AgentTryoutRawRound>) {
return [...rounds.values()].sort(
(first, second) =>
(first.createdAt || first.updatedAt) -
(second.createdAt || second.updatedAt),
);
}
export function useAgentTryoutRawRounds(options: {
mode: 'draft';
sessionId: string;
}) {
const rounds = new Map<string, AgentTryoutRawRound>();
let persistTimer: ReturnType<typeof setTimeout> | undefined;
for (const round of restoreSession(options.mode, options.sessionId)) {
rounds.set(round.roundId, round);
}
function persistNow() {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
const overflow = rounds.size - MAX_ROUNDS;
if (overflow > 0) {
for (const round of sortedRounds(rounds).slice(0, overflow)) {
rounds.delete(round.roundId);
}
}
persistSession(options.mode, options.sessionId, [...rounds.values()]);
}
function schedulePersist() {
const key = storageKey(options.mode, options.sessionId);
memorySessions.set(key, [...rounds.values()].map((item) => clone(item)));
if (persistTimer) {
return;
}
persistTimer = setTimeout(() => {
persistNow();
}, PERSIST_DEBOUNCE_MS);
}
function clear() {
rounds.clear();
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
removeStoredSession(options.mode, options.sessionId);
}
function createRound(prompt: string) {
const now = Date.now();
const roundId = createRoundId();
rounds.set(roundId, {
createdAt: now,
prompt,
roundId,
selectedVariantIndex: 1,
status: 'running',
updatedAt: now,
variants: [createVariant(1)],
});
persistNow();
return roundId;
}
function regenerateRound(roundId: string) {
const round = rounds.get(roundId);
if (!round) {
return undefined;
}
const nextVariantIndex = Math.min(round.variants.length + 1, MAX_VARIANTS);
round.variants.push(createVariant(round.variants.length + 1));
if (round.variants.length > MAX_VARIANTS) {
round.variants.splice(0, round.variants.length - MAX_VARIANTS);
round.variants.forEach((variant, index) => {
variant.variantIndex = index + 1;
});
}
round.selectedVariantIndex = nextVariantIndex;
round.status = 'running';
round.updatedAt = Date.now();
persistNow();
return round.roundId;
}
function getPrompt(roundId?: string) {
return roundId ? rounds.get(roundId)?.prompt || '' : '';
}
function currentVariant(roundId: string) {
const round = rounds.get(roundId);
return round ? selectedVariant(round) : undefined;
}
function recordEvent(
roundId: string,
event: {
domain: string;
payload?: Record<string, unknown>;
type: string;
},
) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) {
return;
}
const runtimeEvent: AgentTryoutRuntimeEvent = {
createdAt: Date.now(),
domain: event.domain.toUpperCase(),
payload: event.payload || {},
type: event.type.toUpperCase(),
};
variant.runtimeEvents.push(runtimeEvent);
if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') {
variant.status = 'completed';
round.status = 'completed';
}
if (runtimeEvent.type === 'ERROR' || runtimeEvent.domain === 'ERROR') {
variant.status = 'error';
round.status = 'error';
}
variant.updatedAt = Date.now();
round.updatedAt = variant.updatedAt;
if (
(runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') ||
runtimeEvent.type === 'ERROR' ||
runtimeEvent.domain === 'ERROR'
) {
persistNow();
return;
}
schedulePersist();
}
function completeRound(roundId: string) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) {
return;
}
if (variant.status === 'error' || round.status === 'error') {
persistNow();
return;
}
variant.status = 'completed';
round.status = 'completed';
variant.updatedAt = Date.now();
round.updatedAt = variant.updatedAt;
persistNow();
}
function buildTimelineItems() {
const items: ChatTimelineItem[] = [];
for (const round of sortedRounds(rounds)) {
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
id: `user-${round.roundId}`,
roundId: round.roundId,
});
const variant = selectedVariant(round);
if (!variant) {
continue;
}
for (const event of variant.runtimeEvents) {
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
}
if (variant.status === 'completed' || variant.status === 'error') {
ChatTimelineBuilder.finalize(items);
markRoundCompleted(
items,
round.roundId,
variant,
round.variants.length,
round.selectedVariantIndex,
);
}
}
return items;
}
function selectVariant(
roundId: string,
direction: 'next' | 'previous',
) {
const round = rounds.get(roundId);
if (!round) {
return;
}
const next =
direction === 'previous'
? round.selectedVariantIndex - 1
: round.selectedVariantIndex + 1;
if (next < 1 || next > round.variants.length) {
return;
}
round.selectedVariantIndex = next;
round.updatedAt = Date.now();
persistNow();
}
function canSwitch(
item: ChatTimelineMessageItem,
direction: 'next' | 'previous',
) {
const current = Number(item.selectedVariantIndex || item.variantIndex || 1);
const total = Number(item.variantCount || 1);
return direction === 'previous' ? current > 1 : current < total;
}
function copyText(item: ChatTimelineMessageItem) {
return visibleText(item);
}
return {
buildTimelineItems,
canSwitch,
clear,
completeRound,
copyText,
createRound,
currentVariant,
getPrompt,
recordEvent,
regenerateRound,
selectVariant,
flush: persistNow,
};
}
export type {
AgentTryoutRawRound,
AgentTryoutRawVariant,
AgentTryoutRuntimeEvent,
};

View File

@@ -0,0 +1,324 @@
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type {
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
import {ref} from 'vue';
import {sseClient} from '#/api/request';
import {clearAgentDraftSession} from '../api';
import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds';
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);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function isEndOfRoundEvent(domain: string, type: string) {
return domain === 'SYSTEM' && type === 'DONE';
}
interface DraftRuntimeContext {
agent: AgentInfo;
knowledgeBindings: AgentKnowledgeBinding[];
toolBindings: AgentToolBinding[];
}
export function useAgentTryoutStream() {
const timelineItems = ref<ChatTimelineItemType[]>([]);
const loading = ref(false);
let rawRounds: ReturnType<typeof useAgentTryoutRawRounds> | undefined;
let activeRoundId = '';
let activeSessionId = '';
let userStopped = false;
function errorMessageOf(error: unknown) {
if (error instanceof Error) {
return `${error.name} ${error.message}`.trim();
}
if (typeof error === 'string') {
return error;
}
const value = asRecord(error);
const nested = asRecord(value.cause);
return [value.name, value.message, value.error, nested.name, nested.message]
.map((item) => asText(item).trim())
.filter(Boolean)
.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();
loading.value = false;
}
function rebuildTimeline() {
timelineItems.value = rawRounds?.buildTimelineItems() || [];
}
function syncDraftContext(payload: DraftRuntimeContext, restore = false) {
const sessionId = resolveDraftSessionId(payload.agent);
const sessionChanged = activeSessionId !== sessionId;
activeSessionId = sessionId;
if (!rawRounds || sessionChanged) {
rawRounds = useAgentTryoutRawRounds({
mode: 'draft',
sessionId,
});
activeRoundId = '';
}
if (restore && sessionChanged && !loading.value) {
rebuildTimeline();
}
}
function markRoundCompleted(roundId: string) {
if (!roundId) {
return;
}
rawRounds?.completeRound(roundId);
rebuildTimeline();
}
function finishAssistant() {
ChatTimelineBuilder.finalize(timelineItems.value);
}
function markToolApproving(payload: {
requestId?: string;
resumeToken?: string;
toolCallId?: string;
}) {
rawRounds?.recordEvent(activeRoundId, {
domain: 'TOOL',
payload,
type: 'FORM_APPROVING',
});
rawRounds?.flush();
rebuildTimeline();
}
function markToolRejected(payload: {
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) {
rawRounds?.recordEvent(activeRoundId, {
domain,
payload,
type,
});
rebuildTimeline();
}
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);
return;
}
if (type === 'ERROR' || domain === 'ERROR') {
const message = payload.message ?? payload.error ?? '试运行失败';
if (shouldIgnoreStoppedError(message)) {
return;
}
}
}
async function runDraft(payload: {
agent: AgentInfo;
knowledgeBindings: AgentKnowledgeBinding[];
prompt: string;
toolBindings: AgentToolBinding[];
}) {
syncDraftContext(payload);
if (!rawRounds) {
return;
}
activeRoundId = rawRounds.createRound(payload.prompt);
rebuildTimeline();
loading.value = true;
userStopped = false;
await sseClient.post(
'/api/v1/agent/chat/draft',
{
...payload,
sessionId: activeSessionId,
},
{
onMessage: handleMessage,
onError: (error) => {
if (shouldIgnoreStoppedError(error)) {
return;
}
rawRounds?.recordEvent(activeRoundId, {
domain: 'SYSTEM',
payload: {
message: error?.message ?? '试运行失败,请稍后再试',
},
type: 'ERROR',
});
rebuildTimeline();
finishAssistant();
rawRounds?.flush();
loading.value = false;
},
onFinished: () => {
if (userStopped) {
return;
}
finishAssistant();
markRoundCompleted(activeRoundId);
rawRounds?.flush();
loading.value = false;
},
},
);
}
async function sendDraft(payload: {
agent: AgentInfo;
knowledgeBindings: AgentKnowledgeBinding[];
prompt: string;
toolBindings: AgentToolBinding[];
}) {
await runDraft(payload);
}
async function regenerateDraft(item: ChatTimelineMessageItem) {
// 有状态 runtime 的历史重生成需要后端 session fork/rollback第一版先禁用入口。
void item;
}
function selectVariant(
item: ChatTimelineMessageItem,
direction: 'next' | 'previous',
) {
if (!item.roundId || !rawRounds?.canSwitch(item, direction)) {
return;
}
rawRounds.selectVariant(item.roundId, direction);
rebuildTimeline();
}
function copyMessageText(item: ChatTimelineMessageItem) {
return rawRounds?.copyText(item) || '';
}
async function clearDraftSession() {
if (loading.value) {
userStopped = true;
sseClient.abort();
loading.value = false;
}
const sessionId = activeSessionId;
rawRounds?.clear();
timelineItems.value = [];
activeRoundId = '';
if (sessionId) {
await clearAgentDraftSession(sessionId);
}
}
function stop() {
if (!loading.value) {
return;
}
userStopped = true;
sseClient.abort();
finishStoppedRun();
}
return {
loading,
clearDraftSession,
markToolApproving,
markToolRejected,
copyMessageText,
regenerateDraft,
selectVariant,
syncDraftContext,
timelineItems,
sendDraft,
stop,
};
}

View File

@@ -0,0 +1,83 @@
/* cspell:ignore hitl */
export type AgentPanelMode = 'base' | 'capability' | 'tryout';
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow';
export interface AgentInfo {
id?: number | string;
name?: string;
description?: string;
avatar?: string;
categoryId?: number | string;
modelId?: number | string;
modelConfigJson?: Record<string, any>;
generationConfigJson?: Record<string, any>;
promptConfigJson?: Record<string, any>;
memoryConfigJson?: Record<string, any>;
executionConfigJson?: Record<string, any>;
status?: number;
visibilityScope?: string;
publishStatus?: string;
displayPublishStatus?: string;
approvalPending?: boolean;
currentApprovalActionType?: string;
currentApprovalInstanceId?: number | string;
publishedSnapshotJson?: Record<string, any>;
toolBindings?: AgentToolBinding[];
knowledgeBindings?: AgentKnowledgeBinding[];
created?: string;
createdByName?: string;
[key: string]: any;
}
export interface AgentToolBinding {
id?: number | string;
agentId?: number | string;
toolType: 'PLUGIN' | 'WORKFLOW' | string;
targetId?: number | string;
toolName?: string;
enabled?: boolean;
hitlEnabled?: boolean;
hitlConfigJson?: Record<string, any>;
optionsJson?: Record<string, any>;
resourceSnapshot?: Record<string, any>;
resourceSummary?: Record<string, any>;
sortNo?: number;
localId?: string;
[key: string]: any;
}
export interface AgentKnowledgeBinding {
id?: number | string;
agentId?: number | string;
knowledgeId?: number | string;
retrievalMode?: string;
enabled?: boolean;
optionsJson?: Record<string, any>;
resourceSnapshot?: Record<string, any>;
resourceSummary?: Record<string, any>;
sortNo?: number;
localId?: string;
[key: string]: any;
}
export interface AgentDraftState {
agent: AgentInfo;
toolBindings: AgentToolBinding[];
knowledgeBindings: AgentKnowledgeBinding[];
selectedNodeId: string;
panelMode: AgentPanelMode;
dirty: boolean;
}
export interface AgentOption {
label: string;
value: string;
raw?: Record<string, any>;
}
export interface AgentValidationIssue {
nodeId: string;
message: string;
field?: string;
}

View File

@@ -1,25 +1,17 @@
<script setup lang="ts">
import type {
ChatTimeHistoryRecord,
ChatTimeTimelineItem,
} from '@easyflow/types';
import type {ChatTimeHistoryRecord, ChatTimeTimelineItem,} from '@easyflow/types';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import {
createChatVariantSwitchController,
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
createChatVariantSwitchController,
uuid,
} from '@easyflow/utils';
import {
Delete,
Plus,
Promotion,
Search,
} from '@element-plus/icons-vue';
import {Delete, Plus, Promotion, Search,} from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
@@ -29,9 +21,9 @@ import {
ElScrollbar,
ElTag,
} from 'element-plus';
import { tryit } from 'radash';
import {tryit} from 'radash';
import { api, sseClient } from '#/api/request';
import {api, sseClient} from '#/api/request';
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
import ChatContextCapsuleBar from '#/components/chat-workspace/ChatContextCapsuleBar.vue';
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
@@ -1904,10 +1896,12 @@ async function deleteSession(targetSession?: SessionItem) {
display: grid;
grid-template-columns: var(--chat-sidebar-width) minmax(0, 1fr);
gap: 14px;
height: calc(100vh - 132px);
min-height: 640px;
height: var(--easyflow-content-height, 100%);
max-height: var(--easyflow-content-height, 100%);
min-height: 0;
padding: 16px;
overflow: hidden;
box-sizing: border-box;
background:
radial-gradient(circle at top left, hsl(var(--nav-ambient) / 0.08), transparent 20%),
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background-deep)) 100%);
@@ -1974,7 +1968,9 @@ async function deleteSession(targetSession?: SessionItem) {
.chat-workspace__sidebar-scroll,
.chat-workspace__message-scroll {
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
.chat-workspace__sidebar-body {
@@ -2190,6 +2186,7 @@ async function deleteSession(targetSession?: SessionItem) {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.chat-workspace__conversation.is-welcome-layout {
@@ -2502,17 +2499,19 @@ async function deleteSession(targetSession?: SessionItem) {
@media (max-width: 1024px) {
.chat-workspace {
grid-template-columns: 1fr;
height: auto;
grid-template-rows: minmax(128px, 30%) minmax(0, 1fr);
height: 100%;
min-height: 0;
overflow: visible;
overflow: hidden;
}
.chat-workspace__sidebar {
max-height: 460px;
min-height: 0;
max-height: none;
}
.chat-workspace__conversation {
min-height: 720px;
min-height: 0;
}
.chat-workspace__conversation.is-welcome-layout {

View File

@@ -1,31 +1,23 @@
<script setup lang="ts">
import type { ServerSentEventMessage } from 'fetch-event-stream';
import type {ServerSentEventMessage} from 'fetch-event-stream';
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
import type { BotInfo } from '@easyflow/types';
import type {ChatThinkingBlockStatus} from '@easyflow/common-ui';
import {ChatThinkingBlock, ChatTimeMarkdown} from '@easyflow/common-ui';
import type {BotInfo} from '@easyflow/types';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch,} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import {LOGIN_PATH} from '@easyflow/constants';
import {IconifyIcon} from '@easyflow/icons';
import {$t} from '@easyflow/locales';
import {useAccessStore} from '@easyflow/stores';
import {createChatVariantSwitchController, uuid} from '@easyflow/utils';
import { ChatThinkingBlock, ChatTimeMarkdown } from '@easyflow/common-ui';
import { LOGIN_PATH } from '@easyflow/constants';
import { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { useAccessStore } from '@easyflow/stores';
import { createChatVariantSwitchController, uuid } from '@easyflow/utils';
import {useTitle} from '@vueuse/core';
import {ElAvatar, ElButton, ElInput, ElMessage, ElSkeleton} from 'element-plus';
import { useTitle } from '@vueuse/core';
import { ElAvatar, ElButton, ElInput, ElMessage, ElSkeleton } from 'element-plus';
import { refreshTokenApi } from '#/api/core';
import { baseRequestClient, sseClient } from '#/api/request';
import {refreshTokenApi} from '#/api/core';
import {baseRequestClient, sseClient} from '#/api/request';
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
@@ -986,7 +978,20 @@ const getRenderableSegments = (message: BubbleMessage) => {
content: message.content,
});
}
return segments;
const textSegments = segments.filter(
(segment): segment is AssistantTextSegment => segment.type === 'text',
);
if (textSegments.length <= 1) {
return segments;
}
return [
...segments.filter((segment) => segment.type !== 'text'),
{
id: `${message.id}-merged-text`,
type: 'text' as const,
content: textSegments.map((segment) => segment.content).join(''),
},
];
};
const hasAssistantRenderableContent = (message: BubbleMessage) => {
@@ -1221,7 +1226,7 @@ const onSseMessage = (event: ServerSentEventMessage) => {
if (eventType === 'MESSAGE' && eventDomain === 'LLM') {
stopAssistantThinking();
const deltaRaw = sseData?.payload?.delta ?? sseData?.payload?.content ?? '';
const deltaRaw = sseData?.payload?.delta ?? '';
const delta =
typeof deltaRaw === 'string'
? deltaRaw
@@ -1856,6 +1861,7 @@ function prefetchVisibleVariants() {
<ChatTimeMarkdown
class="public-chat-markdown"
:content="segment.content"
:streaming="item.loading"
/>
</template>
<template v-else-if="segment.type === 'thinking'">