feat: 全新智能体功能

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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