feat(ai): refine chat process experience

This commit is contained in:
digua committed 2026-08-19 18:49:20 +08:00
1 parent b0049a5cfd
commit f3041f682f
12 files changed
+224 -68

No files matched your search

+26 -20
View File
@@ -564,27 +564,33 @@ watch(
<!-- 输入框区域 -->
<div class="px-4 pb-2">
<div class="mx-auto max-w-[740px]">
<AIChatInput
ref="chatInputRef"
:session-id="sessionId"
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
:chat-type="currentChatType"
@send="handleSend"
@stop="handleStop"
@manage-skills="handleOpenSkillMarket"
@skill-activated="handleSkillActivated"
/>
<div
class="overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5 dark:focus-within:ring-primary-500/40"
:class="[isAIThinking ? 'bg-gray-50/50 dark:bg-page-dark/50' : '']"
>
<AIChatInput
ref="chatInputRef"
:session-id="sessionId"
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
:chat-type="currentChatType"
embedded
@send="handleSend"
@stop="handleStop"
@manage-skills="handleOpenSkillMarket"
@skill-activated="handleSkillActivated"
/>
<!-- 底部状态栏 -->
<ChatStatusBar
:session-token-usage="sessionTokenUsage"
:agent-status="agentStatus"
:current-ai-chat-id="currentAIChatId"
:current-messages="messages"
:fallback-title="sessionName"
:estimated-context-tokens="estimatedContextTokens"
/>
<ChatStatusBar
class="border-t border-gray-100 px-2 py-1.5 dark:border-gray-800"
:session-token-usage="sessionTokenUsage"
:agent-status="agentStatus"
:current-ai-chat-id="currentAIChatId"
:current-messages="messages"
:fallback-title="sessionName"
:estimated-context-tokens="estimatedContextTokens"
/>
</div>
</div>
</div>
</div>
+94 -16
View File
@@ -167,7 +167,14 @@ const visibleBlocks = computed(() => {
})
function isFoldableProcessBlock(block: ContentBlock): boolean {
return block.type === 'think' || block.type === 'tool' || block.type === 'plan' || block.type === 'plan_draft'
return (
block.type === 'think' ||
block.type === 'tool' ||
block.type === 'skill' ||
block.type === 'plan' ||
block.type === 'plan_draft' ||
block.type === 'error'
)
}
function isTextBlock(block: ContentBlock): boolean {
@@ -197,7 +204,7 @@ function isProcessSegmentOpen(segmentIndex: number): boolean {
const key = getProcessSegmentKey(segmentIndex)
const override = processSegmentOpenOverrides.value[key]
if (override !== undefined) return override
return isProcessingProcessSegment(segmentIndex)
return hasProcessSegmentError(renderSegments.value[segmentIndex])
}
function toggleProcessSegment(segmentIndex: number): void {
@@ -213,7 +220,47 @@ function isLastVisibleBlock(block: ContentBlock): boolean {
}
function isProcessingProcessSegment(segmentIndex: number): boolean {
return !!props.isStreaming && segmentIndex === renderSegments.value.length - 1
return !!props.isStreaming && renderSegments.value[segmentIndex]?.type === 'process'
}
function getProcessSegmentStepCount(segment: ProcessSegment<ContentBlock>): number {
if (segment.type !== 'process') return 0
return segment.blocks.filter(isFoldableProcessBlock).length
}
function getProcessSegmentStepLabel(segment: ProcessSegment<ContentBlock>): string {
const count = getProcessSegmentStepCount(segment)
const key = count === 1 ? 'ai.chat.message.process.step' : 'ai.chat.message.process.steps'
return t(key, { count })
}
function getSingleLineText(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
function getProcessSegmentPreview(segment: ProcessSegment<ContentBlock>): string {
if (segment.type !== 'process') return ''
for (let index = segment.blocks.length - 1; index >= 0; index -= 1) {
const block = segment.blocks[index]
if (block.type === 'text' || block.type === 'think' || block.type === 'plan_draft') {
const text = getSingleLineText(block.text)
if (text) return text
}
if (block.type === 'tool') return getToolDisplayName(block.tool)
if (block.type === 'skill') return block.skillName
if (block.type === 'plan') return block.plan.title
if (block.type === 'error') return block.error.message ?? block.error.name ?? ''
}
return ''
}
function hasProcessSegmentError(segment: ProcessSegment<ContentBlock> | undefined): boolean {
if (segment?.type !== 'process') return false
return segment.blocks.some(
(block) => block.type === 'error' || (block.type === 'tool' && block.tool.status === 'error')
)
}
function getBlockDurationMs(block: ContentBlock): number {
@@ -651,7 +698,7 @@ async function handleCopyMarkdown() {
<button
v-if="segment.type === 'process'"
type="button"
class="inline-flex max-w-full items-center gap-1.5 text-left text-xs text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
class="flex w-full min-w-0 max-w-full items-center gap-1.5 rounded-md px-1 py-1 text-left text-xs text-gray-400 transition-colors hover:text-gray-600 focus:outline-none focus-visible:bg-gray-50 dark:text-gray-500 dark:hover:text-gray-300 dark:focus-visible:bg-gray-800/30"
:aria-expanded="isProcessSegmentOpen(segmentIdx)"
@click="toggleProcessSegment(segmentIdx)"
>
@@ -659,23 +706,50 @@ async function handleCopyMarkdown() {
:name="isProcessSegmentOpen(segmentIdx) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="h-3 w-3 shrink-0"
/>
<span class="truncate">{{ getProcessSegmentLabel(segment, segmentIdx) }}</span>
<UIcon
v-if="isProcessingProcessSegment(segmentIdx)"
name="i-heroicons-arrow-path"
class="h-3 w-3 shrink-0 animate-spin"
class="h-3 w-3 shrink-0 animate-spin text-primary-500"
/>
<UIcon
v-else-if="hasProcessSegmentError(segment)"
name="i-heroicons-exclamation-circle"
class="h-3 w-3 shrink-0 text-amber-500"
/>
<span class="shrink-0 font-medium text-gray-500 dark:text-gray-400">
{{ t('ai.chat.message.process.label') }}
</span>
<span class="shrink-0 text-gray-400 dark:text-gray-500">
· {{ getProcessSegmentLabel(segment, segmentIdx) }} · {{ getProcessSegmentStepLabel(segment) }}
</span>
<span
v-if="getProcessSegmentPreview(segment)"
class="min-w-0 flex-1 truncate text-gray-400/80 dark:text-gray-500/80"
>
· {{ getProcessSegmentPreview(segment) }}
</span>
</button>
<div
v-show="segment.type !== 'process' || isProcessSegmentOpen(segmentIdx)"
:class="[segment.type === 'process' ? 'mt-2 space-y-2 pl-4' : '']"
:class="[
segment.type === 'process'
? 'ml-1.5 mt-1 space-y-1.5 border-l border-gray-200 pl-3 dark:border-gray-800'
: '',
]"
>
<template v-for="(block, blockIdx) in getSegmentBlocks(segment)" :key="`${segmentIdx}-${blockIdx}`">
<!-- 文本块 -->
<div v-if="block.type === 'text'" class="py-1 text-gray-900 dark:text-gray-100">
<div
v-if="block.type === 'text'"
class="py-1 text-gray-900 dark:text-gray-100"
:class="[
segment.type === 'process' ? 'rounded-lg px-2 text-xs text-gray-500 dark:text-gray-400' : '',
]"
>
<div
class="prose prose-sm dark:prose-invert max-w-none leading-relaxed"
:class="[segment.type === 'process' ? 'prose-p:my-1' : '']"
v-html="renderMarkdown(getDisplayText(block.text))"
/>
<!-- 流式输出光标(只在最后一个文本块显示) -->
@@ -688,18 +762,23 @@ async function handleCopyMarkdown() {
<!-- 思考块(默认折叠) -->
<details
v-else-if="block.type === 'think'"
class="mb-2 border-l-2 border-gray-200 pl-4 py-1 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400"
class="group rounded-lg px-2 py-1.5 text-sm text-gray-500 transition-colors hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/30"
>
<summary
class="cursor-pointer select-none text-xs font-medium transition-colors hover:text-gray-700 dark:hover:text-gray-300"
class="flex min-w-0 cursor-pointer select-none items-center gap-2 text-xs font-medium transition-colors hover:text-gray-700 dark:hover:text-gray-300"
>
{{ getThinkLabel(block.tag) }}
<span v-if="block.durationMs" class="ml-2 text-xs text-gray-400 dark:text-gray-500">
<span class="shrink-0">{{ getThinkLabel(block.tag) }}</span>
<span
class="min-w-0 flex-1 truncate font-normal text-gray-400 group-open:hidden dark:text-gray-500"
>
{{ getSingleLineText(block.text) }}
</span>
<span v-if="block.durationMs" class="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{{ formatThinkDuration(block.durationMs) }}
</span>
<span
v-if="isStreaming && isLastVisibleBlock(block)"
class="ml-2 inline-flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
class="inline-flex shrink-0 items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
>
<span>{{ t('ai.chat.message.think.loading') }}</span>
<span class="flex gap-0.5">
@@ -769,8 +848,7 @@ async function handleCopyMarkdown() {
<!-- 计划块 -->
<details
v-else-if="block.type === 'plan'"
open
class="mb-2 border-l-2 border-gray-200 py-1 pl-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
class="rounded-lg px-2 py-1.5 text-sm text-gray-600 transition-colors hover:bg-white/70 dark:text-gray-400 dark:hover:bg-gray-800/40"
>
<summary
class="flex cursor-pointer select-none items-center gap-2 text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@@ -821,7 +899,7 @@ async function handleCopyMarkdown() {
<!-- 计划草稿块 -->
<div
v-else-if="block.type === 'plan_draft'"
class="mb-2 border-l-2 border-gray-200 py-1 pl-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
class="rounded-lg px-2 py-1.5 text-sm text-gray-600 dark:text-gray-400"
>
<div class="flex items-center gap-2 text-xs font-medium text-gray-500 dark:text-gray-400">
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0" />
@@ -10,10 +10,13 @@ type TestBlock =
| { type: 'text'; text: string }
| { type: 'think'; text: string; durationMs?: number }
| { type: 'tool'; name: string; durationMs?: number }
| { type: 'skill'; name: string }
| { type: 'error'; message: string }
| { type: 'chart'; title: string }
| { type: 'evidence'; title: string }
const isFoldableProcessBlock = (block: TestBlock): boolean => block.type === 'think' || block.type === 'tool'
const isFoldableProcessBlock = (block: TestBlock): boolean =>
block.type === 'think' || block.type === 'tool' || block.type === 'skill' || block.type === 'error'
const isTextBlock = (block: TestBlock): boolean => block.type === 'text'
@@ -41,12 +44,14 @@ describe('chat message process segments', () => {
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[4]])
})
it('keeps result blocks visible and starts a new process segment after them', () => {
it('keeps one process segment while result blocks remain visible', () => {
const blocks: TestBlock[] = [
{ type: 'tool', name: 'get_time_stats' },
{ type: 'chart', title: '发言趋势' },
{ type: 'text', text: '图表之后继续核对。' },
{ type: 'skill', name: 'relationship-analysis' },
{ type: 'tool', name: 'retrieve_chat_evidence' },
{ type: 'error', message: '部分证据读取失败' },
{ type: 'evidence', title: '证据' },
{ type: 'text', text: '最终解释' },
]
@@ -54,13 +59,12 @@ describe('chat message process segments', () => {
const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
assert.deepEqual(segments, [
{ type: 'process', blocks: [blocks[0]] },
{ type: 'process', blocks: [blocks[0], blocks[2], blocks[3], blocks[4], blocks[5]] },
{ type: 'visible', block: blocks[1] },
{ type: 'process', blocks: blocks.slice(2, 4) },
{ type: 'visible', block: blocks[4] },
{ type: 'visible', block: blocks[5] },
{ type: 'visible', block: blocks[6] },
{ type: 'visible', block: blocks[7] },
])
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[1], blocks[4], blocks[5]])
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[1], blocks[6], blocks[7]])
})
it('keeps text visible when no later process block exists', () => {
@@ -27,24 +27,23 @@ function hasLaterFoldableProcessBlock<T>(
}
export function buildProcessSegments<T>(blocks: T[], options: ProcessSegmentOptions<T>): ProcessSegment<T>[] {
const isProcessBlock = blocks.map(
(block, index) =>
options.isFoldableProcessBlock(block) ||
(options.isTextBlock(block) && hasLaterFoldableProcessBlock(blocks, index, options.isFoldableProcessBlock))
)
const processBlocks = blocks.filter((_block, index) => isProcessBlock[index])
const firstProcessIndex = isProcessBlock.findIndex(Boolean)
const segments: ProcessSegment<T>[] = []
blocks.forEach((block, index) => {
const isProcess =
options.isFoldableProcessBlock(block) ||
(options.isTextBlock(block) && hasLaterFoldableProcessBlock(blocks, index, options.isFoldableProcessBlock))
if (!isProcess) {
segments.push({ type: 'visible', block })
if (index === firstProcessIndex) {
segments.push({ type: 'process', blocks: processBlocks })
return
}
const lastSegment = segments[segments.length - 1]
if (lastSegment?.type === 'process') {
lastSegment.blocks.push(block)
} else {
segments.push({ type: 'process', blocks: [block] })
}
if (isProcessBlock[index]) return
segments.push({ type: 'visible', block })
})
return segments
+11 -4
View File
@@ -9,11 +9,13 @@ const props = withDefaults(
placeholder: string
sendButtonTitle: string
activeSkillName?: string | null
embedded?: boolean
}>(),
{
disabled: false,
status: 'ready',
activeSkillName: null,
embedded: false,
}
)
@@ -75,12 +77,17 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
<template>
<div
class="flex flex-col overflow-hidden rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all dark:bg-page-dark dark:ring-white/5"
:class="
class="flex flex-col overflow-hidden transition-all"
:class="[
embedded
? 'bg-transparent'
: 'rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 dark:bg-page-dark dark:ring-white/5',
disabled
? 'bg-gray-50/50 dark:bg-page-dark/50'
: 'focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:focus-within:ring-primary-500/40'
"
: embedded
? ''
: 'focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:focus-within:ring-primary-500/40',
]"
>
<div class="relative px-4 pt-2.5 pb-2.5">
<div class="flex items-start gap-2 pr-10">
+3 -1
View File
@@ -17,6 +17,7 @@ const props = defineProps<{
disabled?: boolean
status?: 'ready' | 'submitted' | 'streaming' | 'error'
chatType: 'group' | 'private'
embedded?: boolean
}>()
const emit = defineEmits<{
@@ -650,7 +651,7 @@ defineExpose({
</script>
<template>
<div class="shrink-0 pt-2 pb-2">
<div class="shrink-0" :class="[props.embedded ? '' : 'pt-2 pb-2']">
<div ref="rootRef" class="relative w-full max-w-4xl mx-auto">
<SlashCommandMenu
:visible="showSlashMenu"
@@ -681,6 +682,7 @@ defineExpose({
:placeholder="inputPlaceholder"
:send-button-title="sendButtonTitle"
:active-skill-name="activeSkill?.name"
:embedded="props.embedded"
@submit="handleSubmit"
@stop="emit('stop')"
@keydown="handleKeydown"
+3 -1
View File
@@ -45,7 +45,9 @@
"process": {
"label": "Process",
"processing": "Processing",
"processed": "Processed"
"processed": "Processed",
"step": "{count} step",
"steps": "{count} steps"
},
"toolResult": {
"view": "View result",
+3 -1
View File
@@ -45,7 +45,9 @@
"process": {
"label": "実行過程",
"processing": "処理中",
"processed": "処理済み"
"processed": "処理済み",
"step": "{count} ステップ",
"steps": "{count} ステップ"
},
"toolResult": {
"view": "結果を表示",
+3 -1
View File
@@ -45,7 +45,9 @@
"process": {
"label": "执行过程",
"processing": "处理中",
"processed": "已处理"
"processed": "已处理",
"step": "{count} 步",
"steps": "{count} 步"
},
"toolResult": {
"view": "查看结果",
+3 -1
View File
@@ -45,7 +45,9 @@
"process": {
"label": "執行過程",
"processing": "處理中",
"processed": "已處理"
"processed": "已處理",
"step": "{count} 步",
"steps": "{count} 步"
},
"toolResult": {
"view": "查看結果",
+30 -1
View File
@@ -172,7 +172,7 @@ describe('aiChat chart block helpers', () => {
)
})
it('converts render-only chart tool failures to visible error blocks', () => {
it('extracts render-only chart tool failure details', () => {
const errorBlock = toRenderOnlyToolErrorBlock('render_chart', {
content: [{ type: 'text', text: 'Error: Field "missing_count" does not exist in SQL result' }],
details: null,
@@ -234,6 +234,35 @@ describe('aiChat chart block helpers', () => {
)
})
it('keeps render-only chart failures on the matching tool step', () => {
const blocks = [
{ type: 'think', tag: 'thinking', text: '准备生成图表' },
createRenderOnlyToolPendingBlock('render_chart', { spec: { title: '趋势图' } }, 'call_chart_error'),
].filter((block): block is NonNullable<typeof block> => block !== null)
const errorBlock = toRenderOnlyToolErrorBlock('render_chart', {
content: [{ type: 'text', text: 'Error: encoding.x must be a non-empty field name' }],
})
const nextBlocks = finishRenderOnlyToolResultBlocks(blocks, 'render_chart', 'call_chart_error', [], errorBlock)
assert.deepEqual(nextBlocks, [
blocks[0],
{
type: 'tool',
tool: {
name: 'render_chart',
displayName: 'render_chart',
status: 'error',
params: { spec: { title: '趋势图' } },
toolCallId: 'call_chart_error',
result: 'encoding.x must be a non-empty field name',
displayResult: 'encoding.x must be a non-empty field name',
isError: true,
},
},
])
})
it('hides recovered chart render errors when a later native chart exists', () => {
const blocks = [
{ type: 'error', error: { name: 'ChartRenderError', message: 'Field "metric" does not exist in SQL result' } },
+26 -3
View File
@@ -9,7 +9,10 @@ export type RenderOnlyToolPendingBlock = {
status: 'running' | 'done' | 'error'
params?: Record<string, unknown>
toolCallId?: string
transient: true
transient?: true
result?: string
displayResult?: string
isError?: boolean
}
}
export type RenderOnlyToolErrorBlock = {
@@ -210,13 +213,33 @@ export function finishRenderOnlyToolResultBlocks<T>(
charts: ChartPayload[],
errorBlock: RenderOnlyToolErrorBlock | null
): Array<T | ChartContentBlock | RenderOnlyToolErrorBlock> {
// render-only 工具的 pending 行只用于流式占位;如果没有生成图表,也没有需要展示的错误,就应直接移除。
// A pending render-only row is only a streaming placeholder. Remove it when the tool produces no visible result.
if (charts.length > 0) {
return replaceRenderOnlyToolPendingBlockWithCharts(blocks, toolName, toolCallId, charts)
}
if (errorBlock) {
let replaced = false
const withToolError = blocks.map((block) => {
if (!isMatchingPendingRenderOnlyToolBlock(block, toolName, toolCallId)) return block
replaced = true
const settledTool = { ...block.tool }
delete settledTool.transient
return {
...block,
tool: {
...settledTool,
status: 'error' as const,
result: errorBlock.error.message,
displayResult: errorBlock.error.message,
isError: true,
},
}
})
return replaced ? withToolError : [...blocks, errorBlock]
}
const withoutPending = removeRenderOnlyToolPendingBlock(blocks, toolName, toolCallId)
if (errorBlock) return [...withoutPending, errorBlock]
return withoutPending
}