feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能 - 增加智能体聊天,并对接持久化
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user