mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): refine streaming process display
This commit is contained in:
1 parent
f3041f682f
commit
9fa79b5d9f
15 files changed
+625
-315
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 进度条 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"label": "Process",
|
||||
"processing": "Processing",
|
||||
"processed": "Processed",
|
||||
"working": "Working",
|
||||
"planning": "Planning",
|
||||
"failed": "Incomplete",
|
||||
"step": "{count} step",
|
||||
"steps": "{count} steps"
|
||||
},
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"label": "実行過程",
|
||||
"processing": "処理中",
|
||||
"processed": "処理済み",
|
||||
"working": "処理中",
|
||||
"planning": "計画中",
|
||||
"failed": "未完了",
|
||||
"step": "{count} ステップ",
|
||||
"steps": "{count} ステップ"
|
||||
},
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"label": "执行过程",
|
||||
"processing": "处理中",
|
||||
"processed": "已处理",
|
||||
"working": "正在处理",
|
||||
"planning": "正在规划",
|
||||
"failed": "未完成",
|
||||
"step": "{count} 步",
|
||||
"steps": "{count} 步"
|
||||
},
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"label": "執行過程",
|
||||
"processing": "處理中",
|
||||
"processed": "已處理",
|
||||
"working": "正在處理",
|
||||
"planning": "正在規劃",
|
||||
"failed": "未完成",
|
||||
"step": "{count} 步",
|
||||
"steps": "{count} 步"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user