mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): refine chat process experience
This commit is contained in:
1 parent
b0049a5cfd
commit
f3041f682f
12 files changed
+224
-68
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"process": {
|
||||
"label": "Process",
|
||||
"processing": "Processing",
|
||||
"processed": "Processed"
|
||||
"processed": "Processed",
|
||||
"step": "{count} step",
|
||||
"steps": "{count} steps"
|
||||
},
|
||||
"toolResult": {
|
||||
"view": "View result",
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"process": {
|
||||
"label": "実行過程",
|
||||
"processing": "処理中",
|
||||
"processed": "処理済み"
|
||||
"processed": "処理済み",
|
||||
"step": "{count} ステップ",
|
||||
"steps": "{count} ステップ"
|
||||
},
|
||||
"toolResult": {
|
||||
"view": "結果を表示",
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"process": {
|
||||
"label": "执行过程",
|
||||
"processing": "处理中",
|
||||
"processed": "已处理"
|
||||
"processed": "已处理",
|
||||
"step": "{count} 步",
|
||||
"steps": "{count} 步"
|
||||
},
|
||||
"toolResult": {
|
||||
"view": "查看结果",
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"process": {
|
||||
"label": "執行過程",
|
||||
"processing": "處理中",
|
||||
"processed": "已處理"
|
||||
"processed": "已處理",
|
||||
"step": "{count} 步",
|
||||
"steps": "{count} 步"
|
||||
},
|
||||
"toolResult": {
|
||||
"view": "查看結果",
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user