diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index f11720af..d16ff170 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -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" /> @@ -530,7 +530,6 @@ watch( !(messages[messages.length - 1]?.contentBlocks?.length ?? 0) " :current-tool-status="currentToolStatus" - :tools-used="toolsUsedInCurrentRound" :agent-status="agentStatus" /> @@ -550,7 +549,7 @@ watch(
-
+
-
+
() 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') diff --git a/src/components/AIChat/chat/ChatMessage.vue b/src/components/AIChat/chat/ChatMessage.vue index 766fabcf..3287fe4f 100644 --- a/src/components/AIChat/chat/ChatMessage.vue +++ b/src/components/AIChat/chat/ChatMessage.vue @@ -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): number { @@ -234,28 +248,6 @@ function getProcessSegmentStepLabel(segment: ProcessSegment): stri return t(key, { count }) } -function getSingleLineText(text: string): string { - return text.replace(/\s+/g, ' ').trim() -} - -function getProcessSegmentPreview(segment: ProcessSegment): 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 | 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, 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 +): Extract | 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 + } + } + return undefined +} + +function getRepresentativeThinkBlock(segment: ProcessSegment) { + return segment.type === 'process' ? findRepresentativeProcessThought(segment.blocks) : undefined +} + +function getProcessHeaderActivity(segment: ProcessSegment, 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, 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, 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, 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, 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() { @@ -1114,4 +1230,50 @@ async function handleCopyMarkdown() {
- + diff --git a/src/components/AIChat/chat/ChatStatusBar.vue b/src/components/AIChat/chat/ChatStatusBar.vue index b499c94a..23ea6151 100644 --- a/src/components/AIChat/chat/ChatStatusBar.vue +++ b/src/components/AIChat/chat/ChatStatusBar.vue @@ -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(() => {
diff --git a/src/components/AIChat/chat/ErrorBlock.vue b/src/components/AIChat/chat/ErrorBlock.vue index d52c0cfb..6cff2591 100644 --- a/src/components/AIChat/chat/ErrorBlock.vue +++ b/src/components/AIChat/chat/ErrorBlock.vue @@ -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 {