feat(ai): refine streaming process display

This commit is contained in:
digua committed 2026-08-19 18:49:20 +08:00
1 parent f3041f682f
commit 9fa79b5d9f
15 files changed
+625 -315

No files matched your search

+4 -6
View File
@@ -50,7 +50,6 @@ const {
isAIThinking,
currentAIChatId,
currentToolStatus,
toolsUsedInCurrentRound,
sessionTokenUsage,
agentStatus,
selectedAssistantId,
@@ -517,6 +516,7 @@ watch(
:is-streaming="pair.assistant.isStreaming"
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
:active-tool="pair.assistant.isStreaming ? currentToolStatus : null"
@fork="handleForkAIChat"
/>
</div>
@@ -530,7 +530,6 @@ watch(
!(messages[messages.length - 1]?.contentBlocks?.length ?? 0)
"
:current-tool-status="currentToolStatus"
:tools-used="toolsUsedInCurrentRound"
:agent-status="agentStatus"
/>
</div>
@@ -550,7 +549,7 @@ watch(
<!-- 预设问题气泡(仅在对话为空时显示) -->
<div v-if="messages.length === 0 && !isAIThinking" class="px-4 pb-2">
<div class="mx-auto max-w-[740px]">
<div class="mx-auto max-w-3xl">
<PresetQuestions
:questions="currentPresetQuestions"
:leading-action-label="t('ai.chat.input.useSkill')"
@@ -563,10 +562,9 @@ watch(
<!-- 输入框区域 -->
<div class="px-4 pb-2">
<div class="mx-auto max-w-[740px]">
<div class="mx-auto max-w-3xl">
<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"
@@ -582,7 +580,7 @@ watch(
/>
<ChatStatusBar
class="border-t border-gray-100 px-2 py-1.5 dark:border-gray-800"
class="px-2 pb-1.5 pt-0.5"
:session-token-usage="sessionTokenUsage"
:agent-status="agentStatus"
:current-ai-chat-id="currentAIChatId"
@@ -6,7 +6,6 @@ import type { ToolProgress } from '@openchatlab/shared-types'
const { t, te } = useI18n()
// 获取工具的本地化显示名称
function localizedToolName(name: string, fallback?: string): string {
const key = `ai.assistant.builtinToolDesc.${name}`
return te(key) ? t(key) : fallback || name
@@ -18,123 +17,59 @@ function localizedToolProgress(progress?: ToolProgress): string {
return te(key) ? t(key) : ''
}
// Props
const props = defineProps<{
// 当前工具执行状态
currentToolStatus: {
name: string
displayName: string
status: 'running' | 'done' | 'error'
progress?: ToolProgress
} | null
// 当前轮次已使用的工具列表
toolsUsed: string[]
// Agent 运行状态
agentStatus?: AgentRuntimeStatus | null
}>()
const isCompressing = computed(() => props.agentStatus?.phase === 'compressing')
const showInitialShimmer = computed(() => !isCompressing.value && !props.currentToolStatus)
const title = computed(() => {
if (isCompressing.value) return t('ai.chat.thinking.compressing')
if (props.currentToolStatus) {
return localizedToolName(props.currentToolStatus.name, props.currentToolStatus.displayName)
}
return t('ai.chat.thinking.analyzing')
})
const detail = computed(() => {
if (isCompressing.value || !props.currentToolStatus) return ''
if (props.currentToolStatus.status === 'running') {
return localizedToolProgress(props.currentToolStatus.progress)
}
if (props.currentToolStatus.status === 'done') return t('ai.chat.thinking.processingResult')
return ''
})
const icon = computed(() => {
if (isCompressing.value || props.currentToolStatus?.status === 'running') return 'i-heroicons-arrow-path'
if (props.currentToolStatus?.status === 'error') return 'i-heroicons-exclamation-circle'
if (props.currentToolStatus) return 'i-heroicons-wrench-screwdriver'
return 'i-heroicons-sparkles'
})
const isIconSpinning = computed(() => isCompressing.value || props.currentToolStatus?.status === 'running')
</script>
<template>
<!-- 压缩上下文状态(工具块样式,无气泡包裹) -->
<div
v-if="isCompressing"
class="flex w-fit items-center gap-1.5 rounded-lg bg-gray-100 px-2.5 py-1 text-xs transition-colors dark:bg-gray-800/50"
class="ai-live-row flex h-6 min-w-0 items-center gap-1.5 text-sm leading-6 text-gray-500 dark:text-gray-400"
:data-running="showInitialShimmer || undefined"
>
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5 shrink-0 animate-spin text-gray-600 dark:text-gray-400" />
<span class="font-medium text-gray-600 dark:text-gray-400">{{ t('ai.chat.thinking.compressing') }}</span>
</div>
<!-- 常规思考/工具状态(头像+气泡) -->
<div v-else class="flex items-start gap-3">
<!-- AI 头像 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-pink-500 to-pink-600"
>
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-white" />
</div>
<!-- 状态气泡 -->
<div class="rounded-2xl rounded-tl-sm bg-gray-100 px-4 py-3 dark:bg-gray-800">
<!-- 工具执行状态 -->
<div v-if="currentToolStatus" class="space-y-2">
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="[
currentToolStatus.status === 'running'
? 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
: currentToolStatus.status === 'done'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
]"
>
<UIcon
:name="
currentToolStatus.status === 'running'
? 'i-heroicons-cog-6-tooth'
: currentToolStatus.status === 'done'
? 'i-heroicons-check-circle'
: 'i-heroicons-x-circle'
"
class="h-3 w-3"
:class="{ 'animate-spin': currentToolStatus.status === 'running' }"
/>
{{ localizedToolName(currentToolStatus.name, currentToolStatus.displayName) }}
</span>
<!-- 运行中的动画 -->
<span v-if="currentToolStatus.status === 'running'" class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-pink-500 [animation-delay:0ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-pink-500 [animation-delay:150ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-pink-500 [animation-delay:300ms]" />
</span>
<span
v-if="currentToolStatus.status === 'running' && localizedToolProgress(currentToolStatus.progress)"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ localizedToolProgress(currentToolStatus.progress) }}
</span>
<!-- 完成后处理中状态 -->
<span
v-else-if="currentToolStatus.status === 'done'"
class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span>{{ t('ai.chat.thinking.processingResult') }}</span>
<span class="flex gap-1">
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
</span>
</span>
</div>
<!-- 已使用的工具列表 -->
<div v-if="toolsUsed.length > 1" class="flex flex-wrap gap-1">
<span class="text-xs text-gray-400">{{ t('ai.chat.thinking.called') }}</span>
<span
v-for="tool in toolsUsed.slice(0, -1)"
:key="tool"
class="inline-flex items-center gap-1 rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400"
>
<UIcon name="i-heroicons-check" class="h-3 w-3 text-green-500" />
{{ localizedToolName(tool) }}
</span>
</div>
</div>
<!-- 默认状态(无工具调用) -->
<div v-else class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ t('ai.chat.thinking.analyzing') }}</span>
<span class="flex gap-1">
<span class="h-2 w-2 animate-bounce rounded-full bg-pink-500 [animation-delay:0ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-pink-500 [animation-delay:150ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-pink-500 [animation-delay:300ms]" />
</span>
</div>
</div>
<UIcon
:name="icon"
class="h-3.5 w-3.5 shrink-0 text-gray-400 dark:text-gray-500"
:class="[isIconSpinning ? 'animate-spin text-primary-500 dark:text-primary-400' : '']"
/>
<span class="min-w-0 truncate">
<span class="text-gray-600 dark:text-gray-300">{{ title }}</span>
<span v-if="detail" class="text-gray-400 dark:text-gray-500">· {{ detail }}</span>
</span>
</div>
</template>
+336 -174
View File
@@ -11,10 +11,16 @@ import EvidenceBlock from './EvidenceBlock.vue'
import { useToast } from '@/composables/useToast'
import { stripChartImagePlaceholders } from '@/services/ai/chartMarkdownPlaceholders'
import { shouldHideRecoverableChartError } from '@/stores/aiChatChartBlocks'
import type { ToolProgress } from '@openchatlab/shared-types'
import LiveFollowText from './LiveFollowText.vue'
import { getFirstLine, getLatestLine } from './liveFollowText'
import {
buildProcessSegments,
getProcessSegmentStatusLabel,
findRepresentativeProcessThought,
formatProcessDuration,
getProcessSegmentDurationMs,
getVisibleSegmentBlocks,
resolveProcessHeaderActivity,
type ProcessSegment,
} from './chatMessageProcessSegments'
@@ -33,6 +39,13 @@ const props = defineProps<{
/** 是否显示截屏按钮(仅 AI 回复) */
showCaptureButton?: boolean
editable?: boolean
/** Live tool status for the streaming process header. */
activeTool?: {
name: string
displayName: string
status: 'running' | 'done' | 'error'
progress?: ToolProgress
} | null
}>()
const emit = defineEmits<{
@@ -220,7 +233,8 @@ function isLastVisibleBlock(block: ContentBlock): boolean {
}
function isProcessingProcessSegment(segmentIndex: number): boolean {
return !!props.isStreaming && renderSegments.value[segmentIndex]?.type === 'process'
const segment = renderSegments.value[segmentIndex]
return segment?.type === 'process' && !!props.isStreaming
}
function getProcessSegmentStepCount(segment: ProcessSegment<ContentBlock>): number {
@@ -234,28 +248,6 @@ function getProcessSegmentStepLabel(segment: ProcessSegment<ContentBlock>): stri
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(
@@ -269,18 +261,125 @@ function getBlockDurationMs(block: ContentBlock): number {
return 0
}
function getProcessSegmentLabel(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
return getProcessSegmentStatusLabel(segment, {
getBlockDurationMs,
isProcessing: isProcessingProcessSegment(segmentIndex),
labels: {
processed: t('ai.chat.message.process.processed'),
processing: t('ai.chat.message.process.processing'),
},
locale: locale.value,
function getLastFoldableProcessBlock(
segment: ProcessSegment<ContentBlock>
): Extract<ContentBlock, { type: 'tool' | 'think' | 'skill' | 'plan' | 'plan_draft' | 'error' }> | undefined {
if (segment.type !== 'process') return undefined
for (let index = segment.blocks.length - 1; index >= 0; index -= 1) {
const block = segment.blocks[index]
if (isFoldableProcessBlock(block)) {
return block as Extract<ContentBlock, { type: 'tool' | 'think' | 'skill' | 'plan' | 'plan_draft' | 'error' }>
}
}
return undefined
}
function getRepresentativeThinkBlock(segment: ProcessSegment<ContentBlock>) {
return segment.type === 'process' ? findRepresentativeProcessThought(segment.blocks) : undefined
}
function getProcessHeaderActivity(segment: ProcessSegment<ContentBlock>, segmentIndex: number) {
const lastFoldable = getLastFoldableProcessBlock(segment)
return resolveProcessHeaderActivity({
isActive: isProcessingProcessSegment(segmentIndex),
activeToolName: props.activeTool?.status === 'running' ? props.activeTool.name : undefined,
activeToolProgressPhase: props.activeTool?.status === 'running' ? props.activeTool.progress?.phase : undefined,
lastFoldable: lastFoldable
? {
kind: lastFoldable.type,
name:
lastFoldable.type === 'tool'
? lastFoldable.tool.name
: lastFoldable.type === 'skill'
? lastFoldable.skillName
: undefined,
}
: undefined,
})
}
function getProcessHeaderTitle(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (!isProcessingProcessSegment(segmentIndex)) {
const thinkBlock = getRepresentativeThinkBlock(segment)
if (thinkBlock) return getThinkLabel(thinkBlock.tag)
return t('ai.chat.message.process.label')
}
const activity = getProcessHeaderActivity(segment, segmentIndex)
if (activity.type === 'tool') {
if (props.activeTool?.status === 'running' && props.activeTool.name === activity.name) {
return getToolDisplayName(props.activeTool)
}
const toolBlock =
segment.type === 'process'
? segment.blocks.find((block) => block.type === 'tool' && block.tool.name === activity.name)
: undefined
return toolBlock?.type === 'tool' ? getToolDisplayName(toolBlock.tool) : activity.name
}
if (activity.type === 'think') return t('ai.chat.message.think.loading')
if (activity.type === 'skill') return t('ai.skill.active.label', { name: activity.name })
if (activity.type === 'plan') return t('ai.chat.message.process.planning')
return t('ai.chat.message.process.working')
}
function getProcessHeaderMeta(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (isProcessingProcessSegment(segmentIndex)) {
const activity = getProcessHeaderActivity(segment, segmentIndex)
if (activity.type === 'tool' && activity.progressPhase) {
const key = `ai.chat.thinking.toolProgress.${activity.progressPhase}`
return te(key) ? t(key) : ''
}
return ''
}
if (hasProcessSegmentError(segment)) return t('ai.chat.message.process.failed')
const parts = [getProcessSegmentStepLabel(segment)]
const durationMs = getProcessSegmentDurationMs(segment, getBlockDurationMs)
if (durationMs > 0) parts.push(formatProcessDuration(durationMs, locale.value))
return parts.join(' · ')
}
function getProcessHeaderPreview(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (segment.type !== 'process') return ''
if (!isProcessingProcessSegment(segmentIndex)) {
const thinkBlock = getRepresentativeThinkBlock(segment)
return thinkBlock ? getFirstLine(thinkBlock.text) : ''
}
if (props.activeTool?.status === 'running') return ''
for (let index = segment.blocks.length - 1; index >= 0; index -= 1) {
const block = segment.blocks[index]
if (block.type === 'think' || block.type === 'plan_draft' || block.type === 'text') {
const line = getLatestLine(block.text)
if (line) return line
}
}
return ''
}
function getProcessHeaderIcon(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (hasProcessSegmentError(segment)) return 'i-heroicons-exclamation-circle'
if (!isProcessingProcessSegment(segmentIndex)) {
return getRepresentativeThinkBlock(segment) ? 'i-heroicons-light-bulb' : 'i-heroicons-queue-list'
}
const activity = getProcessHeaderActivity(segment, segmentIndex)
if (activity.type === 'tool') return 'i-heroicons-wrench-screwdriver'
if (activity.type === 'think') return 'i-heroicons-light-bulb'
if (activity.type === 'skill') return 'i-heroicons-bolt'
if (activity.type === 'plan') return 'i-heroicons-clipboard-document-list'
return 'i-heroicons-sparkles'
}
function isLiveThinkBlock(block: ContentBlock): boolean {
return block.type === 'think' && !!props.isStreaming && !block.durationMs
}
// 是否使用 contentBlocks 渲染(AI 消息且有内容块)
const useBlocksRendering = computed(() => {
return props.role === 'assistant' && visibleBlocks.value.length > 0
@@ -694,39 +793,59 @@ async function handleCopyMarkdown() {
<template v-else-if="useBlocksRendering">
<div class="space-y-2">
<template v-for="(segment, segmentIdx) in renderSegments" :key="segmentIdx">
<div :class="[segment.type === 'process' ? 'py-1' : '']">
<div>
<button
v-if="segment.type === 'process'"
type="button"
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"
class="ai-live-row group/process flex h-6 w-full min-w-0 max-w-full items-center text-left text-sm leading-6 text-gray-500 transition-colors hover:text-gray-700 focus:outline-none focus-visible:bg-gray-50 dark:text-gray-400 dark:hover:text-gray-200 dark:focus-visible:bg-gray-800/30"
:data-running="isProcessingProcessSegment(segmentIdx) || undefined"
:aria-expanded="isProcessSegmentOpen(segmentIdx)"
@click="toggleProcessSegment(segmentIdx)"
>
<UIcon
:name="isProcessSegmentOpen(segmentIdx) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="h-3 w-3 shrink-0"
/>
<UIcon
v-if="isProcessingProcessSegment(segmentIdx)"
name="i-heroicons-arrow-path"
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 class="relative mr-1.5 flex h-4 w-4 shrink-0 items-center justify-center">
<UIcon
v-if="isProcessSegmentOpen(segmentIdx)"
name="i-heroicons-chevron-down"
class="h-3.5 w-3.5 text-gray-500 dark:text-gray-400"
/>
<template v-else>
<UIcon
:name="getProcessHeaderIcon(segment, segmentIdx)"
class="absolute h-3.5 w-3.5 text-gray-400 transition-opacity duration-100 group-hover/process:opacity-0 dark:text-gray-500"
:class="[hasProcessSegmentError(segment) ? 'text-amber-500 dark:text-amber-400' : '']"
/>
<UIcon
name="i-heroicons-chevron-down"
class="absolute h-3.5 w-3.5 text-gray-500 opacity-0 transition-opacity duration-100 group-hover/process:opacity-100 dark:text-gray-400"
/>
</template>
</span>
<span class="shrink-0 text-gray-400 dark:text-gray-500">
· {{ getProcessSegmentLabel(segment, segmentIdx) }} · {{ getProcessSegmentStepLabel(segment) }}
<span class="shrink-0 text-gray-600 dark:text-gray-300">
{{ getProcessHeaderTitle(segment, segmentIdx) }}
</span>
<template v-if="!isProcessSegmentOpen(segmentIdx) && getProcessHeaderPreview(segment, segmentIdx)">
<span
class="mx-2 h-0.5 w-0.5 shrink-0 rounded-full bg-gray-300 dark:bg-gray-600"
aria-hidden="true"
/>
<LiveFollowText
:text="getProcessHeaderPreview(segment, segmentIdx)"
:follow-end="isProcessingProcessSegment(segmentIdx)"
/>
</template>
<span
v-if="getProcessSegmentPreview(segment)"
class="min-w-0 flex-1 truncate text-gray-400/80 dark:text-gray-500/80"
v-if="getProcessHeaderMeta(segment, segmentIdx)"
class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
:class="[
!isProcessSegmentOpen(segmentIdx) && getProcessHeaderPreview(segment, segmentIdx)
? 'ml-3'
: 'ml-1.5',
]"
>
· {{ getProcessSegmentPreview(segment) }}
<span v-if="isProcessSegmentOpen(segmentIdx) || !getProcessHeaderPreview(segment, segmentIdx)">
·
</span>
{{ getProcessHeaderMeta(segment, segmentIdx) }}
</span>
</button>
@@ -734,7 +853,7 @@ async function handleCopyMarkdown() {
v-show="segment.type !== 'process' || isProcessSegmentOpen(segmentIdx)"
:class="[
segment.type === 'process'
? 'ml-1.5 mt-1 space-y-1.5 border-l border-gray-200 pl-3 dark:border-gray-800'
? 'ml-1.5 mt-1 space-y-0.5 border-l border-gray-200/80 pl-3 dark:border-white/10'
: '',
]"
>
@@ -743,9 +862,7 @@ async function handleCopyMarkdown() {
<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' : '',
]"
:class="[segment.type === 'process' ? 'text-xs text-gray-500 dark:text-gray-400' : '']"
>
<div
class="prose prose-sm dark:prose-invert max-w-none leading-relaxed"
@@ -762,33 +879,40 @@ async function handleCopyMarkdown() {
<!-- 思考块(默认折叠) -->
<details
v-else-if="block.type === 'think'"
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"
class="group group/think w-full text-sm text-gray-500 dark:text-gray-400"
>
<summary
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"
class="ai-live-row flex h-6 min-w-0 cursor-pointer list-none select-none items-center text-sm leading-6 transition-colors hover:text-gray-700 dark:hover:text-gray-300 [&::-webkit-details-marker]:hidden"
:data-running="isLiveThinkBlock(block) || undefined"
>
<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 class="relative mr-1.5 flex h-4 w-4 shrink-0 items-center justify-center">
<UIcon
name="i-heroicons-light-bulb"
class="absolute h-3.5 w-3.5 text-gray-400 transition-opacity duration-100 group-hover/think:opacity-0 group-open:hidden dark:text-gray-500"
/>
<UIcon
name="i-heroicons-chevron-down"
class="absolute h-3.5 w-3.5 text-gray-500 opacity-0 transition-opacity duration-100 group-hover/think:opacity-100 group-open:opacity-100 dark:text-gray-400"
/>
</span>
<span v-if="block.durationMs" class="shrink-0 text-xs text-gray-400 dark:text-gray-500">
<span class="shrink-0 text-gray-600 dark:text-gray-300">
{{ getThinkLabel(block.tag) }}
</span>
<span
v-if="getFirstLine(block.text) || isLiveThinkBlock(block)"
class="mx-2 h-0.5 w-0.5 shrink-0 rounded-full bg-gray-300 group-open:hidden dark:bg-gray-600"
aria-hidden="true"
/>
<LiveFollowText
class="group-open:hidden"
:text="isLiveThinkBlock(block) ? getLatestLine(block.text) : getFirstLine(block.text)"
:follow-end="isLiveThinkBlock(block)"
/>
<span v-if="block.durationMs" class="ml-3 shrink-0 text-xs text-gray-400 dark:text-gray-500">
{{ formatThinkDuration(block.durationMs) }}
</span>
<span
v-if="isStreaming && isLastVisibleBlock(block)"
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">
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
</span>
</span>
</summary>
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none leading-relaxed text-xs">
<div class="prose prose-sm dark:prose-invert mt-1 max-w-none pl-[22px] text-[13px] leading-6">
<template v-if="block.tag === 'plan_validation' && getPlanValidation(block.text)">
<div
v-if="getPlanValidation(block.text)?.title"
@@ -839,22 +963,22 @@ async function handleCopyMarkdown() {
<!-- 技能块 -->
<div
v-else-if="block.type === 'skill'"
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-600 dark:bg-gray-800/50 dark:text-gray-400"
class="flex items-center gap-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300"
>
<UIcon name="i-heroicons-bolt" class="h-3.5 w-3.5" />
<UIcon name="i-heroicons-bolt" class="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span>{{ t('ai.skill.active.label', { name: block.skillName }) }}</span>
</div>
<!-- 计划块 -->
<details
v-else-if="block.type === 'plan'"
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"
class="group w-full text-sm text-gray-600 dark:text-gray-400"
>
<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"
class="flex cursor-pointer select-none items-center gap-2 rounded-md py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-50/80 hover:text-gray-700 dark:hover:bg-gray-800/30 dark:hover:text-gray-300"
>
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 truncate">
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span class="min-w-0 truncate text-gray-600 dark:text-gray-300">
{{ t('ai.chat.message.plan.label') }} · {{ block.plan.title }}
</span>
</summary>
@@ -897,22 +1021,12 @@ async function handleCopyMarkdown() {
</details>
<!-- 计划草稿块 -->
<div
v-else-if="block.type === 'plan_draft'"
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" />
<div v-else-if="block.type === 'plan_draft'" class="py-1 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-300">
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span>{{ t('ai.chat.message.plan.label') }}</span>
<span
v-if="isStreaming"
class="inline-flex items-center gap-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"
>
<span class="flex gap-0.5">
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
</span>
<span v-if="isStreaming" class="text-[11px] font-normal text-gray-400 dark:text-gray-500">
{{ t('ai.chat.message.process.planning') }}
</span>
</div>
<div
@@ -928,49 +1042,48 @@ async function handleCopyMarkdown() {
<EvidenceBlock v-else-if="block.type === 'evidence'" :evidence="block.evidence" />
<!-- 工具块:有结果时可展开查看发送给 AI 的安全文本 -->
<details v-else-if="block.type === 'tool' && hasToolResult(block.tool)" class="w-full max-w-full">
<details
v-else-if="block.type === 'tool' && hasToolResult(block.tool)"
class="group w-full max-w-full"
>
<summary
class="inline-flex cursor-pointer list-none select-none [&::-webkit-details-marker]:hidden"
class="flex cursor-pointer list-none select-none items-center gap-2 rounded-md py-1 text-xs text-gray-500 transition-colors hover:bg-gray-50/80 dark:text-gray-400 dark:hover:bg-gray-800/30 [&::-webkit-details-marker]:hidden"
:class="[block.tool.status === 'error' ? 'text-amber-700 dark:text-amber-400' : '']"
>
<div
class="flex w-fit items-center gap-1.5 rounded-lg bg-gray-50 px-2.5 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 dark:bg-gray-800/30 dark:text-gray-400/80 dark:hover:bg-gray-800/60"
<UIcon
:name="
block.tool.status === 'error'
? 'i-heroicons-exclamation-circle'
: 'i-heroicons-wrench-screwdriver'
"
class="h-3.5 w-3.5 shrink-0"
:class="[block.tool.status === 'error' ? 'text-amber-500' : 'text-gray-400']"
/>
<span
class="shrink-0 font-medium"
:class="[
block.tool.status === 'error'
? 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400'
: '',
? 'text-amber-700 dark:text-amber-300'
: 'text-gray-600 dark:text-gray-300',
]"
>
<UIcon
:name="
block.tool.status === 'error'
? 'i-heroicons-exclamation-circle'
: 'i-heroicons-wrench-screwdriver'
"
class="h-3.5 w-3.5 shrink-0"
/>
<div class="flex min-w-0 items-baseline gap-1.5 font-medium">
<span>{{ getToolDisplayName(block.tool) }}</span>
<span
v-if="formatToolParams(block.tool)"
class="max-w-[200px] truncate text-[11px] font-normal opacity-75 sm:max-w-[300px]"
>
{{ formatToolParams(block.tool) }}
</span>
<span class="shrink-0 text-[11px] font-normal opacity-75">
· {{ t('ai.chat.message.toolResult.view') }}
</span>
</div>
<UIcon
name="i-heroicons-chevron-right"
class="h-3 w-3 shrink-0 transition-transform [[open]>&]:rotate-90"
/>
</div>
{{ getToolDisplayName(block.tool) }}
</span>
<span
v-if="formatToolParams(block.tool)"
class="min-w-0 truncate text-[11px] text-gray-400 dark:text-gray-500"
>
{{ formatToolParams(block.tool) }}
</span>
<span class="ml-auto shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{{ t('ai.chat.message.toolResult.view') }}
</span>
</summary>
<div
class="mt-2 max-w-full rounded-lg border border-gray-200 bg-gray-50/80 dark:border-gray-700/70 dark:bg-page-dark/40"
class="mt-1 max-w-full rounded-lg bg-gray-50/80 ring-1 ring-gray-200/60 dark:bg-white/[0.03] dark:ring-white/5"
>
<div
class="flex items-center justify-between gap-2 border-b border-gray-200/70 px-3 py-2 dark:border-gray-700/70"
class="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/5"
>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('ai.chat.message.toolResult.title') }}
@@ -1000,62 +1113,65 @@ async function handleCopyMarkdown() {
<!-- 工具块:运行中或无结果时保持紧凑展示 -->
<div
v-else-if="block.type === 'tool'"
class="flex w-fit items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs transition-colors"
class="ai-live-row flex min-w-0 items-center gap-2 py-1 text-xs"
:data-running="block.tool.status === 'running' || undefined"
:class="[
block.tool.status === 'running'
? 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400'
: block.tool.status === 'done'
? 'bg-gray-50 text-gray-500 dark:bg-gray-800/30 dark:text-gray-400/80'
: 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400',
block.tool.status === 'error'
? 'text-amber-700 dark:text-amber-400'
: 'text-gray-500 dark:text-gray-400',
]"
>
<UIcon
:name="
block.tool.status === 'running'
? 'i-heroicons-arrow-path'
: block.tool.status === 'done'
? 'i-heroicons-wrench-screwdriver'
: 'i-heroicons-exclamation-circle'
: block.tool.status === 'error'
? 'i-heroicons-exclamation-circle'
: 'i-heroicons-wrench-screwdriver'
"
class="h-3.5 w-3.5 shrink-0"
:class="[block.tool.status === 'running' ? 'animate-spin' : '']"
:class="[
block.tool.status === 'running'
? 'animate-spin text-primary-500'
: block.tool.status === 'error'
? 'text-amber-500'
: 'text-gray-400',
]"
/>
<div class="flex min-w-0 items-baseline gap-1.5 font-medium">
<span>{{ getToolDisplayName(block.tool) }}</span>
<span
v-if="formatToolParams(block.tool)"
class="max-w-[200px] truncate text-[11px] font-normal opacity-75 sm:max-w-[300px]"
>
{{ formatToolParams(block.tool) }}
</span>
<span
v-if="block.tool.status === 'done' && block.tool.durationMs"
class="shrink-0 text-[11px] font-normal opacity-75"
>
· {{ formatThinkDuration(block.tool.durationMs) }}
</span>
</div>
<span
class="shrink-0 font-medium"
:class="[
block.tool.status === 'error'
? 'text-amber-700 dark:text-amber-300'
: 'text-gray-600 dark:text-gray-300',
]"
>
{{ getToolDisplayName(block.tool) }}
</span>
<span
v-if="formatToolParams(block.tool)"
class="min-w-0 truncate text-[11px] text-gray-400 dark:text-gray-500"
>
{{ formatToolParams(block.tool) }}
</span>
<span
v-if="block.tool.status === 'done' && block.tool.durationMs"
class="ml-auto shrink-0 text-[11px] text-gray-400 dark:text-gray-500"
>
{{ formatThinkDuration(block.tool.durationMs) }}
</span>
</div>
<!-- 错误块 -->
<ErrorBlock v-else-if="block.type === 'error'" :error="block.error" />
<ErrorBlock
v-else-if="block.type === 'error'"
:error="block.error"
:compact="segment.type === 'process'"
/>
</template>
</div>
</div>
</template>
<!-- 流式处理中指示器(当最后一个块是已完成的工具块时显示) -->
<div
v-if="isStreaming && visibleBlocks.length > 0 && visibleBlocks[visibleBlocks.length - 1].type === 'tool'"
class="flex items-center gap-2 px-1 py-2 text-sm text-gray-500 dark:text-gray-400"
>
<span class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
</span>
<span>{{ t('ai.chat.message.generating') }}</span>
</div>
</div>
</template>
@@ -1114,4 +1230,50 @@ async function handleCopyMarkdown() {
</div>
</template>
<!-- Markdown 样式已提取到全局 src/assets/styles/markdown.css -->
<style>
.ai-live-row {
position: relative;
overflow: hidden;
}
.ai-live-row[data-running]::after {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--color-page-bg) 60%, transparent) 55%,
transparent 100%
);
animation: ai-live-row-sweep 4s ease-out infinite;
pointer-events: none;
}
.dark .ai-live-row[data-running]::after {
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--color-page-dark) 60%, transparent) 55%,
transparent 100%
);
}
@keyframes ai-live-row-sweep {
0% {
left: -300px;
}
75%,
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.ai-live-row[data-running]::after {
animation: none;
}
}
</style>
+20 -26
View File
@@ -51,27 +51,20 @@ const agentPhaseShortText = computed(() => {
return t(`ai.chat.statusBar.agent.phaseShort.${props.agentStatus.phase}`)
})
const agentPhaseClass = computed(() => {
if (!props.agentStatus) return 'text-gray-500 bg-gray-100 dark:bg-gray-800 dark:text-gray-300'
const isLiveAgentPhase = computed(() => {
const phase = props.agentStatus?.phase
return (
phase === 'compressing' ||
phase === 'preparing' ||
phase === 'thinking' ||
phase === 'tool_running' ||
phase === 'responding'
)
})
switch (props.agentStatus.phase) {
case 'compressing':
return 'text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-300'
case 'tool_running':
return 'text-indigo-600 bg-indigo-50 dark:bg-indigo-900/30 dark:text-indigo-300'
case 'thinking':
return 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300'
case 'responding':
return 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/30 dark:text-emerald-300'
case 'completed':
return 'text-green-600 bg-green-50 dark:bg-green-900/30 dark:text-green-300'
case 'aborted':
return 'text-amber-600 bg-amber-50 dark:bg-amber-900/30 dark:text-amber-300'
case 'error':
return 'text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-300'
default:
return 'text-gray-500 bg-gray-100 dark:bg-gray-800 dark:text-gray-300'
}
const showAgentPhase = computed(() => {
const phase = props.agentStatus?.phase
return isLiveAgentPhase.value || phase === 'error'
})
function formatNumber(value: number): string {
@@ -336,14 +329,15 @@ const thinkingLevelLabel = computed(() => {
<!-- 右侧:配置状态指示 -->
<div class="flex items-center gap-1">
<div
v-if="agentStatus"
class="hidden shrink-0 items-center gap-1 rounded-lg bg-gray-50/90 px-1.5 py-1 text-xs shadow-[inset_0_0_0_1px_rgba(255,255,255,0.35)] dark:bg-gray-800/70 dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] lg:flex"
v-if="showAgentPhase"
class="hidden shrink-0 items-center gap-1.5 px-1.5 py-1 text-[11px] text-gray-400 lg:flex dark:text-gray-500"
:title="agentCompactTitle"
>
<!-- 主栏只展示阶段,context token 放进 tooltip,避免和累计 token 混淆。 -->
<span class="rounded px-1 py-0.5 text-[10px] font-medium" :class="agentPhaseClass">
{{ agentPhaseShortText }}
</span>
<span
class="h-1.5 w-1.5 rounded-full"
:class="[agentStatus?.phase === 'error' ? 'bg-amber-500' : 'bg-primary-500 animate-pulse']"
/>
<span>{{ agentPhaseShortText }}</span>
</div>
<!-- Context 进度条 -->
+15 -1
View File
@@ -6,8 +6,9 @@ import ErrorDetailModal from './ErrorDetailModal.vue'
const { t } = useI18n()
defineProps<{
const props = defineProps<{
error: SerializedErrorInfo
compact?: boolean
}>()
const showDetail = ref(false)
@@ -18,7 +19,20 @@ function getDisplayMessage(error: SerializedErrorInfo): string {
</script>
<template>
<button
v-if="props.compact"
type="button"
class="flex w-full min-w-0 items-start gap-2 rounded-md py-1 text-left text-xs text-amber-700 transition-colors hover:bg-amber-50/70 dark:text-amber-300 dark:hover:bg-amber-950/20"
@click="showDetail = true"
>
<UIcon name="i-heroicons-exclamation-circle" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span class="min-w-0 flex-1 truncate">{{ getDisplayMessage(error) }}</span>
<span class="shrink-0 text-[11px] text-amber-600/80 dark:text-amber-400/80">
{{ t('ai.chat.error.viewDetail') }}
</span>
</button>
<div
v-else
class="group relative my-2 w-full min-w-[320px] cursor-pointer rounded-lg border border-red-200 bg-red-50/60 px-3.5 py-3 text-[13px] transition-all duration-200 hover:border-red-300 hover:bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 dark:hover:border-red-800/50 dark:hover:bg-red-950/30"
@click="showDetail = true"
>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
text: string
followEnd?: boolean
}>(),
{
followEnd: false,
}
)
const el = ref<HTMLSpanElement | null>(null)
let pendingFrame: number | null = null
function align() {
const element = el.value
if (!element) return
element.scrollLeft = props.followEnd ? Math.max(0, element.scrollWidth - element.clientWidth) : 0
}
function scheduleAlign() {
if (pendingFrame !== null) return
let remainingFrames = 3
const advance = () => {
remainingFrames -= 1
if (remainingFrames > 0) {
pendingFrame = requestAnimationFrame(advance)
return
}
pendingFrame = null
align()
}
pendingFrame = requestAnimationFrame(advance)
}
watch(
() => [props.text, props.followEnd] as const,
async () => {
await nextTick()
scheduleAlign()
},
{ flush: 'post', immediate: true }
)
onBeforeUnmount(() => {
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame)
})
</script>
<template>
<span
ref="el"
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-gray-400 dark:text-gray-500"
:class="[props.followEnd ? '' : 'text-ellipsis']"
:data-follow-end="props.followEnd || undefined"
>
{{ props.text }}
</span>
</template>
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
buildProcessSegments,
findRepresentativeProcessThought,
getProcessSegmentStatusLabel,
getVisibleSegmentBlocks,
resolveProcessHeaderActivity,
} from './chatMessageProcessSegments'
type TestBlock =
@@ -67,6 +69,30 @@ describe('chat message process segments', () => {
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[1], blocks[6], blocks[7]])
})
it('keeps live thinking in the process row while the answer streams visibly', () => {
const blocks: TestBlock[] = [
{ type: 'think', text: '先看这周在聊什么' },
{ type: 'tool', name: 'search_messages' },
{ type: 'text', text: '证据拿到了,我再核对统计。' },
]
const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
assert.deepEqual(segments, [
{ type: 'process', blocks: blocks.slice(0, 2) },
{ type: 'visible', block: blocks[2] },
])
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[2]])
})
it('uses natural-language thinking for the collapsed preview instead of plan JSON', () => {
const reasoning = { type: 'think' as const, tag: 'reasoning', text: '先梳理长期变化' }
const planValidation = { type: 'think' as const, tag: 'plan_validation', text: '{\n "title": "分析计划"' }
assert.equal(findRepresentativeProcessThought([reasoning, planValidation]), reasoning)
assert.equal(findRepresentativeProcessThought([planValidation]), undefined)
})
it('keeps text visible when no later process block exists', () => {
const blocks: TestBlock[] = [
{ type: 'tool', name: 'get_members' },
@@ -118,4 +144,23 @@ describe('chat message process segments', () => {
assert.equal(label, '已处理 1分05秒')
})
it('prefers the live tool over the last foldable block while processing', () => {
assert.deepEqual(
resolveProcessHeaderActivity({
isActive: true,
activeToolName: 'retrieve_chat_evidence',
activeToolProgressPhase: 'semantic_search',
lastFoldable: { kind: 'think' },
}),
{ type: 'tool', name: 'retrieve_chat_evidence', progressPhase: 'semantic_search' }
)
assert.deepEqual(
resolveProcessHeaderActivity({
isActive: false,
lastFoldable: { kind: 'tool', name: 'search_messages' },
}),
{ type: 'generic' }
)
})
})
@@ -15,6 +15,12 @@ export interface ProcessSegmentStatusLabelOptions<T> {
locale: string
}
interface ProcessThoughtCandidate {
type: string
tag?: string
text?: string
}
function hasLaterFoldableProcessBlock<T>(
blocks: T[],
startIndex: number,
@@ -53,6 +59,18 @@ export function getVisibleSegmentBlocks<T>(segments: ProcessSegment<T>[]): T[] {
return segments.flatMap((segment) => (segment.type === 'visible' ? [segment.block] : []))
}
export function findRepresentativeProcessThought<T extends ProcessThoughtCandidate>(
blocks: readonly T[]
): Extract<T, { type: 'think' }> | undefined {
for (let index = blocks.length - 1; index >= 0; index -= 1) {
const block = blocks[index]
if (block.type === 'think' && block.tag?.toLowerCase() !== 'plan_validation' && Boolean(block.text?.trim())) {
return block as Extract<T, { type: 'think' }>
}
}
return undefined
}
export function formatProcessDuration(durationMs: number, locale: string): string {
const totalSeconds = Math.max(0, Math.round(durationMs / 1000))
if (locale.startsWith('zh') || locale.startsWith('ja')) {
@@ -98,3 +116,43 @@ export function getProcessSegmentStatusLabel<T>(
const duration = durationMs > 0 ? ` ${formatProcessDuration(durationMs, options.locale)}` : ''
return `${options.labels.processed}${duration}`
}
export type ProcessHeaderActivity =
| { type: 'tool'; name: string; progressPhase?: string }
| { type: 'think' }
| { type: 'skill'; name: string }
| { type: 'plan' }
| { type: 'generic' }
export function resolveProcessHeaderActivity(input: {
isActive: boolean
activeToolName?: string
activeToolProgressPhase?: string
lastFoldable?: {
kind: 'tool' | 'think' | 'skill' | 'plan' | 'plan_draft' | 'error'
name?: string
}
}): ProcessHeaderActivity {
if (input.isActive && input.activeToolName) {
return {
type: 'tool',
name: input.activeToolName,
progressPhase: input.activeToolProgressPhase,
}
}
if (input.isActive && input.lastFoldable) {
if (input.lastFoldable.kind === 'think') return { type: 'think' }
if (input.lastFoldable.kind === 'skill' && input.lastFoldable.name) {
return { type: 'skill', name: input.lastFoldable.name }
}
if (input.lastFoldable.kind === 'plan' || input.lastFoldable.kind === 'plan_draft') {
return { type: 'plan' }
}
if (input.lastFoldable.kind === 'tool' && input.lastFoldable.name) {
return { type: 'tool', name: input.lastFoldable.name }
}
}
return { type: 'generic' }
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { getFirstLine, getLatestLine } from './liveFollowText'
describe('live follow text', () => {
it('uses the first line after thinking settles', () => {
assert.equal(getFirstLine('Inspect the session\nNewest reasoning tokens'), 'Inspect the session')
assert.equal(getFirstLine('one-liner'), 'one-liner')
})
it('follows the latest streaming line, including a line that is still growing', () => {
assert.equal(getLatestLine('Inspect the session\nNewest reasoning tokens'), 'Newest reasoning tokens')
assert.equal(
getLatestLine('Inspect the session\nNewest reasoning tokens keep arriving'),
'Newest reasoning tokens keep arriving'
)
assert.equal(
getLatestLine('Inspect the session\nNewest reasoning tokens keep arriving\n'),
'Newest reasoning tokens keep arriving'
)
})
})
@@ -0,0 +1,10 @@
export function getFirstLine(text: string): string {
const newline = text.indexOf('\n')
return newline === -1 ? text : text.slice(0, newline)
}
export function getLatestLine(text: string): string {
const visible = text.trimEnd()
const newline = visible.lastIndexOf('\n')
return newline === -1 ? visible : visible.slice(newline + 1)
}
@@ -82,11 +82,10 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
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'
: 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',
!embedded && disabled ? 'bg-gray-50/50 dark:bg-page-dark/50' : '',
!embedded && !disabled
? '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">
+3
View File
@@ -46,6 +46,9 @@
"label": "Process",
"processing": "Processing",
"processed": "Processed",
"working": "Working",
"planning": "Planning",
"failed": "Incomplete",
"step": "{count} step",
"steps": "{count} steps"
},
+3
View File
@@ -46,6 +46,9 @@
"label": "実行過程",
"processing": "処理中",
"processed": "処理済み",
"working": "処理中",
"planning": "計画中",
"failed": "未完了",
"step": "{count} ステップ",
"steps": "{count} ステップ"
},
+3
View File
@@ -46,6 +46,9 @@
"label": "执行过程",
"processing": "处理中",
"processed": "已处理",
"working": "正在处理",
"planning": "正在规划",
"failed": "未完成",
"step": "{count} 步",
"steps": "{count} 步"
},
+3
View File
@@ -46,6 +46,9 @@
"label": "執行過程",
"processing": "處理中",
"processed": "已處理",
"working": "正在處理",
"planning": "正在規劃",
"failed": "未完成",
"step": "{count} 步",
"steps": "{count} 步"
},