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'">

View File

@@ -8,6 +8,7 @@ export {
ArrowUpToLine,
Bell,
BookOpenText,
BrushCleaning,
Check,
ChevronDown,
ChevronLeft,
@@ -41,6 +42,7 @@ export {
LogOut,
MailCheck,
Maximize,
MessageSquare,
ArrowRightFromLine as MdiMenuClose,
ArrowLeftFromLine as MdiMenuOpen,
Menu,

View File

@@ -31,13 +31,14 @@
"@easyflow/icons": "workspace:*",
"@easyflow/locales": "workspace:*",
"@easyflow/types": "workspace:*",
"@incremark/theme": "1.0.2",
"@incremark/vue": "1.0.2",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"json-bigint": "catalog:",
"qrcode": "catalog:",
"tippy.js": "catalog:",
"vue": "catalog:",
"vue-element-plus-x": "catalog:",
"vue-json-viewer": "catalog:",
"vue-router": "catalog:",
"vue-tippy": "catalog:"

View File

@@ -1,31 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue';
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
import {computed, ref, watch} from 'vue';
import {IncremarkContent, ThemeProvider} from '@incremark/vue';
import '@incremark/theme/styles.css';
import { usePreferences } from '@easyflow-core/preferences';
import {usePreferences} from '@easyflow-core/preferences';
interface Props {
content?: string;
streaming?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
content: '',
streaming: false,
});
const { isDark } = usePreferences();
const normalizedContent = computed(() => String(props.content || ''));
const markdownContent = computed(() => props.content || '');
const isFinished = computed(() => !props.streaming);
const incremarkOptions = {
breaks: true,
containers: false,
gfm: true,
htmlTree: false,
math: true,
};
const previousContent = ref('');
watch(
() => [props.content || '', props.streaming] as const,
([content, streaming]) => {
const previous = previousContent.value;
if (import.meta.env.DEV && streaming) {
const startsWithPrevious = content.startsWith(previous);
console.debug('[ChatTimeMarkdown] streaming update', {
deltaLength: startsWithPrevious ? content.length - previous.length : null,
length: content.length,
previousLength: previous.length,
preview: content.slice(-160).replaceAll('\n', '\\n'),
startsWithPrevious,
});
}
previousContent.value = content;
},
{ immediate: true },
);
</script>
<template>
<ElXMarkdown
class="chat-time-markdown"
:allow-html="false"
:enable-breaks="true"
:enable-code-line-number="false"
:enable-latex="true"
:default-theme-mode="isDark ? 'dark' : 'light'"
:markdown="normalizedContent"
:need-view-code-btn="false"
/>
<div class="chat-time-markdown">
<ThemeProvider :theme="isDark ? 'dark' : 'default'">
<IncremarkContent
:content="markdownContent"
:incremark-options="incremarkOptions"
:is-finished="isFinished"
/>
</ThemeProvider>
</div>
</template>
<style scoped>
@@ -39,9 +69,8 @@ const normalizedContent = computed(() => String(props.content || ''));
word-break: break-word;
}
.chat-time-markdown :deep(.elx-xmarkdown-container),
.chat-time-markdown :deep(.elx-xmarkdown-provider),
.chat-time-markdown :deep(.markdown-body) {
.chat-time-markdown :deep(.incremark-theme-provider),
.chat-time-markdown :deep(.incremark) {
width: 100%;
max-width: 100%;
color: inherit;
@@ -50,15 +79,15 @@ const normalizedContent = computed(() => String(props.content || ''));
background: transparent;
}
.chat-time-markdown :deep(.markdown-body) {
.chat-time-markdown :deep(.incremark) {
overflow-wrap: anywhere;
}
.chat-time-markdown :deep(.markdown-body > :first-child) {
.chat-time-markdown :deep(.incremark > .incremark-block:first-child > *) {
margin-top: 0;
}
.chat-time-markdown :deep(.markdown-body > :last-child) {
.chat-time-markdown :deep(.incremark > .incremark-block:last-child > *) {
margin-bottom: 0;
}
@@ -260,6 +289,34 @@ const normalizedContent = computed(() => String(props.content || ''));
word-break: normal;
}
.chat-time-markdown :deep(.incremark-code) {
max-width: 100%;
margin: 1em 0;
overflow: hidden;
color: hsl(var(--text-strong));
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--divider-faint) / 0.82);
border-radius: 12px;
}
.chat-time-markdown :deep(.incremark-code .code-header) {
padding: 8px 12px;
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle) / 0.72);
border-bottom: 1px solid hsl(var(--divider-faint) / 0.72);
}
.chat-time-markdown :deep(.incremark-code .code-content) {
overflow: auto;
}
.chat-time-markdown :deep(.incremark-code pre) {
margin: 0;
background: transparent;
border: 0;
border-radius: 0;
}
.chat-time-markdown :deep(.shiki),
.chat-time-markdown :deep(.shiki code) {
background: transparent !important;
@@ -302,7 +359,8 @@ const normalizedContent = computed(() => String(props.content || ''));
}
:global(.dark) .chat-time-markdown :deep(code),
:global(.dark) .chat-time-markdown :deep(pre) {
:global(.dark) .chat-time-markdown :deep(pre),
:global(.dark) .chat-time-markdown :deep(.incremark-code) {
background: hsl(var(--surface-subtle) / 0.78);
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import ChatShimmerText from './ChatShimmerText.vue';
defineOptions({
name: 'ChatEventLabel',
});
withDefaults(
defineProps<{
active?: boolean;
text: string;
}>(),
{
active: false,
},
);
</script>
<template>
<span class="chat-event-label" :class="{ 'is-active': active }">
<span v-if="$slots.icon" class="chat-event-label__icon" aria-hidden="true">
<slot name="icon" />
</span>
<ChatShimmerText
class="chat-event-label__text"
:active="active"
:text="text"
/>
</span>
</template>
<style scoped>
.chat-event-label {
display: inline-flex;
gap: 4px;
align-items: center;
min-width: 0;
color: var(--el-text-color-placeholder);
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
.chat-event-label__icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
color: var(--el-text-color-placeholder);
}
.chat-event-label__text {
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
defineOptions({
name: 'ChatShimmerText',
});
withDefaults(
defineProps<{
active?: boolean;
text: string;
}>(),
{
active: false,
},
);
</script>
<template>
<span class="chat-shimmer-text" :class="{ 'is-active': active }">
{{ text }}
</span>
</template>
<style scoped>
.chat-shimmer-text {
display: inline-block;
min-width: 0;
overflow: hidden;
color: var(--el-text-color-placeholder);
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-shimmer-text.is-active {
color: transparent;
background: linear-gradient(
100deg,
var(--el-text-color-placeholder) 0%,
var(--el-text-color-secondary) 45%,
var(--el-text-color-placeholder) 80%
);
background-size: 220% 100%;
background-clip: text;
animation: chat-shimmer-text-flow 2.2s ease-in-out infinite;
}
@keyframes chat-shimmer-text-flow {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-shimmer-text.is-active {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,23 @@
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import ChatShimmerText from '../ChatShimmerText.vue';
describe('ChatShimmerText', () => {
it('renders text and toggles active shimmer class', async () => {
const wrapper = mount(ChatShimmerText, {
props: {
active: true,
text: '正在检索知识库',
},
});
expect(wrapper.text()).toBe('正在检索知识库');
expect(wrapper.classes()).toContain('is-active');
await wrapper.setProps({ active: false });
expect(wrapper.classes()).not.toContain('is-active');
});
});

View File

@@ -0,0 +1,2 @@
export { default as ChatShimmerText } from './ChatShimmerText.vue';
export { default as ChatEventLabel } from './ChatEventLabel.vue';

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { ChatThinkingBlockProps } from './types';
import type {ChatThinkingBlockProps} from './types';
import { computed } from 'vue';
import {computed, ref, watch} from 'vue';
import {ChatEventLabel} from '../chat-status';
defineOptions({
name: 'ChatThinkingBlock',
@@ -22,6 +24,15 @@ const emit = defineEmits<{
'update:expanded': [boolean];
}>();
const innerExpanded = ref(props.expanded);
watch(
() => props.expanded,
(value) => {
innerExpanded.value = value;
},
);
const normalizedContent = computed(() =>
String(props.content || '')
.replaceAll('\r\n', '\n')
@@ -35,8 +46,11 @@ const shouldRender = computed(
);
const expandedModel = computed({
get: () => props.expanded,
set: (value: boolean) => emit('update:expanded', value),
get: () => innerExpanded.value,
set: (value: boolean) => {
innerExpanded.value = value;
emit('update:expanded', value);
},
});
const computedLabel = computed(() => {
@@ -52,20 +66,6 @@ const computedLabel = computed(() => {
return '已思考';
});
const computedSummary = computed(() => {
if (props.summary) {
return props.summary;
}
const source = normalizedContent.value
.split('\n')
.map((line) => line.trim())
.find(Boolean);
if (!source) {
return props.emptyBehavior === 'placeholder' ? '暂无思考内容' : '';
}
return source.length > 56 ? `${source.slice(0, 56)}...` : source;
});
const canToggle = computed(
() => !props.disabled && normalizedContent.value.length > 0,
);
@@ -94,20 +94,15 @@ function toggleExpanded() {
<button
type="button"
class="chat-thinking-block__trigger"
:aria-expanded="expandedModel"
:disabled="!canToggle"
@click="toggleExpanded"
>
<span class="chat-thinking-block__leading">
<span class="chat-thinking-block__indicator" aria-hidden="true"></span>
<span class="chat-thinking-block__label">{{ computedLabel }}</span>
</span>
<span
v-if="!expandedModel && computedSummary"
class="chat-thinking-block__summary"
>
{{ computedSummary }}
</span>
<ChatEventLabel
class="chat-thinking-block__label"
:active="status === 'thinking'"
:text="computedLabel"
/>
<span
class="chat-thinking-block__chevron"
@@ -121,9 +116,8 @@ function toggleExpanded() {
v-if="expandedModel && normalizedContent"
class="chat-thinking-block__body"
>
<div class="chat-thinking-block__content">
{{ normalizedContent }}
</div>
<span class="chat-thinking-block__rail" aria-hidden="true"></span>
<div class="chat-thinking-block__content">{{ normalizedContent }}</div>
</div>
</transition>
</div>
@@ -131,33 +125,25 @@ function toggleExpanded() {
<style scoped>
.chat-thinking-block {
background: linear-gradient(
180deg,
hsl(var(--glass-tint) / 48%) 0%,
hsl(var(--surface-panel) / 74%) 100%
);
border: 1px solid hsl(var(--divider-faint) / 18%);
border-radius: 16px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 24%),
0 10px 24px -24px hsl(var(--foreground) / 18%);
backdrop-filter: blur(12px);
max-width: 100%;
color: var(--el-text-color-placeholder);
}
.chat-thinking-block__trigger {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
display: inline-flex;
gap: 8px;
align-items: center;
width: 100%;
max-width: 100%;
min-width: 0;
padding: 9px 12px;
padding: 2px 0;
color: inherit;
text-align: left;
background: transparent;
border: 0;
border-radius: inherit;
transition: background-color 0.18s ease;
border-radius: 6px;
transition:
color 0.18s ease,
opacity 0.18s ease;
}
.chat-thinking-block__trigger:not(:disabled) {
@@ -165,85 +151,94 @@ function toggleExpanded() {
}
.chat-thinking-block__trigger:not(:disabled):hover {
background: hsl(var(--surface-contrast-soft) / 34%);
color: var(--el-text-color-secondary);
}
.chat-thinking-block__trigger:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 3px;
}
.chat-thinking-block__trigger:disabled {
cursor: default;
}
.chat-thinking-block__leading {
display: inline-flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.chat-thinking-block__indicator {
position: relative;
flex: 0 0 auto;
width: 8px;
height: 8px;
background: hsl(var(--text-muted) / 74%);
border-radius: 999px;
}
.chat-thinking-block.is-thinking .chat-thinking-block__indicator {
background: hsl(var(--primary) / 82%);
box-shadow: 0 0 0 4px hsl(var(--primary) / 12%);
animation: chat-thinking-pulse 1.8s ease-in-out infinite;
}
.chat-thinking-block.is-error .chat-thinking-block__indicator {
background: hsl(var(--destructive) / 86%);
box-shadow: 0 0 0 4px hsl(var(--destructive) / 10%);
}
.chat-thinking-block__label {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-thinking-block__summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 1.3;
color: hsl(var(--text-muted));
white-space: nowrap;
max-width: 100%;
}
.chat-thinking-block__chevron {
width: 9px;
height: 9px;
border-right: 1.5px solid hsl(var(--text-muted));
border-bottom: 1.5px solid hsl(var(--text-muted));
transform: rotate(45deg) translateY(-1px);
transition: transform 0.18s ease;
flex: 0 0 auto;
width: 8px;
height: 8px;
margin-left: 2px;
border-right: 1.5px solid var(--el-text-color-secondary);
border-bottom: 1.5px solid var(--el-text-color-secondary);
transform: rotate(45deg) translateY(-2px);
transition:
border-color 0.18s ease,
transform 0.18s ease;
}
.chat-thinking-block__chevron.is-open {
transform: rotate(225deg) translateY(-1px);
transform: rotate(225deg) translate(-2px, -1px);
}
.chat-thinking-block__body {
padding: 0 12px 12px;
position: relative;
display: grid;
grid-template-columns: 13px minmax(0, 1fr);
column-gap: 10px;
padding: 8px 0 0;
margin-left: 3px;
}
.chat-thinking-block__rail {
position: relative;
min-height: 100%;
}
.chat-thinking-block__rail::before {
position: absolute;
top: 4px;
bottom: 2px;
left: 3px;
width: 1px;
content: '';
background: var(--el-border-color);
}
.chat-thinking-block__rail::after {
position: absolute;
top: 0;
left: 0;
width: 7px;
height: 7px;
content: '';
background: var(--el-text-color-placeholder);
border-radius: 999px;
}
.chat-thinking-block.is-thinking .chat-thinking-block__rail::after {
background: var(--el-color-primary);
box-shadow: 0 0 0 4px var(--el-color-primary-light-9);
animation: chat-thinking-pulse 1.8s ease-in-out infinite;
}
.chat-thinking-block.is-error .chat-thinking-block__rail::after {
background: var(--el-color-danger);
box-shadow: 0 0 0 4px var(--el-color-danger-light-9);
}
.chat-thinking-block__content {
padding: 10px 12px;
min-width: 0;
padding: 0 0 2px;
margin: 0;
font-size: 12px;
line-height: 1.68;
color: hsl(var(--text-secondary));
font-size: 14px;
line-height: 24px;
color: var(--el-text-color-secondary);
overflow-wrap: anywhere;
white-space: pre-wrap;
background: hsl(var(--surface-panel) / 72%);
border-radius: 12px;
}
.chat-thinking-block.is-disabled {
@@ -266,13 +261,34 @@ function toggleExpanded() {
@keyframes chat-thinking-pulse {
0%,
100% {
box-shadow: 0 0 0 4px hsl(var(--primary) / 12%);
opacity: 0.92;
opacity: 0.86;
}
50% {
box-shadow: 0 0 0 7px hsl(var(--primary) / 4%);
opacity: 1;
opacity: 0.92;
}
}
@keyframes chat-thinking-shimmer {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-thinking-block.is-thinking .chat-thinking-block__rail::after {
animation: none;
}
}
@media (width <= 768px) {
.chat-thinking-block__content {
font-size: 13px;
line-height: 22px;
}
}
</style>

View File

@@ -0,0 +1,70 @@
import {mount} from '@vue/test-utils';
import {nextTick} from 'vue';
import {describe, expect, it} from 'vitest';
import ChatThinkingBlock from '../ChatThinkingBlock.vue';
describe('ChatThinkingBlock', () => {
it('keeps local expanded state when the parent does not bind v-model', async () => {
const wrapper = mount(ChatThinkingBlock, {
props: {
content: '思考内容',
expanded: false,
status: 'end',
},
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(false);
await wrapper.find('button').trigger('click');
await nextTick();
expect(wrapper.emitted('update:expanded')?.at(-1)).toEqual([true]);
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(true);
await wrapper.find('button').trigger('click');
await nextTick();
expect(wrapper.emitted('update:expanded')?.at(-1)).toEqual([false]);
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(false);
});
it('syncs expanded state from prop changes', async () => {
const wrapper = mount(ChatThinkingBlock, {
props: {
content: '思考内容',
expanded: true,
status: 'thinking',
},
});
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(true);
await wrapper.setProps({
expanded: false,
status: 'end',
});
await nextTick();
expect(wrapper.find('.chat-thinking-block__body').exists()).toBe(false);
expect(wrapper.classes()).toContain('is-end');
});
it('renders the label without a leading indicator dot', () => {
const wrapper = mount(ChatThinkingBlock, {
props: {
content: '思考内容',
expanded: false,
status: 'thinking',
},
});
expect(wrapper.find('.chat-event-label').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
expect(wrapper.find('.chat-thinking-block__leading').exists()).toBe(false);
expect(wrapper.find('.chat-thinking-block__indicator').exists()).toBe(
false,
);
});
});

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
defineProps<{
message: string;
}>();
</script>
<template>
<div class="chat-error-notice" role="alert">
<span class="chat-error-notice__icon" aria-hidden="true">!</span>
<span>{{ message }}</span>
</div>
</template>
<style scoped>
.chat-error-notice {
display: flex;
gap: 8px;
align-items: flex-start;
max-width: min(78%, 680px);
padding: 10px 12px;
font-size: 13px;
line-height: 20px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-7);
border-radius: 8px;
}
.chat-error-notice__icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-top: 2px;
font-size: 12px;
font-weight: 700;
border: 1px solid currentColor;
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,368 @@
<script setup lang="ts">
import type {ChatTimelineKnowledgeHit} from './types';
import {BookOpenText, X} from '@easyflow/icons';
import {computed, ref} from 'vue';
interface KnowledgeCitation {
id: string;
isFaq: boolean;
label: string;
items: ChatTimelineKnowledgeHit[];
}
const props = defineProps<{
items: ChatTimelineKnowledgeHit[];
}>();
const activeId = ref('');
function asText(value: unknown) {
return value === null || value === undefined ? '' : String(value).trim();
}
function isFaqHit(item: ChatTimelineKnowledgeHit) {
if (item.faqCollection === true) {
return true;
}
return asText(item.knowledgeType).toUpperCase() === 'FAQ';
}
function sourceFileName(item: ChatTimelineKnowledgeHit) {
return asText(item.sourceFileName ?? item.metadata?.sourceFileName);
}
function resolveLabel(item: ChatTimelineKnowledgeHit, index: number) {
if (isFaqHit(item)) {
return asText(item.knowledgeName) || `知识库 ${index + 1}`;
}
return (
asText(item.documentName) ||
sourceFileName(item) ||
asText(item.knowledgeName) ||
`知识库 ${index + 1}`
);
}
function resolveGroupKey(item: ChatTimelineKnowledgeHit, index: number) {
const label = resolveLabel(item, index);
if (isFaqHit(item)) {
return `faq:${label || asText(item.knowledgeId) || index}`;
}
const sourceName = asText(item.documentName) || sourceFileName(item);
return [
'doc',
sourceName || asText(item.sourceUri) || label || asText(item.documentId),
asText(item.knowledgeId),
]
.filter(Boolean)
.join(':') || `doc:${index}`;
}
function formatScore(score: ChatTimelineKnowledgeHit['score']) {
if (score === null || score === undefined || score === '') {
return '';
}
const numberValue = Number(score);
if (Number.isFinite(numberValue)) {
return numberValue > 1 ? numberValue.toFixed(2) : `${Math.round(numberValue * 100)}%`;
}
return String(score);
}
function resolveChunkContent(item: ChatTimelineKnowledgeHit) {
return asText(item.chunkContent ?? item.content ?? item.text ?? item.summary);
}
function citationTypeLabel(citation: KnowledgeCitation) {
return citation.isFaq ? 'FAQ' : '文档';
}
const citations = computed<KnowledgeCitation[]>(() => {
const groups = new Map<string, KnowledgeCitation>();
props.items.forEach((item, index) => {
const key = resolveGroupKey(item, index);
const found = groups.get(key);
if (found) {
found.items.push(item);
return;
}
groups.set(key, {
id: key,
isFaq: isFaqHit(item),
label: resolveLabel(item, index),
items: [item],
});
});
return [...groups.values()];
});
const activeCitation = computed(
() => citations.value.find((citation) => citation.id === activeId.value),
);
function toggleCitation(id: string) {
activeId.value = activeId.value === id ? '' : id;
}
</script>
<template>
<section v-if="citations.length > 0" class="chat-knowledge-card">
<div class="chat-knowledge-card__pills" aria-label="知识库引注">
<button
v-for="citation in citations"
:key="citation.id"
type="button"
class="chat-knowledge-card__pill"
:class="{ 'is-active': citation.id === activeId }"
:aria-expanded="citation.id === activeId"
@click="toggleCitation(citation.id)"
>
<span class="chat-knowledge-card__icon" aria-hidden="true">
<BookOpenText :size="13" :stroke-width="2" />
</span>
<span class="chat-knowledge-card__label">{{ citation.label }}</span>
<span
v-if="citation.items.length > 1"
class="chat-knowledge-card__count"
>
· {{ citation.items.length }}
</span>
</button>
</div>
<div
v-if="activeCitation"
class="chat-knowledge-card__popover"
role="dialog"
:aria-label="activeCitation.label"
>
<div class="chat-knowledge-card__popover-head">
<div class="chat-knowledge-card__popover-main">
<div class="chat-knowledge-card__popover-title">
{{ activeCitation.label }}
</div>
<div class="chat-knowledge-card__popover-meta">
<span>{{ citationTypeLabel(activeCitation) }}</span>
<span>{{ activeCitation.items.length }} 个片段</span>
</div>
</div>
<button
type="button"
class="chat-knowledge-card__close"
aria-label="关闭"
@click="activeId = ''"
>
<X :size="14" :stroke-width="2" />
</button>
</div>
<div class="chat-knowledge-card__hits">
<article
v-for="(hit, index) in activeCitation.items"
:key="hit.chunkId || hit.id || index"
class="chat-knowledge-card__hit"
>
<div class="chat-knowledge-card__meta">
<span v-if="hit.knowledgeName">{{ hit.knowledgeName }}</span>
<span v-if="hit.documentName && !isFaqHit(hit)">
{{ hit.documentName }}
</span>
<span v-if="formatScore(hit.score)">
{{ formatScore(hit.score) }}
</span>
</div>
<blockquote class="chat-knowledge-card__content">
{{ resolveChunkContent(hit) }}
</blockquote>
</article>
</div>
</div>
</section>
</template>
<style scoped>
.chat-knowledge-card {
position: relative;
max-width: min(78%, 680px);
margin-top: 10px;
}
.chat-knowledge-card__pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-knowledge-card__pill {
display: inline-flex;
gap: 5px;
align-items: center;
max-width: 260px;
min-height: 26px;
padding: 3px 9px;
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary-light-9) 78%, white);
border: 1px solid color-mix(in srgb, var(--el-color-primary-light-7) 72%, white);
border-radius: 999px;
transition:
color 0.18s ease,
background-color 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
cursor: pointer;
}
.chat-knowledge-card__pill:hover,
.chat-knowledge-card__pill.is-active {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary-light-8) 80%, white);
border-color: var(--el-color-primary-light-5);
}
.chat-knowledge-card__pill:hover {
transform: translateY(-1px);
}
.chat-knowledge-card__pill:focus-visible,
.chat-knowledge-card__close:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-knowledge-card__icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: color-mix(in srgb, var(--el-color-primary) 78%, var(--el-text-color-regular));
}
.chat-knowledge-card__label {
overflow: hidden;
font-size: 12px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-knowledge-card__count {
flex: 0 0 auto;
font-size: 12px;
font-weight: 600;
line-height: 18px;
color: color-mix(in srgb, var(--el-color-primary) 62%, var(--el-text-color-secondary));
text-align: center;
}
.chat-knowledge-card__popover {
width: min(540px, calc(100vw - 48px));
padding: 14px;
margin-top: 8px;
color: var(--el-text-color-primary);
background: color-mix(in srgb, var(--el-bg-color-overlay) 94%, white);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
box-shadow: var(--el-box-shadow-light);
}
.chat-knowledge-card__popover-head {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.chat-knowledge-card__popover-main {
min-width: 0;
}
.chat-knowledge-card__popover-title {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-knowledge-card__popover-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.chat-knowledge-card__popover-meta span + span::before {
margin-right: 6px;
color: var(--el-border-color);
content: "·";
}
.chat-knowledge-card__close {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
background: transparent;
border: 0;
border-radius: 6px;
}
.chat-knowledge-card__close:hover {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.chat-knowledge-card__hits {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 360px;
overflow: auto;
}
.chat-knowledge-card__hit {
padding: 10px 10px 10px 12px;
background: color-mix(in srgb, var(--el-fill-color-lighter) 72%, white);
border-left: 2px solid var(--el-color-primary-light-5);
border-radius: 8px;
}
.chat-knowledge-card__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.chat-knowledge-card__meta span + span::before {
margin-right: 6px;
color: var(--el-border-color);
content: "/";
}
.chat-knowledge-card__content {
margin: 0;
font-size: 13px;
line-height: 20px;
color: var(--el-text-color-regular);
white-space: pre-wrap;
max-height: 200px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import {Copy, RotateCw} from '@easyflow/icons';
import ChatVariantNavigator from './ChatVariantNavigator.vue';
const props = withDefaults(
defineProps<{
align?: 'end' | 'start';
allowCopy?: boolean;
allowRegenerate?: boolean;
disabledVariantNext?: boolean;
disabledVariantPrevious?: boolean;
regenerateDisabled?: boolean;
showVariantNavigator?: boolean;
variantCurrent?: number;
variantLoading?: boolean;
variantTotal?: number;
}>(),
{
align: 'start',
allowCopy: false,
allowRegenerate: false,
disabledVariantNext: false,
disabledVariantPrevious: false,
regenerateDisabled: false,
showVariantNavigator: false,
variantCurrent: 1,
variantLoading: false,
variantTotal: 1,
},
);
const emit = defineEmits<{
copy: [];
regenerate: [];
selectNextVariant: [];
selectPreviousVariant: [];
}>();
function handleRegenerate() {
if (props.regenerateDisabled) {
return;
}
emit('regenerate');
}
</script>
<template>
<div
v-if="allowCopy || allowRegenerate || showVariantNavigator"
class="chat-message-toolbar"
:class="`is-${align}`"
>
<button
v-if="allowCopy"
type="button"
class="chat-message-toolbar__button"
aria-label="复制消息"
title="复制"
@click="emit('copy')"
>
<Copy />
</button>
<button
v-if="allowRegenerate"
type="button"
class="chat-message-toolbar__button"
:disabled="regenerateDisabled"
aria-label="重新生成"
title="重新生成"
@click="handleRegenerate"
>
<RotateCw />
</button>
<ChatVariantNavigator
v-if="showVariantNavigator"
class="chat-message-toolbar__navigator"
:current="variantCurrent"
:total="variantTotal"
:disabled-next="disabledVariantNext"
:disabled-previous="disabledVariantPrevious"
:loading="variantLoading"
@next="emit('selectNextVariant')"
@previous="emit('selectPreviousVariant')"
/>
</div>
</template>
<style scoped>
.chat-message-toolbar {
display: flex;
width: 100%;
gap: 4px;
align-items: center;
margin-top: 4px;
}
.chat-message-toolbar.is-end {
justify-content: flex-end;
}
.chat-message-toolbar__navigator {
margin-left: auto;
}
.chat-message-toolbar__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
transition:
color 0.18s ease,
background-color 0.18s ease,
opacity 0.18s ease;
}
.chat-message-toolbar__button:hover:not(:disabled) {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.chat-message-toolbar__button:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-message-toolbar__button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.chat-message-toolbar__button :deep(svg) {
width: 14px;
height: 14px;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import ChatTimeMarkdown from '../chat-markdown/ChatTimeMarkdown.vue';
defineProps<{
content: string;
streaming?: boolean;
}>();
</script>
<template>
<div class="chat-text-block">
<ChatTimeMarkdown :content="content" :streaming="streaming" />
</div>
</template>
<style scoped>
.chat-text-block {
min-width: 0;
font-size: 14px;
line-height: 22px;
color: var(--el-text-color-primary);
}
</style>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import type {
ChatTimelineItem as ChatTimelineItemType,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from './types';
import {nextTick, onBeforeUnmount, ref, watch} from 'vue';
import ChatTimelineItem from './ChatTimelineItem.vue';
const props = defineProps<{
approvalLoading?: boolean;
copyable?: (item: ChatTimelineMessageItem) => boolean;
emptyText?: string;
items: ChatTimelineItemType[];
regenerable?: (item: ChatTimelineMessageItem) => boolean;
regenerateDisabled?: boolean;
variantLoading?: (item: ChatTimelineMessageItem) => boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
}>();
const containerRef = ref<HTMLElement>();
const isPinnedToBottom = ref(true);
const suppressNextAutoScroll = ref(false);
let preservedScrollTop: number | undefined;
const bottomThreshold = 24;
let scrollFrame = 0;
function isNearBottom(container: HTMLElement) {
return (
container.scrollHeight - container.scrollTop - container.clientHeight <=
bottomThreshold
);
}
function updatePinnedState() {
const container = containerRef.value;
if (!container) {
return;
}
isPinnedToBottom.value = isNearBottom(container);
}
function scrollToBottom() {
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
}
scrollFrame = requestAnimationFrame(() => {
const container = containerRef.value;
if (!container) {
return;
}
container.scrollTop = container.scrollHeight;
updatePinnedState();
});
}
function handleTimelineScroll() {
updatePinnedState();
}
function handleThinkingToggle() {
preservedScrollTop = containerRef.value?.scrollTop;
suppressNextAutoScroll.value = true;
}
function canCopyMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
function canRegenerateMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function isAssistantActionAnchor(
item: ChatTimelineItemType,
index: number,
items: ChatTimelineItemType[],
) {
if (item.type !== 'message' || item.role !== 'assistant' || !item.roundId) {
return false;
}
for (let cursor = items.length - 1; cursor >= 0; cursor -= 1) {
const current = items[cursor];
if (
current &&
current.type === 'message' &&
current.role === 'assistant' &&
current.roundId === item.roundId
) {
return cursor === index;
}
}
return false;
}
function isVariantLoading(item: ChatTimelineItemType) {
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
}
onBeforeUnmount(() => {
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
}
});
watch(
() => props.items,
async () => {
if (suppressNextAutoScroll.value) {
suppressNextAutoScroll.value = false;
await nextTick();
if (preservedScrollTop !== undefined && containerRef.value) {
containerRef.value.scrollTop = preservedScrollTop;
}
preservedScrollTop = undefined;
updatePinnedState();
return;
}
if (isPinnedToBottom.value) {
scrollToBottom();
}
},
{ deep: true, immediate: true },
);
</script>
<template>
<div
ref="containerRef"
class="chat-timeline"
@scroll.passive="handleTimelineScroll"
>
<div v-if="items.length === 0" class="chat-timeline__empty">
<div class="chat-timeline__empty-icon" aria-hidden="true"></div>
<div class="chat-timeline__empty-text">
{{ emptyText || '开始对话' }}
</div>
</div>
<template v-else>
<ChatTimelineItem
v-for="(item, index) in items"
:key="item.id"
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
:item="item"
:approval-loading="approvalLoading"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleThinkingToggle"
/>
</template>
</div>
</template>
<style scoped>
.chat-timeline {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow: auto;
}
.chat-timeline__empty-icon {
width: 72px;
height: 72px;
margin: 0 auto;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
}
.chat-timeline__empty {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 180px;
color: var(--el-text-color-secondary);
}
.chat-timeline__empty-text {
font-size: 13px;
line-height: 20px;
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import type {
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineToolApprovalPayload,
} from './types';
import {computed} from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatErrorNotice from './ChatErrorNotice.vue';
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
import ChatMessageToolbar from './ChatMessageToolbar.vue';
import ChatTextBlock from './ChatTextBlock.vue';
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
import ChatToolCard from './ChatToolCard.vue';
const props = defineProps<{
approvalLoading?: boolean;
copyable?: boolean;
regenerable?: boolean;
regenerateDisabled?: boolean;
assistantActionsVisible?: boolean;
item: ChatTimelineItem;
variantLoading?: boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
thinkingToggle: [];
}>();
const messageItem = computed(() =>
props.item.type === 'message' ? props.item : undefined,
);
const alignmentClass = computed(() => {
if (props.item.type === 'message' && props.item.role === 'user') {
return 'is-user';
}
return 'is-assistant';
});
const variantCurrent = computed(() => {
const item = messageItem.value;
return Number(item?.selectedVariantIndex || item?.variantIndex || 1);
});
const variantTotal = computed(() =>
Number(messageItem.value?.variantCount || 1),
);
const showVariantNavigator = computed(() => {
const item = messageItem.value;
return Boolean(
item?.role === 'assistant' &&
item.roundCompleted &&
item.roundId &&
item.switchable !== false &&
variantTotal.value > 1,
);
});
const disabledVariantPrevious = computed(
() => props.variantLoading || variantCurrent.value <= 1,
);
const disabledVariantNext = computed(
() => props.variantLoading || variantCurrent.value >= variantTotal.value,
);
const showToolbar = computed(() => {
const item = messageItem.value;
return Boolean(
item &&
(item.role === 'assistant'
? (props.assistantActionsVisible ?? true) &&
(props.copyable || props.regenerable || showVariantNavigator.value)
: props.copyable),
);
});
function getMessageParts(item: ChatTimelineMessageItem) {
const textParts = item.parts.filter(
(part): part is Extract<ChatTimelineMessagePart, { type: 'text' }> =>
part.type === 'text',
);
if (textParts.length <= 1) {
return item.parts;
}
return [
...item.parts.filter((part) => part.type !== 'text'),
{
content: textParts.map((part) => part.content).join(''),
id: `${item.id}-merged-text`,
type: 'text' as const,
},
];
}
function updateThinkingExpanded(partId: string, expanded: boolean) {
const item = messageItem.value;
if (!item) {
return;
}
const part = item.parts.find((current) => current.id === partId);
if (part?.type === 'thinking') {
emit('thinkingToggle');
part.expanded = expanded;
}
}
</script>
<template>
<div class="chat-timeline-item" :class="alignmentClass">
<div
v-if="messageItem"
class="chat-timeline-item__message"
:class="[
`is-${messageItem.role}`,
{ 'has-variant-navigator': showVariantNavigator },
]"
>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
:content="part.content"
:expanded="part.expanded ?? part.status === 'thinking'"
:status="part.status"
class="chat-timeline-item__thinking"
readonly
@update:expanded="updateThinkingExpanded(part.id, $event)"
/>
<ChatTextBlock
v-else
:content="part.content"
:streaming="messageItem.status === 'streaming'"
/>
</template>
<ChatKnowledgeCard
v-if="messageItem.knowledgeItems?.length"
:items="messageItem.knowledgeItems"
/>
<ChatMessageToolbar
v-if="showToolbar && messageItem"
:align="messageItem.role === 'user' ? 'end' : 'start'"
:allow-copy="copyable"
:allow-regenerate="regenerable"
:disabled-variant-next="disabledVariantNext"
:disabled-variant-previous="disabledVariantPrevious"
:regenerate-disabled="regenerateDisabled"
:show-variant-navigator="showVariantNavigator"
:variant-current="variantCurrent"
:variant-loading="variantLoading"
:variant-total="variantTotal"
@copy="emit('copyMessage', messageItem)"
@regenerate="emit('regenerateMessage', messageItem)"
@select-next-variant="emit('selectNextVariant', messageItem)"
@select-previous-variant="emit('selectPreviousVariant', messageItem)"
/>
</div>
<ChatToolCard
v-else-if="item.type === 'tool'"
:item="item"
:loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<ChatKnowledgeCard
v-else-if="item.type === 'knowledge'"
:items="item.items"
/>
<ChatTimelineStatusRow v-else-if="item.type === 'status'" :item="item" />
<ChatErrorNotice
v-else-if="item.type === 'error'"
:message="item.message"
/>
</div>
</template>
<style scoped>
.chat-timeline-item {
display: flex;
width: 100%;
}
.chat-timeline-item.is-user {
justify-content: flex-end;
}
.chat-timeline-item.is-assistant {
justify-content: flex-start;
}
.chat-timeline-item__bubble {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(78%, 680px);
min-width: 0;
}
.chat-timeline-item__message {
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(78%, 680px);
min-width: 0;
}
.chat-timeline-item__message :deep(.chat-text-block),
.chat-timeline-item__message :deep(.chat-thinking-block) {
padding: 0;
color: var(--el-text-color-primary);
background: transparent;
border: 0;
}
.chat-timeline-item__message :deep(.chat-text-block) {
font-size: 14px;
line-height: 22px;
}
.chat-timeline-item__message.is-user {
color: var(--el-text-color-primary);
}
.chat-timeline-item__message.is-assistant.has-variant-navigator {
width: min(78%, 680px);
}
.chat-timeline-item__bubble.is-assistant,
.chat-timeline-item__bubble.is-system {
color: var(--el-text-color-primary);
}
.chat-timeline-item__thinking {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type {ChatTimelineStatusItem} from './types';
import {computed} from 'vue';
import {BookOpenText} from '@easyflow/icons';
import {ChatEventLabel} from '../chat-status';
defineOptions({
name: 'ChatTimelineStatusRow',
});
const props = defineProps<{
item: ChatTimelineStatusItem;
}>();
const isRunning = computed(() => props.item.status === 'running');
const isSeparator = computed(() => props.item.presentation === 'separator');
</script>
<template>
<div
class="chat-timeline-status-row"
:class="[
`is-${item.status}`,
`is-${item.tone || 'muted'}`,
{ 'is-separator': isSeparator },
]"
>
<span
v-if="isSeparator"
class="chat-timeline-status-row__line"
aria-hidden="true"
/>
<ChatEventLabel
class="chat-timeline-status-row__content"
:active="isRunning"
:text="item.label"
>
<template #icon>
<BookOpenText
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>
</template>
</ChatEventLabel>
<span
v-if="isSeparator"
class="chat-timeline-status-row__line"
aria-hidden="true"
/>
</div>
</template>
<style scoped>
.chat-timeline-status-row {
display: inline-flex;
gap: 4px;
align-items: center;
max-width: min(78%, 680px);
min-width: 0;
padding: 2px 0;
color: var(--el-text-color-placeholder);
}
.chat-timeline-status-row__content {
min-width: 0;
}
.chat-timeline-status-row__icon {
width: 14px;
height: 14px;
color: var(--el-text-color-placeholder);
}
.chat-timeline-status-row.is-done {
opacity: 0.84;
}
.chat-timeline-status-row.is-separator {
display: flex;
width: 100%;
max-width: 100%;
gap: 8px;
justify-content: center;
}
.chat-timeline-status-row.is-separator .chat-timeline-status-row__line {
flex: 1 1 auto;
min-width: 24px;
height: 1px;
background: var(--el-border-color-lighter);
}
</style>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import type {ChatTimelineToolApprovalPayload} from './types';
defineProps<{
loading?: boolean;
payload: ChatTimelineToolApprovalPayload;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
reject: [payload: ChatTimelineToolApprovalPayload];
}>();
function formatPayload(value: unknown) {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
</script>
<template>
<section class="chat-tool-approval">
<div class="chat-tool-approval__header">
<span class="chat-tool-approval__icon" aria-hidden="true"></span>
<div class="chat-tool-approval__title-wrap">
<div class="chat-tool-approval__title">
{{ payload.toolDisplayName || payload.toolName }}
</div>
<div class="chat-tool-approval__desc">需要确认后执行</div>
</div>
</div>
<pre v-if="payload.input" class="chat-tool-approval__payload">{{
formatPayload(payload.input)
}}</pre>
<div class="chat-tool-approval__actions">
<button
type="button"
class="chat-tool-approval__button"
:disabled="loading"
@click="emit('reject', payload)"
>
拒绝
</button>
<button
type="button"
class="chat-tool-approval__button is-primary"
:disabled="loading"
@click="emit('approve', payload)"
>
批准
</button>
</div>
</section>
</template>
<style scoped>
.chat-tool-approval {
max-width: min(78%, 680px);
padding: 12px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
}
.chat-tool-approval__header {
display: flex;
gap: 8px;
align-items: flex-start;
}
.chat-tool-approval__icon {
flex: 0 0 auto;
width: 14px;
height: 14px;
margin-top: 2px;
background: var(--el-color-primary);
border-radius: 4px;
}
.chat-tool-approval__title-wrap {
min-width: 0;
}
.chat-tool-approval__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.chat-tool-approval__desc {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-tool-approval__payload {
max-height: 160px;
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;
}
.chat-tool-approval__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.chat-tool-approval__button {
min-width: 64px;
height: 32px;
padding: 0 12px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.chat-tool-approval__button:hover {
border-color: var(--el-color-primary-light-5);
}
.chat-tool-approval__button:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-tool-approval__button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.chat-tool-approval__button.is-primary {
color: var(--el-color-white);
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
</style>

View File

@@ -0,0 +1,380 @@
<script setup lang="ts">
import type {ChatTimelineToolApprovalPayload, ChatTimelineToolItem,} from './types';
import {IconifyIcon} from '@easyflow/icons';
import {computed, ref} from 'vue';
const props = defineProps<{
item: ChatTimelineToolItem;
loading?: boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
reject: [payload: ChatTimelineToolApprovalPayload];
}>();
const expanded = ref(false);
const isApprovalMode = computed(() => props.item.mode === 'approval');
const canApprove = computed(
() => isApprovalMode.value && props.item.status === 'pending_approval',
);
const hasDetails = computed(() => Boolean(props.item.input));
const statusText = computed(() => {
switch (props.item.status) {
case 'approving': {
return '确认中';
}
case 'error': {
return '失败';
}
case 'pending_approval': {
return '待确认';
}
case 'rejected': {
return '已拒绝';
}
case 'success': {
return '已完成';
}
default: {
return '调用中';
}
}
});
const modeText = computed(() =>
isApprovalMode.value ? '需要确认后执行' : '自动执行',
);
function formatPayload(value: unknown) {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function toggleExpanded() {
if (!hasDetails.value) {
return;
}
expanded.value = !expanded.value;
}
function getApprovalPayload() {
return props.item.approval;
}
function handleApprove() {
const payload = getApprovalPayload();
if (!payload || !canApprove.value) {
return;
}
emit('approve', payload);
}
function handleReject() {
const payload = getApprovalPayload();
if (!payload || !canApprove.value) {
return;
}
emit('reject', payload);
}
</script>
<template>
<section
class="chat-tool-card"
:class="[`is-${item.status}`, { 'is-approval': isApprovalMode }]"
>
<button
v-if="!isApprovalMode"
type="button"
class="chat-tool-card__compact"
:class="{ 'is-clickable': hasDetails }"
:disabled="!hasDetails"
@click="toggleExpanded"
>
<span class="chat-tool-card__title">
<IconifyIcon
aria-hidden="true"
class="chat-tool-card__tool-icon"
icon="mdi:hammer"
/>
<span class="chat-tool-card__name">{{ item.toolName }}</span>
</span>
<span class="chat-tool-card__meta">
<span>{{ modeText }}</span>
<span class="chat-tool-card__status">
<span class="chat-tool-card__status-dot" aria-hidden="true"></span>
<span>{{ statusText }}</span>
</span>
</span>
</button>
<template v-else>
<div class="chat-tool-card__approval-head">
<IconifyIcon
aria-hidden="true"
class="chat-tool-card__tool-icon"
icon="mdi:hammer"
/>
<div class="chat-tool-card__heading">
<div class="chat-tool-card__name">{{ item.toolName }}</div>
<div class="chat-tool-card__desc">{{ modeText }}</div>
</div>
<span class="chat-tool-card__status">
<span class="chat-tool-card__status-dot" aria-hidden="true"></span>
<span>{{ statusText }}</span>
</span>
</div>
<pre v-if="item.input" class="chat-tool-card__payload">{{
formatPayload(item.input)
}}</pre>
<div v-if="canApprove" class="chat-tool-card__actions">
<button
type="button"
class="chat-tool-card__button"
:disabled="loading"
@click="handleReject"
>
拒绝
</button>
<button
type="button"
class="chat-tool-card__button is-primary"
:disabled="loading"
@click="handleApprove"
>
批准
</button>
</div>
<div v-if="item.rejectReason" class="chat-tool-card__result-note">
{{ item.rejectReason }}
</div>
</template>
<div
v-if="!isApprovalMode && expanded && hasDetails"
class="chat-tool-card__body"
>
<pre v-if="item.input" class="chat-tool-card__payload">{{
formatPayload(item.input)
}}</pre>
</div>
</section>
</template>
<style scoped>
.chat-tool-card {
max-width: min(78%, 680px);
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.chat-tool-card.is-approval {
padding: 16px;
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
}
.chat-tool-card__compact,
.chat-tool-card__approval-head {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
}
.chat-tool-card__compact {
justify-content: space-between;
padding: 10px 12px;
color: inherit;
text-align: left;
background: transparent;
border: 0;
}
.chat-tool-card__compact.is-clickable {
cursor: pointer;
}
.chat-tool-card__compact.is-clickable:hover {
background: var(--el-fill-color-lighter);
}
.chat-tool-card__compact:focus-visible,
.chat-tool-card__button:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-tool-card__approval-head {
align-items: flex-start;
}
.chat-tool-card__title,
.chat-tool-card__meta,
.chat-tool-card__status {
display: inline-flex;
align-items: center;
min-width: 0;
}
.chat-tool-card__title {
gap: 6px;
}
.chat-tool-card__heading {
flex: 1 1 auto;
min-width: 0;
}
.chat-tool-card__name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.chat-tool-card__desc {
margin-top: 2px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.chat-tool-card__tool-icon {
flex: 0 0 auto;
width: 14px;
height: 14px;
margin-top: 2px;
color: var(--el-text-color-secondary);
}
.chat-tool-card.is-approval .chat-tool-card__tool-icon,
.chat-tool-card.is-pending_approval .chat-tool-card__tool-icon,
.chat-tool-card.is-approving .chat-tool-card__tool-icon,
.chat-tool-card.is-running .chat-tool-card__tool-icon {
color: var(--el-color-primary);
}
.chat-tool-card__meta {
flex: 0 0 auto;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-tool-card__status {
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-tool-card__status-dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
}
.chat-tool-card.is-approving .chat-tool-card__status,
.chat-tool-card.is-pending_approval .chat-tool-card__status,
.chat-tool-card.is-running .chat-tool-card__status {
color: var(--el-color-primary);
}
.chat-tool-card.is-success .chat-tool-card__status {
color: var(--el-color-success);
}
.chat-tool-card.is-error .chat-tool-card__status,
.chat-tool-card.is-rejected .chat-tool-card__status {
color: var(--el-color-danger);
}
.chat-tool-card__body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 12px 12px;
}
.chat-tool-card__payload {
max-height: 180px;
padding: 10px;
margin: 12px 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: 8px;
}
.chat-tool-card__body .chat-tool-card__payload {
margin: 0;
background: var(--el-fill-color-light);
}
.chat-tool-card__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.chat-tool-card__button {
min-width: 64px;
height: 32px;
padding: 0 12px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.chat-tool-card__button:hover {
border-color: var(--el-color-primary-light-5);
}
.chat-tool-card__button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.chat-tool-card__button.is-primary {
color: var(--el-color-white);
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.chat-tool-card__result-note {
margin-top: 12px;
font-size: 12px;
line-height: 18px;
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import {ChevronLeft, ChevronRight, LoaderCircle} from '@easyflow/icons';
const props = withDefaults(
defineProps<{
current?: number;
disabledNext?: boolean;
disabledPrevious?: boolean;
loading?: boolean;
total?: number;
}>(),
{
current: 1,
disabledNext: false,
disabledPrevious: false,
loading: false,
total: 1,
},
);
const emit = defineEmits<{
next: [];
previous: [];
}>();
function handlePrevious() {
if (props.disabledPrevious || props.loading) {
return;
}
emit('previous');
}
function handleNext() {
if (props.disabledNext || props.loading) {
return;
}
emit('next');
}
</script>
<template>
<div v-if="total > 1" class="chat-variant-navigator">
<button
type="button"
class="chat-variant-navigator__button"
:disabled="disabledPrevious || loading"
aria-label="上一版答案"
title="上一版"
@click="handlePrevious"
>
<LoaderCircle v-if="loading" />
<ChevronLeft v-else />
</button>
<span class="chat-variant-navigator__label">{{ current }}/{{ total }}</span>
<button
type="button"
class="chat-variant-navigator__button"
:disabled="disabledNext || loading"
aria-label="下一版答案"
title="下一版"
@click="handleNext"
>
<LoaderCircle v-if="loading" />
<ChevronRight v-else />
</button>
</div>
</template>
<style scoped>
.chat-variant-navigator {
display: inline-flex;
gap: 2px;
align-items: center;
}
.chat-variant-navigator__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
transition:
color 0.18s ease,
background-color 0.18s ease,
opacity 0.18s ease;
}
.chat-variant-navigator__button:hover:not(:disabled) {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.chat-variant-navigator__button:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.chat-variant-navigator__button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.chat-variant-navigator__button :deep(svg) {
width: 14px;
height: 14px;
}
.chat-variant-navigator__button :deep(.lucide-loader-circle) {
animation: chat-variant-spin 0.8s linear infinite;
}
.chat-variant-navigator__label {
min-width: 36px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: var(--el-text-color-secondary);
text-align: center;
}
@keyframes chat-variant-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,119 @@
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import ChatKnowledgeCard from '../ChatKnowledgeCard.vue';
describe('ChatKnowledgeCard', () => {
it('renders FAQ citation with knowledge name', () => {
const wrapper = mount(ChatKnowledgeCard, {
props: {
items: [
{
id: 'faq-1',
chunkContent: '暑假安排原文',
faqCollection: true,
knowledgeId: 'kb-faq',
knowledgeName: '学生事务 FAQ',
knowledgeType: 'FAQ',
},
],
},
});
expect(wrapper.find('.chat-knowledge-card__pill').text()).toContain(
'学生事务 FAQ',
);
});
it('renders document citation with document name', () => {
const wrapper = mount(ChatKnowledgeCard, {
props: {
items: [
{
chunkContent: '文档 chunk 原文',
documentId: 'doc-1',
documentName: '数据引接与治理.pdf',
knowledgeName: '治理知识库',
knowledgeType: 'DOCUMENT',
},
],
},
});
expect(wrapper.find('.chat-knowledge-card__pill').text()).toContain(
'数据引接与治理.pdf',
);
});
it('falls back to sourceFileName for document citation', () => {
const wrapper = mount(ChatKnowledgeCard, {
props: {
items: [
{
chunkContent: '文档 chunk 原文',
documentId: 'doc-1',
knowledgeName: '治理知识库',
knowledgeType: 'DOCUMENT',
metadata: {
sourceFileName: '治理方案.docx',
},
},
],
},
});
expect(wrapper.find('.chat-knowledge-card__pill').text()).toContain(
'治理方案.docx',
);
});
it('aggregates multiple chunks from the same source', () => {
const wrapper = mount(ChatKnowledgeCard, {
props: {
items: [
{
chunkContent: '第一段',
chunkId: 'chunk-1',
documentId: 'doc-1',
documentName: '治理方案.docx',
knowledgeType: 'DOCUMENT',
},
{
chunkContent: '第二段',
chunkId: 'chunk-2',
documentId: 'doc-1',
documentName: '治理方案.docx',
knowledgeType: 'DOCUMENT',
},
],
},
});
expect(wrapper.findAll('.chat-knowledge-card__pill')).toHaveLength(1);
expect(wrapper.find('.chat-knowledge-card__count').text()).toContain('2');
});
it('shows raw chunk content after clicking citation', async () => {
const wrapper = mount(ChatKnowledgeCard, {
props: {
items: [
{
chunkContent: '这里是命中片段原文',
chunkId: 'chunk-1',
documentId: 'doc-1',
documentName: '治理方案.docx',
knowledgeName: '治理知识库',
score: 0.86,
},
],
},
});
await wrapper.find('.chat-knowledge-card__pill').trigger('click');
expect(wrapper.find('.chat-knowledge-card__popover').exists()).toBe(true);
expect(wrapper.text()).toContain('这里是命中片段原文');
expect(wrapper.text()).toContain('86%');
});
});

View File

@@ -0,0 +1,76 @@
import type {ChatTimelineStatusItem} from '../types';
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import ChatTimelineStatusRow from '../ChatTimelineStatusRow.vue';
describe('ChatTimelineStatusRow', () => {
it('uses shimmer text while running and static text after done', async () => {
const item: ChatTimelineStatusItem = {
id: 'knowledge-retrieval',
label: '正在检索知识库',
status: 'running',
statusKey: 'knowledge-retrieval',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.text()).toContain('正在检索知识库');
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
await wrapper.setProps({
item: {
...item,
label: '已检索知识库',
status: 'done',
},
});
expect(wrapper.text()).toContain('已检索知识库');
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
});
it('renders memory compression status as a separator row', () => {
const item: ChatTimelineStatusItem = {
id: 'memory-compression',
label: '正在整理上下文',
presentation: 'separator',
status: 'running',
statusKey: 'memory-compression',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.classes()).toContain('is-separator');
expect(wrapper.text()).toContain('正在整理上下文');
expect(wrapper.findAll('.chat-timeline-status-row__line')).toHaveLength(2);
expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(true);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active');
});
it('uses the shared event label style', () => {
const item: ChatTimelineStatusItem = {
id: 'knowledge-retrieval',
label: '已检索知识库',
status: 'done',
statusKey: 'knowledge-retrieval',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.find('.chat-event-label').exists()).toBe(true);
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
});
});

View File

@@ -0,0 +1,190 @@
import type {ChatTimelineItem} from '../types';
import {mount} from '@vue/test-utils';
import {describe, expect, it} from 'vitest';
import ChatTimeline from '../ChatTimeline.vue';
function textMessage(
role: 'assistant' | 'user',
content: string,
extra: Partial<Extract<ChatTimelineItem, { type: 'message' }>> = {},
): Extract<ChatTimelineItem, { type: 'message' }> {
return {
id: `${role}-${content}`,
parts: [
{
id: `text-${content}`,
content,
type: 'text',
},
],
role,
status: 'done',
type: 'message',
...extra,
};
}
describe('ChatTimeline toolbar', () => {
it('shows copy button for user messages and emits copy-message', async () => {
const userMessage = textMessage('user', '用户问题');
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [userMessage],
},
});
const copyButton = wrapper.find('[aria-label="复制消息"]');
expect(copyButton.exists()).toBe(true);
await copyButton.trigger('click');
expect(wrapper.emitted('copyMessage')?.[0]?.[0]).toEqual(userMessage);
});
it('shows copy and regenerate buttons for assistant messages', async () => {
const assistantMessage = textMessage('assistant', '助手回答', {
regenerable: true,
roundId: 'round-1',
roundCompleted: true,
});
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [assistantMessage],
regenerable: () => true,
},
});
expect(wrapper.find('[aria-label="复制消息"]').exists()).toBe(true);
const regenerateButton = wrapper.find('[aria-label="重新生成"]');
expect(regenerateButton.exists()).toBe(true);
await regenerateButton.trigger('click');
expect(wrapper.emitted('regenerateMessage')?.[0]?.[0]).toEqual(
assistantMessage,
);
});
it('renders variant navigator and disables boundary buttons', async () => {
const assistantMessage = textMessage('assistant', '助手回答', {
roundId: 'round-1',
selectedVariantIndex: 1,
roundCompleted: true,
switchable: true,
variantCount: 2,
variantIndex: 1,
});
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [assistantMessage],
regenerable: () => true,
},
});
expect(wrapper.text()).toContain('1/2');
const buttons = wrapper.findAll('.chat-variant-navigator__button');
expect(buttons[0]?.attributes('disabled')).toBeDefined();
expect(buttons[1]?.attributes('disabled')).toBeUndefined();
await buttons[1]?.trigger('click');
expect(wrapper.emitted('selectNextVariant')?.[0]?.[0]).toEqual(
assistantMessage,
);
});
it('disables regenerate while streaming or globally disabled', () => {
const assistantMessage = textMessage('assistant', '助手回答', {
roundId: 'round-1',
status: 'streaming',
});
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [assistantMessage],
regenerable: () => false,
regenerateDisabled: true,
},
});
expect(wrapper.find('[aria-label="重新生成"]').exists()).toBe(false);
});
it('does not show message toolbar before streaming completes', () => {
const assistantMessage = textMessage('assistant', '助手回答', {
roundId: 'round-1',
selectedVariantIndex: 1,
status: 'streaming',
roundCompleted: false,
switchable: true,
variantCount: 2,
variantIndex: 1,
});
const wrapper = mount(ChatTimeline, {
props: {
copyable: (item) => item.status === 'done',
items: [assistantMessage],
regenerable: (item) => item.status === 'done',
},
});
expect(wrapper.find('[aria-label="复制消息"]').exists()).toBe(false);
expect(wrapper.find('[aria-label="重新生成"]').exists()).toBe(false);
expect(wrapper.find('.chat-variant-navigator').exists()).toBe(false);
});
it('keeps toolbar hidden for unfinished rounds even after a partial done segment', () => {
const assistantMessage = textMessage('assistant', '助手回答', {
roundId: 'round-1',
status: 'done',
roundCompleted: false,
switchable: true,
variantCount: 2,
variantIndex: 1,
});
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => false,
items: [assistantMessage],
regenerable: () => false,
},
});
expect(wrapper.find('[aria-label="复制消息"]').exists()).toBe(false);
expect(wrapper.find('[aria-label="重新生成"]').exists()).toBe(false);
});
it('only shows action toolbar on the last assistant segment of the round', () => {
const assistantHead = textMessage('assistant', '前半段', {
roundId: 'round-1',
status: 'done',
roundCompleted: true,
});
const assistantTail = textMessage('assistant', '后半段', {
roundId: 'round-1',
status: 'done',
roundCompleted: true,
});
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [assistantHead, assistantTail],
regenerable: () => true,
},
});
const copyButtons = wrapper.findAll('[aria-label="复制消息"]');
const regenerateButtons = wrapper.findAll('[aria-label="重新生成"]');
expect(copyButtons).toHaveLength(1);
expect(regenerateButtons).toHaveLength(1);
expect(wrapper.text()).toContain('前半段');
expect(wrapper.text()).toContain('后半段');
});
});

View File

@@ -0,0 +1,574 @@
import type {ChatTimelineItem} from '../types';
import {describe, expect, it} from 'vitest';
import {ChatTimelineBuilder} from '../builder';
describe('chat timeline builder', () => {
it('keeps streamed thinking, text, tool and following text in timeline order', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '先思考');
ChatTimelineBuilder.appendMessageDelta(items, '正文 A');
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '查询工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.appendMessageDelta(items, '正文 B');
expect(items).toHaveLength(3);
expect(items[0]?.type).toBe('message');
expect(items[1]?.type).toBe('tool');
expect(items[2]?.type).toBe('message');
const firstMessage = items[0];
expect(firstMessage?.type).toBe('message');
if (firstMessage?.type === 'message') {
expect(firstMessage.parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(firstMessage.parts[0]?.content).toBe('先思考');
expect(firstMessage.parts[1]?.content).toBe('正文 A');
}
const secondMessage = items[2];
expect(secondMessage?.type).toBe('message');
if (secondMessage?.type === 'message') {
expect(secondMessage.parts.map((part) => part.type)).toEqual(['text']);
expect(secondMessage.parts[0]?.content).toBe('正文 B');
}
});
it('keeps round metadata on user and assistant messages', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendUserMessage(items, '问题', {
roundId: 'round-1',
});
ChatTimelineBuilder.appendMessageDelta(items, '回答', {
roundId: 'round-1',
variantIndex: 1,
});
ChatTimelineBuilder.finalize(items);
expect(items).toHaveLength(2);
expect(items[0]?.type).toBe('message');
expect(items[1]?.type).toBe('message');
if (items[0]?.type === 'message' && items[1]?.type === 'message') {
expect(items[0].roundId).toBe('round-1');
expect(items[1].roundId).toBe('round-1');
expect(items[1].variantIndex).toBe(1);
expect(items[1].status).toBe('done');
}
});
it('updates tool result by toolCallId instead of adding another card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '查询工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '查询工具',
output: { result: 'ok' },
status: 'success',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('tool');
if (items[0]?.type === 'tool') {
expect(items[0].status).toBe('success');
expect(items[0].input).toEqual({ keyword: 'EasyFlow' });
expect(items[0].output).toEqual({ result: 'ok' });
}
});
it('shows built-in knowledge retrieval as a lightweight status row', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-knowledge',
toolName: 'retrieve_knowledge',
input: { query: '请假安排' },
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('正在检索知识库');
expect(items[0].status).toBe('running');
}
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-knowledge',
toolName: 'retrieve_knowledge',
output: { result: 'ok' },
status: 'success',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('已检索知识库');
expect(items[0].status).toBe('done');
}
});
it('shows memory compression status as a lightweight status row', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
label: '正在整理上下文',
phase: 'started',
status: 'running',
statusKey: 'memory-compression',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('正在整理上下文');
expect(items[0].presentation).toBe('separator');
expect(items[0].status).toBe('running');
expect(items[0].statusKey).toBe('memory-compression');
}
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
label: '已整理上下文',
phase: 'completed',
status: 'done',
statusKey: 'memory-compression',
compressed: true,
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('已整理上下文');
expect(items[0].presentation).toBe('separator');
expect(items[0].status).toBe('done');
expect(items[0].statusKey).toBe('memory-compression');
}
});
it('shows no compression needed when memory compression produced no compressed event', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
compressed: false,
label: '已整理上下文',
phase: 'completed',
status: 'done',
statusKey: 'memory-compression',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('status');
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('无需压缩上下文');
expect(items[0].presentation).toBe('separator');
expect(items[0].status).toBe('done');
expect(items[0].statusKey).toBe('memory-compression');
}
});
it('ends current thinking before showing knowledge retrieval status', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '需要先检索知识库');
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-knowledge',
toolName: 'retrieve_knowledge',
});
ChatTimelineBuilder.appendThinkingDelta(items, '开始分析检索结果');
expect(items).toHaveLength(3);
expect(items[0]?.type).toBe('message');
expect(items[1]?.type).toBe('status');
expect(items[2]?.type).toBe('message');
if (items[0]?.type === 'message' && items[2]?.type === 'message') {
expect(items[0].status).toBe('done');
expect(items[0].roundCompleted).not.toBe(true);
expect(items[0].parts[0]).toMatchObject({
content: '需要先检索知识库',
expanded: false,
status: 'end',
type: 'thinking',
});
expect(items[2].parts[0]).toMatchObject({
content: '开始分析检索结果',
expanded: true,
status: 'thinking',
type: 'thinking',
});
}
});
it('keeps AgentScope internal fragment hidden without showing knowledge status', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-fragment',
toolName: '__fragment__',
input: { query: '暑假安排' },
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-fragment',
toolName: '__fragment__',
output: { result: 'ok' },
status: 'success',
});
expect(items).toHaveLength(0);
});
it('ignores anonymous tool call events instead of rendering a fallback card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-fragment',
input: { arguments: '{"query":"test"}' },
});
expect(items).toHaveLength(0);
});
it('does not let hidden tool events overwrite a visible tool card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '查询工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '__fragment__',
input: { arguments: '{"keyword":"EasyFlow"}' },
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
toolCallId: 'call-1',
toolName: '查询工具',
type: 'tool',
});
});
it('keeps knowledge retrieval events in one timeline status row', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-knowledge-1',
toolName: 'retrieve_knowledge',
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-knowledge-2',
toolName: 'retrieve_knowledge',
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-knowledge-1',
toolName: 'retrieve_knowledge',
status: 'success',
});
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
'done',
'knowledge-retrieval',
);
expect(items).toHaveLength(1);
expect(items.every((item) => item.type === 'status')).toBe(true);
if (items[0]?.type === 'status') {
expect(items[0].label).toBe('已检索知识库');
expect(items[0].status).toBe('done');
expect(items[0].statusKey).toBe('knowledge-retrieval');
}
});
it('attaches final knowledge citations to assistant message', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendMessageDelta(items, '暑假安排如下');
ChatTimelineBuilder.appendKnowledge(items, [
{
chunkContent: '暑假安排原文',
chunkId: 'faq-1',
faqCollection: true,
knowledgeName: '学生事务 FAQ',
},
]);
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('message');
if (items[0]?.type === 'message') {
expect(items[0].knowledgeItems?.[0]?.chunkContent).toBe('暑假安排原文');
expect(items[0].knowledgeItems?.[0]?.knowledgeName).toBe('学生事务 FAQ');
}
});
it('keeps knowledge citations as fallback item without assistant message', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendKnowledge(items, [
{
chunkContent: '第一段',
chunkId: 'chunk-1',
documentId: 'doc-1',
documentName: '治理方案.docx',
},
]);
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('knowledge');
if (items[0]?.type === 'knowledge') {
expect(items[0].items).toHaveLength(1);
}
});
it('keeps approval tool lifecycle in one card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
toolCallId: 'call-1',
toolName: '审批工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.markToolApproving(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
toolCallId: 'call-1',
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '审批工具',
status: 'running',
});
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
output: { result: 'ok' },
status: 'success',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('tool');
if (items[0]?.type === 'tool') {
expect(items[0].mode).toBe('approval');
expect(items[0].status).toBe('success');
expect(items[0].approval?.requestId).toBe('request-1');
expect(items[0].input).toEqual({ keyword: 'EasyFlow' });
expect(items[0].output).toEqual({ result: 'ok' });
}
});
it('marks approval tool rejected in the same card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
toolCallId: 'call-1',
toolName: '审批工具',
input: { keyword: 'EasyFlow' },
});
ChatTimelineBuilder.markToolRejected(items, {
requestId: 'request-1',
toolCallId: 'call-1',
reason: '用户拒绝执行',
});
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('tool');
if (items[0]?.type === 'tool') {
expect(items[0].mode).toBe('approval');
expect(items[0].status).toBe('rejected');
expect(items[0].rejectReason).toBe('用户拒绝执行');
}
});
it('keeps approval and auto updates separate when toolCallId differs', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.upsertToolCall(items, {
toolCallId: 'call-1',
toolName: '查询工具',
input: { keyword: 'before approval' },
});
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
toolCallId: 'call-2',
toolName: '查询工具',
input: { keyword: 'approval' },
});
expect(items).toHaveLength(2);
expect(items[0]?.type).toBe('tool');
expect(items[1]?.type).toBe('tool');
if (items[0]?.type === 'tool' && items[1]?.type === 'tool') {
expect(items[0].toolCallId).toBe('call-1');
expect(items[0].mode).toBe('auto');
expect(items[1].toolCallId).toBe('call-2');
expect(items[1].mode).toBe('approval');
expect(items[1].status).toBe('pending_approval');
expect(items[1].approval?.requestId).toBe('request-1');
expect(items[1].input).toEqual({ keyword: 'approval' });
}
});
it('streams thinking content in a thinking part', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, 'A');
ChatTimelineBuilder.appendThinkingDelta(items, 'B');
expect(items).toHaveLength(1);
expect(items[0]?.type).toBe('message');
if (items[0]?.type === 'message') {
expect(items[0].parts).toHaveLength(1);
expect(items[0].parts[0]).toMatchObject({
content: 'AB',
expanded: true,
status: 'thinking',
type: 'thinking',
});
}
});
it('keeps reasoning content separate from normal text payloads', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '思考中');
ChatTimelineBuilder.appendMessageDelta(items, '正文');
expect(items).toHaveLength(1);
if (items[0]?.type === 'message') {
expect(items[0].parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(items[0].parts[0]?.content).toBe('思考中');
expect(items[0].parts[0]).toMatchObject({
expanded: false,
status: 'end',
});
expect(items[0].parts[1]?.content).toBe('正文');
}
});
it('appends duplicated text deltas without filtering model output', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '思考中');
ChatTimelineBuilder.appendMessageDelta(items, '你好啊');
ChatTimelineBuilder.appendMessageDelta(items, '你好啊');
expect(items).toHaveLength(1);
if (items[0]?.type === 'message') {
expect(items[0].parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(items[0].parts[1]?.content).toBe('你好啊你好啊');
}
});
it('appends accumulated-looking text without guessing protocol semantics', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '思考中');
ChatTimelineBuilder.appendMessageDelta(items, '你好啊!很高兴见到');
ChatTimelineBuilder.appendMessageDelta(
items,
'你好啊!很高兴见到你!有什么我可以帮助你的吗?',
);
expect(items).toHaveLength(1);
if (items[0]?.type === 'message') {
expect(items[0].parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(items[0].parts[1]?.content).toBe(
'你好啊!很高兴见到你好啊!很高兴见到你!有什么我可以帮助你的吗?',
);
}
});
it('keeps markdown and code text exactly as streamed', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '思考中');
ChatTimelineBuilder.appendMessageDelta(items, '## 标题\n');
ChatTimelineBuilder.appendMessageDelta(items, '| 模型 | 说明 |\n');
ChatTimelineBuilder.appendMessageDelta(items, '| --- | --- |\n');
ChatTimelineBuilder.appendMessageDelta(items, '| ACL | 访问控制列表 |\n');
ChatTimelineBuilder.appendMessageDelta(
items,
'Final Answer: ```echartsoption',
);
expect(items).toHaveLength(1);
if (items[0]?.type === 'message') {
expect(items[0].status).toBe('streaming');
expect(items[0].parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(items[0].parts[1]?.content).toBe(
'## 标题\n| 模型 | 说明 |\n| --- | --- |\n| ACL | 访问控制列表 |\nFinal Answer: ```echartsoption',
);
}
});
it('appends thinking delta without accumulated snapshot replacement', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(
items,
'用户问的是“暑假安排是什么”。',
);
ChatTimelineBuilder.appendThinkingDelta(
items,
'用户问的是“暑假安排是什么”。我需要先检索知识库,看看有没有相关文档。',
);
ChatTimelineBuilder.appendMessageDelta(items, '正文');
expect(items).toHaveLength(1);
if (items[0]?.type === 'message') {
expect(items[0].parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(items[0].parts[0]?.content).toBe(
'用户问的是“暑假安排是什么”。用户问的是“暑假安排是什么”。我需要先检索知识库,看看有没有相关文档。',
);
expect(items[0].parts[1]?.content).toBe('正文');
}
});
it('ignores late thinking delta after text started in the same assistant message', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendThinkingDelta(items, '思考 A');
ChatTimelineBuilder.appendMessageDelta(items, '正文');
ChatTimelineBuilder.appendThinkingDelta(items, '思考 B');
expect(items).toHaveLength(1);
if (items[0]?.type === 'message') {
expect(items[0].parts.map((part) => part.type)).toEqual([
'thinking',
'text',
]);
expect(items[0].parts[0]?.content).toBe('思考 A');
expect(items[0].parts[1]?.content).toBe('正文');
}
});
});

View File

@@ -0,0 +1,572 @@
import type {
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,
ChatTimelineThinkingStatus,
ChatTimelineToolApprovalPayload,
ChatTimelineToolItem,
ChatTimelineToolMode,
ChatTimelineToolStatus,
} from './types';
function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function normalizeText(value: unknown) {
return value === null || value === undefined ? '' : String(value);
}
function normalizePayloadValue(value: unknown) {
if (value === null || value === undefined || value === '') {
return undefined;
}
if (typeof value === 'string') {
return value;
}
return value;
}
function normalizeToolName(value?: string) {
return normalizeText(value).trim().toLowerCase();
}
function isHiddenToolName(toolName?: string) {
const normalizedName = normalizeToolName(toolName);
return (
normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__'
);
}
function isKnowledgeRetrievalToolName(toolName?: string) {
return normalizeToolName(toolName) === 'retrieve_knowledge';
}
function isBlankToolName(toolName?: string) {
return !normalizeToolName(toolName);
}
function knowledgeRetrievalStatusKey(statusKey?: string) {
return normalizeText(statusKey).trim() || 'knowledge-retrieval';
}
function ensureMessageTail(
items: ChatTimelineItem[],
role: ChatTimelineMessageItem['role'],
status: ChatTimelineMessageItem['status'] = 'streaming',
metadata?: Partial<ChatTimelineMessageItem>,
) {
const last = items[items.length - 1];
if (
last?.type === 'message' &&
last.role === role &&
last.status !== 'done' &&
(!metadata?.roundId || last.roundId === metadata.roundId)
) {
last.status = status;
Object.assign(last, metadata);
return last;
}
const item: ChatTimelineMessageItem = {
id: createId(role),
role,
status,
createdAt: Date.now(),
parts: [],
type: 'message',
...metadata,
};
items.push(item);
return item;
}
function appendMessagePart(
message: ChatTimelineMessageItem,
part: ChatTimelineMessagePart,
) {
const tail = message.parts[message.parts.length - 1];
if (tail?.type === part.type) {
tail.content += part.content;
if (tail.type === 'thinking' && part.type === 'thinking') {
tail.status = part.status;
}
return;
}
message.parts.push(part);
}
function appendThinkingPart(
message: ChatTimelineMessageItem,
part: Extract<ChatTimelineMessagePart, { type: 'thinking' }>,
) {
appendMessagePart(message, part);
}
function appendTextPart(message: ChatTimelineMessageItem, content: string) {
appendMessagePart(message, {
id: createId('text'),
content,
type: 'text',
});
}
function replaceTextPart(message: ChatTimelineMessageItem, content: string) {
message.parts = [
...message.parts.filter((part) => part.type !== 'text'),
{
id: createId('text'),
content,
type: 'text' as const,
},
];
}
function updateThinkingStatus(
message: ChatTimelineMessageItem,
status: ChatTimelineThinkingStatus,
) {
message.parts = message.parts.map((part) =>
part.type === 'thinking' && part.status === 'thinking'
? { ...part, expanded: status === 'thinking', status }
: part,
);
}
function finishLastAssistantMessage(items: ChatTimelineItem[]) {
finishAssistantMessage(items, true);
}
function finishAssistantMessage(
items: ChatTimelineItem[],
roundCompleted: boolean,
) {
const lastMessage = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
if (!lastMessage) {
return;
}
updateThinkingStatus(lastMessage, 'end');
lastMessage.status = lastMessage.status === 'error' ? 'error' : 'done';
if (lastMessage.status === 'done' && roundCompleted) {
lastMessage.roundCompleted = true;
}
}
function findToolItem(items: ChatTimelineItem[], toolCallId?: string) {
const identity = normalizeText(toolCallId).trim();
if (!identity) {
return undefined;
}
return items.find(
(item): item is ChatTimelineToolItem =>
item.type === 'tool' && item.toolCallId === identity,
);
}
function findStatusItem(items: ChatTimelineItem[], statusKey: string) {
return items.find(
(item): item is ChatTimelineStatusItem =>
item.type === 'status' && item.statusKey === statusKey,
);
}
function doneStatusLabel(item: ChatTimelineStatusItem) {
if (item.statusKey === 'knowledge-retrieval') {
return '已检索知识库';
}
if (item.statusKey === 'memory-compression') {
return '已整理上下文';
}
return item.label.replace(/^正在/, '已');
}
function finishRunningStatusItems(items: ChatTimelineItem[]) {
items.forEach((item) => {
if (item.type !== 'status' || item.status !== 'running') {
return;
}
item.status = 'done';
item.label = doneStatusLabel(item);
});
}
function upsertStatus(
items: ChatTimelineItem[],
payload: {
label: string;
presentation?: ChatTimelineStatusItem['presentation'];
status: ChatTimelineStatusStatus;
statusKey: string;
tone?: ChatTimelineStatusTone;
},
) {
const found = findStatusItem(items, payload.statusKey);
if (found) {
found.label = payload.label;
found.presentation = payload.presentation ?? found.presentation;
found.status = payload.status;
found.tone = payload.tone ?? found.tone;
return found;
}
const item: ChatTimelineStatusItem = {
id: payload.statusKey,
createdAt: Date.now(),
label: payload.label,
presentation: payload.presentation,
status: payload.status,
statusKey: payload.statusKey,
tone: payload.tone ?? 'muted',
type: 'status',
};
items.push(item);
return item;
}
function upsertTool(
items: ChatTimelineItem[],
payload: {
approval?: ChatTimelineToolApprovalPayload;
input?: unknown;
mode?: ChatTimelineToolMode;
output?: unknown;
rejectReason?: string;
requestId?: string;
resumeToken?: string;
status?: ChatTimelineToolStatus;
toolCallId?: string;
toolName?: string;
},
) {
const toolCallId = normalizeText(
payload.toolCallId ?? payload.approval?.toolCallId,
).trim();
const found = findToolItem(items, toolCallId);
const approval = payload.approval ?? found?.approval;
const mode =
payload.mode === 'approval'
? 'approval'
: (found?.mode ?? payload.mode ?? (approval ? 'approval' : 'auto'));
const toolName =
payload.toolName ||
approval?.toolDisplayName ||
approval?.toolName ||
found?.toolName;
if (isHiddenToolName(toolName)) {
return found;
}
if (!found && isBlankToolName(toolName)) {
return undefined;
}
if (found) {
found.approval = approval;
found.input =
normalizePayloadValue(payload.input) ??
normalizePayloadValue(approval?.input) ??
found.input;
found.mode = mode;
found.output = normalizePayloadValue(payload.output) ?? found.output;
found.rejectReason = payload.rejectReason ?? found.rejectReason;
found.status = payload.status || found.status;
found.toolCallId = toolCallId || found.toolCallId;
found.toolName = toolName || found.toolName;
return found;
}
const toolItem: ChatTimelineToolItem = {
id: toolCallId || createId('tool'),
approval,
createdAt: Date.now(),
input:
normalizePayloadValue(payload.input) ??
normalizePayloadValue(approval?.input),
mode,
output: normalizePayloadValue(payload.output),
rejectReason: payload.rejectReason,
status:
payload.status || (mode === 'approval' ? 'pending_approval' : 'running'),
toolCallId,
toolName: toolName || '工具调用',
type: 'tool',
};
items.push(toolItem);
return toolItem;
}
export const ChatTimelineBuilder = {
appendUserMessage(
items: ChatTimelineItem[],
content?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(content);
if (!text) {
return;
}
const item: ChatTimelineMessageItem = {
id: createId('user'),
role: 'user',
status: 'done',
createdAt: Date.now(),
parts: [
{
id: createId('text'),
content: text,
type: 'text',
},
],
type: 'message',
...metadata,
};
items.push(item);
},
appendThinkingDelta(
items: ChatTimelineItem[],
delta?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(delta);
if (!text) {
return;
}
const message = ensureMessageTail(
items,
'assistant',
'streaming',
metadata,
);
if (message.parts.some((part) => part.type === 'text')) {
return;
}
appendThinkingPart(message, {
id: createId('thinking'),
content: text,
expanded: true,
status: 'thinking',
type: 'thinking',
});
},
appendMessageDelta(
items: ChatTimelineItem[],
delta?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(delta);
if (!text) {
return;
}
const message = ensureMessageTail(
items,
'assistant',
'streaming',
metadata,
);
updateThinkingStatus(message, 'end');
appendTextPart(message, text);
},
replaceMessageContent(items: ChatTimelineItem[], content?: unknown) {
const text = normalizeText(content);
if (!text) {
return;
}
const message = ensureMessageTail(items, 'assistant', 'done');
updateThinkingStatus(message, 'end');
replaceTextPart(message, text);
},
appendToolApproval(
items: ChatTimelineItem[],
payload: ChatTimelineToolApprovalPayload,
) {
upsertTool(items, {
approval: payload,
input: payload.input,
mode: 'approval',
status: 'pending_approval',
toolCallId: payload.toolCallId,
toolName: payload.toolDisplayName || payload.toolName,
});
},
upsertToolCall(
items: ChatTimelineItem[],
payload: {
input?: unknown;
output?: unknown;
status?: ChatTimelineToolStatus;
statusKey?: string;
toolCallId?: string;
toolName?: string;
},
) {
if (isKnowledgeRetrievalToolName(payload.toolName)) {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
payload.status === 'success' ? 'done' : 'running',
payload.statusKey,
);
return;
}
upsertTool(items, {
...payload,
mode: 'auto',
status: payload.status || 'running',
});
},
upsertKnowledgeRetrievalStatus(
items: ChatTimelineItem[],
status: ChatTimelineStatusStatus,
statusKey?: string,
) {
finishAssistantMessage(items, false);
upsertStatus(items, {
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
status,
statusKey: knowledgeRetrievalStatusKey(statusKey),
tone: 'muted',
});
},
upsertMemoryCompressionStatus(
items: ChatTimelineItem[],
payload?: {
compressed?: boolean;
label?: string;
phase?: string;
status?: string;
statusKey?: string;
},
) {
const status =
payload?.status === 'done' || payload?.phase === 'completed'
? 'done'
: 'running';
finishAssistantMessage(items, false);
const label =
status === 'running'
? payload?.label || '正在整理上下文'
: payload?.compressed === false
? '无需压缩上下文'
: payload?.label || '已整理上下文';
upsertStatus(items, {
label,
status,
statusKey: payload?.statusKey || 'memory-compression',
presentation: 'separator',
tone: 'muted',
});
},
markToolApproving(
items: ChatTimelineItem[],
payload: {
requestId?: string;
resumeToken?: string;
toolCallId?: string;
},
) {
upsertTool(items, {
...payload,
mode: 'approval',
status: 'approving',
});
},
markToolRejected(
items: ChatTimelineItem[],
payload: {
reason?: string;
requestId?: string;
resumeToken?: string;
toolCallId?: string;
},
) {
upsertTool(items, {
...payload,
mode: 'approval',
rejectReason: payload.reason,
status: 'rejected',
});
},
appendKnowledge(
items: ChatTimelineItem[],
knowledgeItems: ChatTimelineKnowledgeHit[],
) {
if (knowledgeItems.length === 0) {
return;
}
const lastAssistantMessage = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.role === 'assistant',
);
if (lastAssistantMessage) {
lastAssistantMessage.knowledgeItems = [
...(lastAssistantMessage.knowledgeItems || []),
...knowledgeItems,
];
return;
}
const last = items[items.length - 1];
if (last?.type === 'knowledge') {
last.items.push(...knowledgeItems);
return;
}
items.push({
id: createId('knowledge'),
createdAt: Date.now(),
items: knowledgeItems,
type: 'knowledge',
});
},
appendError(items: ChatTimelineItem[], message?: unknown) {
const text = normalizeText(message) || '请求失败';
const last = items[items.length - 1];
if (last?.type === 'message' && last.role === 'assistant') {
updateThinkingStatus(last, 'error');
last.status = 'error';
}
items.push({
id: createId('error'),
createdAt: Date.now(),
message: text,
type: 'error',
});
},
finalize(items: ChatTimelineItem[]) {
finishRunningStatusItems(items);
finishLastAssistantMessage(items);
},
replaceRoundAssistant(
items: ChatTimelineItem[],
roundId: string,
message: ChatTimelineMessageItem,
) {
const targetIndex = items.findIndex(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
item.roundId === roundId,
);
if (targetIndex >= 0) {
items.splice(targetIndex, 1, message);
}
},
};

View File

@@ -0,0 +1,29 @@
export { ChatTimelineBuilder } from './builder';
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue';
export { default as ChatTextBlock } from './ChatTextBlock.vue';
export { default as ChatTimeline } from './ChatTimeline.vue';
export { default as ChatTimelineItemView } from './ChatTimelineItem.vue';
export { default as ChatTimelineStatusRow } from './ChatTimelineStatusRow.vue';
export { default as ChatToolApprovalCard } from './ChatToolApprovalCard.vue';
export { default as ChatToolCard } from './ChatToolCard.vue';
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
export type {
ChatTimelineErrorItem,
ChatTimelineItem,
ChatTimelineItemStatus,
ChatTimelineKnowledgeHit,
ChatTimelineKnowledgeItem,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineRole,
ChatTimelineThinkingStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,
ChatTimelineToolApprovalItem,
ChatTimelineToolApprovalPayload,
ChatTimelineToolItem,
ChatTimelineToolStatus,
} from './types';

View File

@@ -0,0 +1,127 @@
export type ChatTimelineRole = 'assistant' | 'system' | 'user';
export type ChatTimelineItemStatus = 'done' | 'error' | 'pending' | 'streaming';
export type ChatTimelineThinkingStatus = 'end' | 'error' | 'thinking';
export type ChatTimelineToolMode = 'approval' | 'auto';
export type ChatTimelineToolStatus =
| 'approving'
| 'error'
| 'pending_approval'
| 'rejected'
| 'running'
| 'success';
export type ChatTimelineStatusStatus = 'done' | 'running';
export type ChatTimelineStatusTone = 'muted';
export interface ChatTimelineToolApprovalPayload {
requestId: string;
resumeToken: string;
toolName: string;
toolDisplayName?: string;
toolCallId?: string;
toolType?: string;
input?: unknown;
expiresAt?: string;
metadata?: unknown;
}
export interface ChatTimelineKnowledgeHit {
chunkContent?: string;
chunkId?: string;
id?: string;
documentId?: string;
documentName?: string;
faqCollection?: boolean;
knowledgeId?: string;
knowledgeName?: string;
knowledgeType?: string;
metadata?: Record<string, unknown>;
source?: string;
sourceFileName?: string;
sourceUri?: string;
score?: number | string;
title?: string;
content?: string;
[key: string]: any;
}
export interface ChatTimelineItemBase {
createdAt?: number;
id: string;
}
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
knowledgeItems?: ChatTimelineKnowledgeHit[];
parts: ChatTimelineMessagePart[];
regenerable?: boolean;
role: ChatTimelineRole;
roundId?: string;
roundCompleted?: boolean;
roundNo?: number;
status?: ChatTimelineItemStatus;
selectedVariantIndex?: number;
switchable?: boolean;
type: 'message';
variantCount?: number;
variantIndex?: number;
}
export interface ChatTimelineToolItem extends ChatTimelineItemBase {
approval?: ChatTimelineToolApprovalPayload;
input?: unknown;
mode: ChatTimelineToolMode;
output?: unknown;
rejectReason?: string;
status: ChatTimelineToolStatus;
toolCallId?: string;
toolName: string;
type: 'tool';
}
/**
* @deprecated 工具审批已聚合到 ChatTimelineToolItem保留类型用于旧调用方过渡。
*/
export interface ChatTimelineToolApprovalItem extends ChatTimelineItemBase {
payload: ChatTimelineToolApprovalPayload;
type: 'tool_approval';
}
export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
items: ChatTimelineKnowledgeHit[];
type: 'knowledge';
}
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
label: string;
presentation?: 'inline' | 'separator';
status: ChatTimelineStatusStatus;
statusKey: string;
tone?: ChatTimelineStatusTone;
type: 'status';
}
export interface ChatTimelineErrorItem extends ChatTimelineItemBase {
message: string;
type: 'error';
}
export type ChatTimelineItem =
| ChatTimelineErrorItem
| ChatTimelineKnowledgeItem
| ChatTimelineMessageItem
| ChatTimelineStatusItem
| ChatTimelineToolApprovalItem
| ChatTimelineToolItem;
export type ChatTimelineMessagePart =
| {
content: string;
id: string;
type: 'text';
}
| {
content: string;
expanded?: boolean;
id: string;
status: ChatTimelineThinkingStatus;
type: 'thinking';
};

View File

@@ -1,7 +1,9 @@
export * from './api-component';
export * from './captcha';
export * from './chat-markdown';
export * from './chat-status';
export * from './chat-thinking';
export * from './chat-timeline';
export * from './col-page';
export * from './count-to';
export * from './ellipsis-text';

View File

@@ -1,6 +1,7 @@
import type { useSvelteFlow } from '@xyflow/svelte';
import { componentName } from './consts';
import type { TinyflowData, TinyflowOptions, TinyflowTheme } from './types';
import type {useSvelteFlow} from '@xyflow/svelte';
import {componentName} from './consts';
import {store} from './store/stores.svelte';
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
type FlowInstance = ReturnType<typeof useSvelteFlow>;
@@ -93,6 +94,37 @@ export class Tinyflow {
return flow.toObject();
}
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {
const flow = this._getFlowInstance();
if (!flow) {
return false;
}
const currentViewport = flow.getViewport();
const currentNodes = flow.getNodes();
const currentNodePositions = new Map(
currentNodes.map((node) => [node.id, node.position]),
);
const nextNodes =
options?.preserveViewport === true
? (data.nodes || currentNodes).map((node) => {
const currentPosition = currentNodePositions.get(node.id);
return currentPosition
? { ...node, position: { ...currentPosition } }
: node;
})
: data.nodes || currentNodes;
store.setNodes(nextNodes);
store.setEdges(data.edges || flow.getEdges());
if (data.viewport && options?.preserveViewport !== true) {
flow.setViewport(data.viewport, { duration: 0 });
} else {
flow.setViewport(currentViewport, { duration: 0 });
}
return true;
}
async focusNode(
nodeId: string,
options?: { duration?: number; zoom?: number },

View File

@@ -65,6 +65,14 @@
const readonly = options.readonly === true;
let canvasLocked = $state(readonly);
const hideBottomDock = options.hideBottomDock === true;
const hideEdgePanel = options.hideEdgePanel === true;
const hideMiniMap = options.hideMiniMap === true;
const hideNodePicker = options.hideNodePicker === true;
const nodesDraggable = options.nodesDraggable ?? !readonly;
const nodesConnectable = options.nodesConnectable ?? !readonly;
const elementsSelectable = options.elementsSelectable ?? !readonly;
const dropEnabled = options.dropEnabled ?? !readonly;
const connectionEnabled = nodesConnectable && !readonly;
const availableNodes = getAvailableNodes(options);
const onRunTest = options.onRunTest;
@@ -779,22 +787,22 @@
bind:nodes={store.getNodes, store.setNodes}
bind:edges={store.getEdges, store.setEdges}
bind:viewport={store.getViewport, store.setViewport}
nodesDraggable={!canvasLocked}
nodesConnectable={!canvasLocked}
elementsSelectable={!canvasLocked}
nodesDraggable={nodesDraggable && !canvasLocked}
nodesConnectable={nodesConnectable && !canvasLocked}
elementsSelectable={elementsSelectable && !canvasLocked}
panOnDrag={readonly ? true : !canvasLocked}
zoomOnScroll={readonly ? true : !canvasLocked}
zoomOnDoubleClick={readonly ? true : !canvasLocked}
ondrop={readonly ? undefined : onDrop}
ondragover={readonly ? undefined : onDragOver}
ondrop={dropEnabled ? onDrop : undefined}
ondragover={dropEnabled ? onDragOver : undefined}
isValidConnection={isValidConnection}
onconnectend={readonly ? undefined : onconnectend}
onconnectstart={readonly ? undefined : onconnectstart}
onconnect={readonly ? undefined : onconnect}
onconnectend={connectionEnabled ? onconnectend : undefined}
onconnectstart={connectionEnabled ? onconnectstart : undefined}
onconnect={connectionEnabled ? onconnect : undefined}
connectionRadius={50}
connectionLineComponent={FlowConnectionLine}
onedgeclick={(e) => {
if (readonly) {
if (readonly || hideEdgePanel) {
return;
}
showEdgePanel = true;
@@ -803,7 +811,7 @@
onbeforeconnect={(edge: any) => normalizeEdgeBeforeConnect(edge)}
ondelete={readonly ? undefined : onDelete}
onclick={(e) => {
if (readonly) {
if (readonly || hideEdgePanel) {
return;
}
const el = e.target as HTMLElement;
@@ -825,7 +833,9 @@
}}
>
<Background />
<MiniMap />
{#if !hideMiniMap}
<MiniMap />
{/if}
{#if showEdgePanel}
<Panel>
@@ -889,7 +899,7 @@
</Panel>
{/if}
</SvelteFlow>
{#if nodePickerVisible}
{#if nodePickerVisible && !hideNodePicker}
{#if pendingConnectionLine}
<svg class="node-picker-connection-line" width="100%" height="100%">
<FlowMarkerDefs id="tf-flow-inline-arrow-closed" />

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import {EdgeLabel, type EdgeProps, getBezierPath} from '@xyflow/svelte';
import FlowLinePath from './FlowLinePath.svelte';
import {getOptions} from '../utils/NodeUtils';
let {
sourceX,
@@ -16,6 +17,15 @@
labelStyle
}: EdgeProps = $props();
const options = getOptions();
const resolvedMarkerStart = $derived(options.hideEdgeMarkers === true ? undefined : markerStart);
const resolvedMarkerEnd = $derived(options.hideEdgeMarkers === true ? undefined : markerEnd);
const resolvedInteractionWidth = $derived(
typeof options.edgeInteractionWidth === 'number'
? options.edgeInteractionWidth
: interactionWidth
);
const edgeAnimated = $derived(options.edgeAnimated === false ? false : true);
const bezierPathResult = $derived.by(() =>
getBezierPath({
sourceX,
@@ -32,13 +42,13 @@
const labelY = $derived(bezierPathResult[2]);
</script>
<FlowLinePath path={path} {markerStart} {markerEnd} animated={true} />
<FlowLinePath path={path} markerStart={resolvedMarkerStart} markerEnd={resolvedMarkerEnd} animated={edgeAnimated} />
{#if interactionWidth > 0}
{#if resolvedInteractionWidth > 0}
<path
d={path}
stroke-opacity={0}
stroke-width={interactionWidth}
stroke-width={resolvedInteractionWidth}
fill="none"
class="svelte-flow__edge-interaction"
></path>

View File

@@ -8,8 +8,7 @@
useUpdateNodeInternals
} from '@xyflow/svelte';
import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base';
import {type Snippet} from 'svelte';
import {onDestroy, onMount} from 'svelte';
import {onDestroy, onMount, type Snippet} from 'svelte';
import {useDeleteNode} from '../utils/useDeleteNode.svelte';
import {useCopyNode} from '../utils/useCopyNode.svelte';
import {getOptions} from '../utils/NodeUtils';
@@ -70,6 +69,13 @@
const { copyNode } = useCopyNode();
const options = getOptions();
const toolbarHidden = options.hideNodeToolbar === true;
const nodeSettingHidden = options.hideNodeSetting === true;
const handlesHidden = options.hideNodeHandles === true;
const toolbarDeleteEnabled = $derived(allowDelete && !toolbarHidden);
const toolbarCopyEnabled = $derived(allowCopy && !toolbarHidden);
const toolbarExecuteEnabled = $derived(allowExecute && !toolbarHidden);
const toolbarSettingEnabled = $derived(allowSetting && !toolbarHidden && !nodeSettingHidden);
const executeNode = () => {
options.onNodeExecute?.(getNode(id)!);
@@ -111,10 +117,10 @@
</script>
{#if allowExecute || allowCopy || allowDelete}
{#if toolbarExecuteEnabled || toolbarCopyEnabled || toolbarDeleteEnabled || toolbarSettingEnabled}
<NodeToolbar position={Position.Top} align="start">
<div class="tf-node-toolbar">
{#if allowDelete}
{#if toolbarDeleteEnabled}
<Button class="tf-node-toolbar-item" onclick={()=>{ deleteNode(id) }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -122,7 +128,7 @@
</svg>
</Button>
{/if}
{#if allowCopy}
{#if toolbarCopyEnabled}
<Button class="tf-node-toolbar-item" onclick={()=>{copyNode(id)}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -130,7 +136,7 @@
</svg>
</Button>
{/if}
{#if allowExecute}
{#if toolbarExecuteEnabled}
<Button class="tf-node-toolbar-item" onclick={executeNode}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -138,7 +144,7 @@
</svg>
</Button>
{/if}
{#if allowSetting}
{#if toolbarSettingEnabled}
<FloatingTrigger placement="bottom">
<Button class="tf-node-toolbar-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
@@ -294,10 +300,10 @@
</div>
</div>
{#if showTargetHandle}
{#if showTargetHandle && !handlesHidden}
<Handle type="target" position={Position.Left} style=" left: -12px;top: 20px" />
{/if}
{#if showSourceHandle}
{#if showSourceHandle && !handlesHidden}
<Handle type="source" position={Position.Right} style="right: -12px;top: 20px" />
{/if}
{@render handle?.()}

View File

@@ -2,7 +2,7 @@
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type Node, type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {type Node, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
@@ -76,7 +76,7 @@
let container = $state<HTMLElement | null>(null);
$effect(() => {
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
if (data.expand && container) {
if ((data.expand || customNode.presentation === 'plain') && container) {
container.append(externalElement);
}
});
@@ -105,12 +105,14 @@
</script>
{#if customNode.presentation === 'plain'}
<div bind:this={container} style={customNode.rootStyle||""} class={customNode.rootClass}></div>
{:else}
<NodeWrapper data={{...data, description: customNode.description}} {...getRestProps()}>
<NodeWrapper data={{...data, description: customNode.description}} {...getRestProps()}>
{#snippet icon()}
{@html customNode.icon}
{/snippet}
{#snippet icon()}
{@html customNode.icon}
{/snippet}
{#if customNode.parametersEnable !== false}
<div class="heading">
@@ -251,7 +253,8 @@
<OutputDefList />
{/if}
</NodeWrapper>
</NodeWrapper>
{/if}
<style>
.heading {

View File

@@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { Node, useSvelteFlow } from '@xyflow/svelte';
import type {Snippet} from 'svelte';
import type {Node, useSvelteFlow} from '@xyflow/svelte';
export type TinyflowData = Partial<
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
@@ -50,7 +50,9 @@ export type CustomNodeForm = {
defaultValue?: string | number | boolean;
attrs?: Record<string, any>;
options?: SelectItem[];
resolveValue?: (data: Record<string, any>) => string | number | boolean | undefined;
resolveValue?: (
data: Record<string, any>,
) => string | number | boolean | undefined;
resolveOptions?: (data: Record<string, any>) => SelectItem[];
onValueChange?: (
value: string | number | boolean | undefined,
@@ -76,6 +78,7 @@ export type CustomNode = {
icon?: string;
sortNo?: number;
group?: 'base' | 'tools';
presentation?: 'default' | 'plain';
renderFirst?: boolean;
rootClass?: string;
rootStyle?: string;
@@ -100,6 +103,19 @@ export type TinyflowOptions = {
data?: TinyflowData | string;
readonly?: boolean;
hideBottomDock?: boolean;
hideEdgePanel?: boolean;
hideMiniMap?: boolean;
hideNodeHandles?: boolean;
hideNodeToolbar?: boolean;
hideNodePicker?: boolean;
hideNodeSetting?: boolean;
hideEdgeMarkers?: boolean;
edgeAnimated?: boolean;
edgeInteractionWidth?: number;
nodesDraggable?: boolean;
nodesConnectable?: boolean;
elementsSelectable?: boolean;
dropEnabled?: boolean;
provider?: {
llm?: () => SelectItem[] | Promise<SelectItem[]>;
knowledge?: () => SelectItem[] | Promise<SelectItem[]>;

View File

@@ -3,9 +3,11 @@
</template>
<script setup lang="ts">
import { Tinyflow as TinyflowNative, TinyflowOptions } from '@tinyflow-ai/ui';
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
import '@tinyflow-ai/ui/dist/index.css';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import {nextTick, onMounted, onUnmounted, ref, useAttrs, watch} from 'vue';
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
const props = defineProps<
{
@@ -15,7 +17,25 @@ const props = defineProps<
>();
const divRef = ref<HTMLDivElement | null>(null);
const attrs = useAttrs();
let tinyflow: TinyflowNative | null = null;
let mountedDataReady = false;
let lastAppliedDataSignature = '';
function normalizeOptionKey(key: string) {
return key.replace(/-([a-z])/g, (_match: string, letter: string) =>
letter.toUpperCase(),
);
}
function normalizeOptions(source: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(source).map(([key, value]) => [
normalizeOptionKey(key),
value,
]),
);
}
// 安全深拷贝工具函数
function safeDeepClone<T>(obj: T): T {
@@ -36,18 +56,45 @@ function safeDeepClone<T>(obj: T): T {
}
}
function createDataSignature(data: unknown) {
if (data == null || typeof data === 'string') {
return String(data ?? '');
}
try {
return JSON.stringify(data);
} catch {
return '';
}
}
function cloneDataIfChanged(data: TinyflowOptions['data']) {
if (data == null || typeof data === 'string') {
return null;
}
const signature = createDataSignature(data);
if (signature && signature === lastAppliedDataSignature) {
return null;
}
lastAppliedDataSignature = signature;
return safeDeepClone(data as TinyflowDataOption);
}
onMounted(() => {
if (divRef.value) {
// 净化 props.data避免响应式对象或函数污染
const cleanedProps = { ...props } as any;
const cleanedProps = {
...normalizeOptions(attrs),
...props,
} as any;
if ('data' in cleanedProps && cleanedProps.data != null) {
cleanedProps.data = safeDeepClone(cleanedProps.data);
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
}
tinyflow = new TinyflowNative({
...cleanedProps,
element: divRef.value,
});
mountedDataReady = true;
}
});
@@ -56,6 +103,8 @@ onUnmounted(() => {
tinyflow.destroy();
tinyflow = null;
}
mountedDataReady = false;
lastAppliedDataSignature = '';
});
watch(
@@ -67,6 +116,24 @@ watch(
},
);
watch(
() => props.data,
(data) => {
if (
tinyflow &&
mountedDataReady &&
data != null &&
typeof data !== 'string'
) {
const clonedData = cloneDataIfChanged(data);
if (clonedData) {
tinyflow.updateData(clonedData, { preserveViewport: true });
}
}
},
{ deep: true },
);
const getData = () => {
if (tinyflow) {
return tinyflow.getData();
@@ -103,10 +170,26 @@ const fitView = async (options?: { duration?: number; padding?: number }) => {
return false;
};
const updateData = (
data: TinyflowOptions['data'],
options?: { preserveViewport?: boolean },
) => {
if (tinyflow && data != null && typeof data !== 'string') {
const clonedData = cloneDataIfChanged(data);
if (!clonedData) {
return true;
}
return tinyflow.updateData(clonedData, options);
}
console.warn('Tinyflow instance is not initialized');
return false;
};
defineExpose({
getData,
getInstance,
focusNode,
fitView,
updateData,
});
</script>

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest';
import {describe, expect, it} from 'vitest';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
} from '../chat-time';
import {ChatTimeHistoryMapper, ChatTimeTimelineBuilder,} from '../chat-time';
describe('chat-time timeline builder', () => {
it('builds assistant thinking and message in the same assistant item', () => {
@@ -29,6 +26,37 @@ describe('chat-time timeline builder', () => {
]);
});
it('appends markdown deltas without altering repeated symbols', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendThinkingDelta(items, '先想一下', 1);
ChatTimeTimelineBuilder.appendMessageDelta(items, '## 标题\n', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '| 模型 | 说明 |\n', 3);
ChatTimeTimelineBuilder.appendMessageDelta(items, '| --- | --- |\n', 4);
ChatTimeTimelineBuilder.appendMessageDelta(items, '| ACL | 访问控制列表 |\n', 5);
ChatTimeTimelineBuilder.appendMessageDelta(
items,
'Final Answer: ```echartsoption',
6,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
content:
'## 标题\n| 模型 | 说明 |\n| --- | --- |\n| ACL | 访问控制列表 |\nFinal Answer: ```echartsoption',
role: 'assistant',
typing: true,
});
expect(items[0].segments).toMatchObject([
{ content: '先想一下', status: 'end', type: 'thinking' },
{
content:
'## 标题\n| 模型 | 说明 |\n| --- | --- |\n| ACL | 访问控制列表 |\nFinal Answer: ```echartsoption',
type: 'text',
},
]);
});
it('creates a new assistant item after tool result', () => {
const items: any[] = [];
@@ -61,6 +89,62 @@ describe('chat-time timeline builder', () => {
{ content: '第二段回答', type: 'text' },
]);
});
it('does not render built-in knowledge retrieval tools as normal tool cards', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'retrieve_knowledge',
toolCallId: 'knowledge-1',
value: '{"query":"请假安排"}',
});
ChatTimeTimelineBuilder.upsertToolResult(items, {
name: 'retrieve_knowledge',
result: '{"hits":1}',
toolCallId: 'knowledge-1',
});
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'search_docs',
toolCallId: 'tool-1',
value: '{"query":"java"}',
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
expect(items[1]).toMatchObject({
name: 'search_docs',
role: 'tool',
status: 'TOOL_CALL',
toolCallId: 'tool-1',
});
});
it('does not render anonymous internal tool calls as normal tool cards', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
toolCallId: 'fragment-1',
value: '{"arguments":"partial"}',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
});
it('does not render anonymous orphan tool results as normal tool cards', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolResult(items, {
result: '{"ok":true}',
toolCallId: 'fragment-1',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
});
});
describe('chat-time history mapper', () => {
@@ -118,6 +202,58 @@ describe('chat-time history mapper', () => {
expect(items[0]?.id).not.toBe(items[2]?.id);
});
it('skips built-in knowledge retrieval tools when restoring structured history', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
contentPayload: {
messageChain: [
{
content: '先回答一点',
role: 'assistant',
toolCalls: [
{
arguments: '{"query":"请假安排"}',
id: 'knowledge-1',
toolName: 'retrieve_knowledge',
},
{
arguments: '{"query":"java"}',
id: 'tool-1',
name: 'search_docs',
},
],
},
{
content: '{"hits":1}',
role: 'tool',
toolCallId: 'knowledge-1',
},
{
content: '{"hits":2}',
role: 'tool',
toolCallId: 'tool-1',
},
],
},
created: 100,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
content: '先回答一点',
role: 'assistant',
});
expect(items[1]).toMatchObject({
name: 'search_docs',
result: '{"hits":2}',
role: 'tool',
toolCallId: 'tool-1',
});
});
it('falls back to legacy chains when messageChain is unavailable', () => {
const items = ChatTimeHistoryMapper.fromLegacyMessages([
{

View File

@@ -10,13 +10,22 @@ import type {
ChatTimeToolStatus,
} from '../../../types/src/chat-time';
import { uuid } from './uuid';
import {uuid} from './uuid';
type ChatTimeToolMeta = {
arguments?: string;
name?: string;
};
function isHiddenToolName(value?: string) {
const normalized = normalizePlainText(value).trim().toLowerCase();
return normalized === 'retrieve_knowledge' || normalized === '__fragment__';
}
function isBlankToolName(value?: string) {
return !normalizePlainText(value).trim();
}
/**
* 聊天时间线实时构建器。
*/
@@ -159,6 +168,35 @@ class ChatTimeTimelineBuilder {
assistant.typing = true;
}
/**
* 用最终完整回答替换当前 assistant 文本。
*/
static replaceMessageContent(
items: ChatTimeTimelineItem[],
content?: string,
created?: number | string,
meta?: ChatTimeRoundMeta,
) {
const normalizedContent = normalizeAssistantText(content);
if (!normalizedContent) {
return;
}
prepareRoundVariant(items, meta);
const assistant = ensureAssistantTail(items, created, meta);
stopThinkingForAssistant(assistant);
assistant.content = normalizedContent;
assistant.segments = [
...assistant.segments.filter((segment) => segment.type !== 'text'),
{
content: normalizedContent,
id: uuid(),
type: 'text' as const,
},
];
assistant.loading = false;
assistant.typing = false;
}
/**
* 停止当前 assistant 的思考态。
*/
@@ -177,6 +215,9 @@ class ChatTimeTimelineBuilder {
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
if (isHiddenToolName(payload.name) || isBlankToolName(payload.name)) {
return;
}
prepareRoundVariant(items, payload);
this.stopThinking(items);
const toolItem = ensureToolItem(
@@ -198,6 +239,16 @@ class ChatTimeTimelineBuilder {
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
if (isHiddenToolName(payload.name)) {
return;
}
const normalizedToolCallId = normalizePlainText(payload.toolCallId);
if (
isBlankToolName(payload.name) &&
!findToolItem(items, normalizedToolCallId, payload)
) {
return;
}
prepareRoundVariant(items, payload);
const toolItem = ensureToolItem(
items,
@@ -298,7 +349,8 @@ class ChatTimeHistoryMapper {
return [createUserItem(record)];
}
if (role === 'tool') {
return [createToolItemFromTopLevelRecord(record)];
const toolItem = createToolItemFromTopLevelRecord(record);
return toolItem ? [toolItem] : [];
}
if (role !== 'assistant') {
return [];
@@ -324,7 +376,8 @@ class ChatTimeHistoryMapper {
return [createUserItem(record)];
}
if (role === 'tool') {
return [createToolItemFromTopLevelRecord(record)];
const toolItem = createToolItemFromTopLevelRecord(record);
return toolItem ? [toolItem] : [];
}
if (role !== 'assistant') {
return [];
@@ -404,14 +457,15 @@ class ChatTimeHistoryMapper {
}
if (role === 'tool') {
items.push(
createToolItemFromStructuredMessage(
rawMessage,
toolMetaMap,
record.created,
record,
),
const toolItem = createToolItemFromStructuredMessage(
rawMessage,
toolMetaMap,
record.created,
record,
);
if (toolItem) {
items.push(toolItem);
}
}
}
@@ -560,7 +614,10 @@ function createToolItemFromChain(
record?: ChatTimeHistoryRecord,
) {
const toolCallId = normalizePlainText(rawChain.id);
const name = normalizePlainText(rawChain.name);
const name = normalizePlainText(rawChain.name ?? rawChain.toolName);
if (isHiddenToolName(name)) {
return null;
}
const argumentsValue = normalizePayloadValue(rawChain.arguments ?? rawChain.result);
const status = normalizeToolStatus(rawChain.status);
if (!toolCallId && !name && !argumentsValue) {
@@ -594,13 +651,17 @@ function createToolItemFromStructuredMessage(
rawMessage.toolCallId ?? rawMessage.tool_call_id,
);
const toolMeta = toolMetaMap.get(toolCallId);
const toolName = normalizePlainText(rawMessage.name ?? rawMessage.toolName);
if (isHiddenToolName(toolMeta?.name || toolName)) {
return null;
}
const result = normalizePayloadValue(rawMessage.content);
return createToolItem({
arguments: toolMeta?.arguments,
created,
id: toolCallId || uuid(),
messageKind: record?.messageKind,
name: toolMeta?.name,
name: toolMeta?.name || toolName,
roundId: record?.roundId,
roundNo: record?.roundNo,
result,
@@ -615,6 +676,10 @@ function createToolItemFromStructuredMessage(
function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
const payload = toObjectRecord(record.contentPayload);
const name = normalizePlainText(payload.name ?? payload.toolName);
if (isHiddenToolName(name)) {
return null;
}
const toolCallId = normalizePlainText(
payload.toolCallId ?? payload.tool_call_id ?? record.id,
);
@@ -622,7 +687,7 @@ function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
created: record.created,
id: record.id == null ? toolCallId || uuid() : String(record.id),
messageKind: record.messageKind,
name: normalizePlainText(payload.name),
name,
roundId: record.roundId,
roundNo: record.roundNo,
result: normalizePayloadValue(
@@ -709,7 +774,7 @@ function collectToolMeta(
}
toolMetaMap.set(toolCallId, {
arguments: normalizePayloadValue(toolCall.arguments),
name: normalizePlainText(toolCall.name),
name: normalizePlainText(toolCall.name ?? toolCall.toolName),
});
}
}
@@ -1000,9 +1065,7 @@ function normalizePositiveInteger(value: any) {
}
function normalizeAssistantText(value: any) {
return normalizePlainText(value)
.replace(/^Final Answer:\s*/i, '')
.replaceAll('```echartsoption', '```echarts\noption');
return normalizePlainText(value);
}
function normalizePayloadValue(value: any) {

View File

@@ -531,10 +531,10 @@ importers:
version: 24.10.1
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))
version: 6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))
'@vitejs/plugin-vue-jsx':
specifier: 'catalog:'
version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))
version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
@@ -576,10 +576,10 @@ importers:
version: 3.6.1(sass@1.94.0)(typescript@5.9.3)(vue-tsc@2.2.10(typescript@5.9.3))(vue@3.5.24(typescript@5.9.3))
vite:
specifier: 'catalog:'
version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
version: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vitest:
specifier: 'catalog:'
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vue:
specifier: ^3.5.17
version: 3.5.24(typescript@5.9.3)
@@ -1348,6 +1348,12 @@ importers:
'@easyflow/types':
specifier: workspace:*
version: link:../../types
'@incremark/theme':
specifier: 1.0.2
version: 1.0.2
'@incremark/vue':
specifier: 1.0.2
version: 1.0.2(katex@0.16.25)(vue@3.5.24(typescript@5.9.3))
'@vueuse/core':
specifier: 'catalog:'
version: 13.9.0(vue@3.5.24(typescript@5.9.3))
@@ -1366,9 +1372,6 @@ importers:
vue:
specifier: ^3.5.17
version: 3.5.24(typescript@5.9.3)
vue-element-plus-x:
specifier: 'catalog:'
version: 1.3.7(rollup@4.53.2)(vue@3.5.24(typescript@5.9.3))
vue-json-viewer:
specifier: 'catalog:'
version: 3.0.4(vue@3.5.24(typescript@5.9.3))
@@ -1767,6 +1770,9 @@ packages:
'@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@antfu/utils@9.3.0':
resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==}
'@apideck/better-ajv-errors@0.3.6':
resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==}
engines: {node: '>=10'}
@@ -3309,6 +3315,40 @@ packages:
peerDependencies:
vue: ^3.5.17
'@incremark/colors@1.0.2':
resolution: {integrity: sha512-WYj1ITAnkvLFYSioTk1W2/7HFo+eXwSwVpBZuH/IUyf1UExaRo3/xc+GhZwcuet7X8rZCu36qeD3xBg5XGtlyA==}
'@incremark/core@1.0.2':
resolution: {integrity: sha512-87adubRCGpnV60O9sr6yYYPhvRePT7zxw63gqoFXkcsTkbsUGjUlvgyj/hnpv4g7nZqmmRlLd/Ln9EnIO+05Lg==}
'@incremark/devtools@1.0.2':
resolution: {integrity: sha512-JUkiLGirATiWbAU/8y24MiA20gJZ9UItF7BMSNlKiqBxeX6ILRdoT/PWTNIPF6mKnTYNAxCUBZyin5xDo/WmGw==}
peerDependencies:
'@incremark/core': 1.0.2
'@incremark/icons@1.0.2':
resolution: {integrity: sha512-GNlDFk3GRFl0GBje6naqU9foToEknaFiZL+NwLkZJ8epHomswNjLq53CSx2StxUSGv9Y2Ap5tgGMtGxa+qcCIg==}
'@incremark/shared@1.0.2':
resolution: {integrity: sha512-BsfZXx9nmXANBlFUGNoM1GpGKG9J8bEhzabp23GMxDvmYnLIlpUZb7QrmqNAwWJgG//z4Rg6fL5V7tlZgH7ToQ==}
peerDependencies:
'@incremark/core': 1.0.2
'@incremark/theme@1.0.2':
resolution: {integrity: sha512-Mc8E6fmd+wRGzxQcHg2gmaLWjjc5MUhfgrLiLJ3m1olnVm3VNc4R6fTLGx/1ht5e2EyOAvpLbMfdLhuLINYDgQ==}
'@incremark/vue@1.0.2':
resolution: {integrity: sha512-SxHq/IbsknPwKOsg+9DPUWfhDCMZ9R44k1l6W2y/JJapwfSkyQ5lExPVAmr2du24RntaYc4O43IivLMqMLzTfA==}
peerDependencies:
katex: ^0.16.0
mermaid: ^10.0.0 || ^11.0.0
vue: ^3.5.17
peerDependenciesMeta:
katex:
optional: true
mermaid:
optional: true
'@inquirer/external-editor@1.0.3':
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
engines: {node: '>=18'}
@@ -3936,24 +3976,42 @@ packages:
'@shikijs/core@3.17.1':
resolution: {integrity: sha512-VWsduykcibGU0WMi66PflThDWyqEeTOiWdCRa3wmsZuishh+1PDSOh5gGxHdSrOtS+v1pmYaxodk/JNzwusElA==}
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/engine-javascript@3.17.1':
resolution: {integrity: sha512-Ars0DVJITQrkOl5Swwy+94NL/BlOi/w1NSFbPGkcsln7Dv+M2qHaVpNHwdtWCC4/arzvjuHbyWBUsWExDHPDLw==}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/engine-oniguruma@3.17.1':
resolution: {integrity: sha512-fsXPy4va/4iblEGS+22nP5V08IwwBcM+8xHUzSON0QmHm29/AJRghA95w9VDnxuwp9wOdJxEhfPkKp6vqcsN+w==}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
'@shikijs/langs@3.17.1':
resolution: {integrity: sha512-YTBVN+L2j7zBuOVjNZ2XiSNQEkm/7wZ1TSc5UO77GJPcg7Rk25WSscWA7y8pW7Bo25JIU0EWchUkq/UQjOJlJA==}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/themes@3.17.1':
resolution: {integrity: sha512-aohwwqNUB5h2ATfgrqYRPl8vyazqCiQ2wIV4xq+UzaBRHpqLMGSemkasK+vIEpl0YaendoaKUsDfpwhCqyHIaQ==}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/transformers@3.17.1':
resolution: {integrity: sha512-vFKeOSDAK2Ju631LS2wez0aGadtiZmRd+wiTsgb8xkBMAN3s2K++HPFAzoT6JJ5HcSyqUvASuUykEHEphdt9Cg==}
'@shikijs/types@3.17.1':
resolution: {integrity: sha512-yUFLiCnZHHJ16KbVbt3B1EzBUadU3OVpq0PEyb301m5BbuFKApQYBzJGhrK48hH/tYWSjzwcj7BSmYbBc0zntQ==}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -4173,6 +4231,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -5138,6 +5199,9 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
@@ -6906,6 +6970,12 @@ packages:
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -6957,6 +7027,9 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
@@ -6995,6 +7068,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
@@ -7238,6 +7314,9 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-formatter-js@2.5.23:
resolution: {integrity: sha512-Cbm8wHXjo/C56aCePP1VuKvjxoMEmL7g7Ckss1oWFFlCsvOEEbye1kTeaNNaqba1Cl6YpIOYAnK65pUQ8mDIUQ==}
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@@ -7432,6 +7511,9 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
@@ -7563,6 +7645,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@17.0.6:
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -7570,6 +7657,9 @@ packages:
mathml-tag-names@2.1.3:
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
mdast-util-directive@3.1.0:
resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -7645,6 +7735,9 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-directive@4.0.0:
resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
@@ -8151,6 +8244,9 @@ packages:
resolution: {integrity: sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==}
engines: {node: '>=8'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-imports-exports@0.2.4:
resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
@@ -9293,9 +9389,26 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki-stream@0.1.4:
resolution: {integrity: sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw==}
peerDependencies:
react: ^19.0.0
solid-js: ^1.9.0
vue: ^3.5.17
peerDependenciesMeta:
react:
optional: true
solid-js:
optional: true
vue:
optional: true
shiki@3.17.1:
resolution: {integrity: sha512-KbAPJo6pQpfjupOg5HW0fk/OSmeBfzza2IjZ5XbNKbqhZaCoxro/EyOgesaLvTdyDfrsAUDA6L4q14sc+k9i7g==}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
short-tree@3.0.0:
resolution: {integrity: sha512-Yd9NFs/o9QSoH4/wTjxk4Xe0+CIzitDRN1Qg7iBeTSejKjlCg/3PbgiRwDUVuaIxD0RRdv7Iz9jKr7e0HljtUg==}
engines: {node: ^14.13.1 || >=16.0.0}
@@ -10748,6 +10861,8 @@ snapshots:
'@antfu/utils@0.7.10': {}
'@antfu/utils@9.3.0': {}
'@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)':
dependencies:
ajv: 8.17.1
@@ -12569,6 +12684,69 @@ snapshots:
'@iconify/types': 2.0.0
vue: 3.5.24(typescript@5.9.3)
'@incremark/colors@1.0.2': {}
'@incremark/core@1.0.2':
dependencies:
'@types/lodash-es': 4.17.12
'@types/mdast': 4.0.4
lodash-es: 4.18.1
marked: 17.0.6
mdast-util-directive: 3.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-gfm: 3.1.0
mdast-util-gfm-footnote: 2.1.0
mdast-util-math: 3.0.0
micromark-extension-directive: 4.0.0
micromark-extension-gfm: 3.0.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-math: 3.1.0
micromark-factory-destination: 2.0.1
micromark-factory-label: 2.0.1
micromark-factory-space: 2.0.1
micromark-factory-title: 2.0.1
micromark-factory-whitespace: 2.0.1
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
transitivePeerDependencies:
- supports-color
'@incremark/devtools@1.0.2(@incremark/core@1.0.2)':
dependencies:
'@floating-ui/dom': 1.7.4
'@incremark/core': 1.0.2
json-formatter-js: 2.5.23
'@incremark/icons@1.0.2': {}
'@incremark/shared@1.0.2(@incremark/core@1.0.2)':
dependencies:
'@incremark/core': 1.0.2
'@incremark/theme@1.0.2':
dependencies:
'@incremark/colors': 1.0.2
'@incremark/vue@1.0.2(katex@0.16.25)(vue@3.5.24(typescript@5.9.3))':
dependencies:
'@antfu/utils': 9.3.0
'@incremark/core': 1.0.2
'@incremark/devtools': 1.0.2(@incremark/core@1.0.2)
'@incremark/icons': 1.0.2
'@incremark/shared': 1.0.2(@incremark/core@1.0.2)
'@incremark/theme': 1.0.2
shiki: 3.23.0
shiki-stream: 0.1.4(vue@3.5.24(typescript@5.9.3))
vue: 3.5.24(typescript@5.9.3)
optionalDependencies:
katex: 0.16.25
transitivePeerDependencies:
- react
- solid-js
- supports-color
'@inquirer/external-editor@1.0.3(@types/node@24.10.1)':
dependencies:
chardet: 2.1.1
@@ -13287,25 +13465,51 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.17.1':
dependencies:
'@shikijs/types': 3.17.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.17.1':
dependencies:
'@shikijs/types': 3.17.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.17.1':
dependencies:
'@shikijs/types': 3.17.1
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/themes@3.17.1':
dependencies:
'@shikijs/types': 3.17.1
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/transformers@3.17.1':
dependencies:
'@shikijs/core': 3.17.1
@@ -13316,6 +13520,11 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sindresorhus/is@7.1.1': {}
@@ -13537,6 +13746,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/web-bluetooth@0.0.16': {}
@@ -13791,6 +14002,18 @@ snapshots:
- rollup
- supports-color
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))':
dependencies:
'@babel/core': 7.28.5
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5)
'@rolldown/pluginutils': 1.0.0-beta.50
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5)
vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vue: 3.5.24(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))':
dependencies:
'@babel/core': 7.28.5
@@ -13803,6 +14026,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vue: 3.5.24(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
@@ -13823,6 +14052,14 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
@@ -13830,6 +14067,7 @@ snapshots:
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
optional: true
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -14663,6 +14901,8 @@ snapshots:
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
chardet@2.1.1: {}
chatarea@5.9.3: {}
@@ -16723,6 +16963,13 @@ snapshots:
iron-webcrypto@1.2.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -16779,6 +17026,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-decimal@2.0.1: {}
is-docker@2.2.1: {}
is-docker@3.0.0: {}
@@ -16809,6 +17058,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-hotkey@0.2.0: {}
is-in-ci@1.0.0: {}
@@ -17001,6 +17252,8 @@ snapshots:
json-buffer@3.0.1: {}
json-formatter-js@2.5.23: {}
json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {}
@@ -17196,6 +17449,8 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.18.1: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
dependencies:
'@types/lodash-es': 4.17.12
@@ -17314,10 +17569,26 @@ snapshots:
markdown-table@3.0.4: {}
marked@17.0.6: {}
math-intrinsics@1.1.0: {}
mathml-tag-names@2.1.3: {}
mdast-util-directive@3.1.0:
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-visit-parents: 6.0.2
transitivePeerDependencies:
- supports-color
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -17486,6 +17757,16 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-directive@4.0.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-factory-whitespace: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
parse-entities: 4.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
@@ -18163,6 +18444,16 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.2.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-imports-exports@0.2.4:
dependencies:
parse-statements: 1.0.11
@@ -19324,6 +19615,12 @@ snapshots:
shebang-regex@3.0.0: {}
shiki-stream@0.1.4(vue@3.5.24(typescript@5.9.3)):
dependencies:
'@shikijs/core': 3.17.1
optionalDependencies:
vue: 3.5.24(typescript@5.9.3)
shiki@3.17.1:
dependencies:
'@shikijs/core': 3.17.1
@@ -19335,6 +19632,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
short-tree@3.0.0:
dependencies:
'@types/bintrees': 1.0.6
@@ -20374,6 +20682,27 @@ snapshots:
dependencies:
vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vite-node@3.2.4(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite-node@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
cac: 6.7.14
@@ -20394,6 +20723,7 @@ snapshots:
- terser
- tsx
- yaml
optional: true
vite-plugin-compression@0.5.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)):
dependencies:
@@ -20523,6 +20853,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
esbuild: 0.25.3
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.53.2
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.10.1
fsevents: 2.3.3
jiti: 1.21.7
less: 4.4.2
sass: 1.94.0
terser: 5.44.1
yaml: 2.8.1
vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
esbuild: 0.25.3
@@ -20557,6 +20904,23 @@ snapshots:
terser: 5.44.1
yaml: 2.8.1
vite@7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
esbuild: 0.25.3
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.53.2
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.10.1
fsevents: 2.3.3
jiti: 1.21.7
less: 4.4.2
sass: 1.94.0
terser: 5.44.1
yaml: 2.8.1
vite@7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
esbuild: 0.25.3
@@ -20573,11 +20937,55 @@ snapshots:
sass: 1.94.0
terser: 5.44.1
yaml: 2.8.1
optional: true
vitefu@1.1.2(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)):
optionalDependencies:
vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.2.2
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.10.1
happy-dom: 17.6.3
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.3
@@ -20620,6 +21028,7 @@ snapshots:
- terser
- tsx
- yaml
optional: true
vscode-languageserver-textdocument@1.0.12: {}