feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能 - 增加智能体聊天,并对接持久化
This commit is contained in:
124
easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue
Normal file
124
easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
125
easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue
Normal file
125
easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
113
easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue
Normal file
113
easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
45
easyflow-ui-admin/app/src/components/ai-chat/types.ts
Normal file
45
easyflow-ui-admin/app/src/components/ai-chat/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"chat": "Chat",
|
||||
"agents": "Agent",
|
||||
"bots": "ChatAssistant",
|
||||
"title": "AI",
|
||||
"resources": "Resources",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"chat": "聊天",
|
||||
"agents": "智能体",
|
||||
"bots": "聊天助手",
|
||||
"title": "AI能力",
|
||||
"resources": "素材库",
|
||||
|
||||
30
easyflow-ui-admin/app/src/router/routes/modules/agent.ts
Normal file
30
easyflow-ui-admin/app/src/router/routes/modules/agent.ts
Normal 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;
|
||||
@@ -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('准备调用工具');
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '请求失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
146
easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts
Normal file
146
easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts
Normal 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();
|
||||
}
|
||||
1027
easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue
Normal file
1027
easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
356
easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue
Normal file
356
easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue
Normal 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>
|
||||
302
easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue
Normal file
302
easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue
Normal 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>
|
||||
111
easyflow-ui-admin/app/src/views/ai/agents/api.ts
Normal file
111
easyflow-ui-admin/app/src/views/ai/agents/api.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
83
easyflow-ui-admin/app/src/views/ai/agents/types.ts
Normal file
83
easyflow-ui-admin/app/src/views/ai/agents/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ChatShimmerText } from './ChatShimmerText.vue';
|
||||
export { default as ChatEventLabel } from './ChatEventLabel.vue';
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('后半段');
|
||||
});
|
||||
});
|
||||
@@ -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('正文');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
423
easyflow-ui-admin/pnpm-lock.yaml
generated
423
easyflow-ui-admin/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user