feat(ai): refine chat process experience

This commit is contained in:
digua committed 2026-08-19 18:49:20 +08:00
1 parent 9fa79b5d9f
commit 6d22f5d5fb
18 files changed
+481 -48

No files matched your search

@@ -101,15 +101,20 @@ describe('AgentEventHandler', () => {
])
})
it('updates tool rounds on turn_end', () => {
it('forwards turn completion so the UI can identify the final response', () => {
const chunks: AgentStreamChunk[] = []
const handler = new AgentEventHandler({
onChunk: () => {},
onChunk: (chunk) => chunks.push(chunk),
context: {},
systemPrompt: 'test',
})
handler.handleCoreEvent({ type: 'turn_end', round: 3, hadToolCalls: true }, [])
assert.equal(handler.toolRounds, 3)
assert.deepEqual(
chunks.find((chunk) => chunk.type === 'turn_end'),
{ type: 'turn_end', hadToolCalls: true }
)
})
it('cloneUsage returns independent copy', () => {
@@ -44,6 +44,7 @@ export interface AgentStreamChunk {
| 'plan_delta'
| 'plan'
| 'plan_skipped'
| 'turn_end'
| 'done'
| 'error'
content?: string
@@ -62,6 +63,7 @@ export interface AgentStreamChunk {
routeDecision?: RouteDecision
planDelta?: string
plan?: PlanContentBlock
hadToolCalls?: boolean
compressionResult?: {
summaryContent: string
tokensBefore: number
@@ -219,6 +221,7 @@ export class AgentEventHandler {
case 'turn_end':
this.activeTools.clear()
this.toolRounds = event.round
this.onChunk({ type: 'turn_end', hadToolCalls: event.hadToolCalls })
this.emitStatus('thinking', messages, { force: true })
break
case 'usage_update':
+1 -1
View File
@@ -27,7 +27,7 @@ export interface AIChat {
}
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'text'; text: string; processDurationMs?: number }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| { type: 'evidence'; evidence: ChatEvidencePayload }
+43 -12
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from '@/composables/useToast'
import ConversationList from './chat/ConversationList.vue'
import DataSourcePanel from './chat/DataSourcePanel.vue'
@@ -28,6 +29,8 @@ import type { MentionedMemberContext } from '@/composables/useAIChat'
import type { AssistantUpgradeInfo } from '@openchatlab/shared-types'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const settingsStore = useSettingsStore()
const assistantStore = useAssistantStore()
@@ -41,8 +44,11 @@ const props = defineProps<{
chatType?: 'group' | 'private'
}>()
const initialAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null
// 使用 AI 对话 Composable
const {
initialization,
messages,
sourceMessages,
currentKeywords,
@@ -61,7 +67,37 @@ const {
updateMaxMessages,
stopGeneration,
selectAssistantForSession,
} = useAIChat(props.sessionId, props.sessionName, props.timeFilter, props.chatType ?? 'group', settingsStore.locale)
} = useAIChat(
props.sessionId,
props.sessionName,
props.timeFilter,
props.chatType ?? 'group',
settingsStore.locale,
initialAIChatId
)
let isAIChatInitialized = false
async function syncAIChatIdToRoute(aiChatId: string | null): Promise<void> {
const routeAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null
if (routeAIChatId === aiChatId) return
await router.replace({
query: {
...route.query,
aiChatId: aiChatId || undefined,
},
})
}
void initialization.finally(() => {
isAIChatInitialized = true
void syncAIChatIdToRoute(currentAIChatId.value)
})
watch(currentAIChatId, (aiChatId) => {
if (isAIChatInitialized) void syncAIChatIdToRoute(aiChatId)
})
// 智能滚动
const chatScroll = useChatScroll(messages, isAIThinking)
@@ -514,24 +550,19 @@ watch(
:content="pair.assistant.content"
:timestamp="pair.assistant.timestamp"
:is-streaming="pair.assistant.isStreaming"
:process-duration-ms="pair.assistant.processDurationMs"
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
:active-tool="pair.assistant.isStreaming ? currentToolStatus : null"
@fork="handleForkAIChat"
/>
<AIThinkingIndicator
v-else-if="pair.assistant?.isStreaming"
:current-tool-status="currentToolStatus"
:agent-status="agentStatus"
/>
</div>
</template>
<!-- AI 思考中指示器(仅在没有任何内容块时显示) -->
<AIThinkingIndicator
v-if="
isAIThinking &&
!messages[messages.length - 1]?.content &&
!(messages[messages.length - 1]?.contentBlocks?.length ?? 0)
"
:current-tool-status="currentToolStatus"
:agent-status="agentStatus"
/>
</div>
</div>
+51 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import MarkdownIt from 'markdown-it'
@@ -20,7 +20,9 @@ import {
formatProcessDuration,
getProcessSegmentDurationMs,
getVisibleSegmentBlocks,
isActiveProcessSegment,
resolveProcessHeaderActivity,
resolveProcessElapsedMs,
type ProcessSegment,
} from './chatMessageProcessSegments'
@@ -34,6 +36,7 @@ const props = defineProps<{
content: string
timestamp: number
isStreaming?: boolean
processDurationMs?: number
/** AI 消息的混合内容块(按时序排列的文本和工具调用) */
contentBlocks?: ContentBlock[]
/** 是否显示截屏按钮(仅 AI 回复) */
@@ -194,10 +197,27 @@ function isTextBlock(block: ContentBlock): boolean {
return block.type === 'text'
}
// Text may be emitted between tool rounds, so only a completed tool-free turn can end this state.
const isAgentRunActive = computed(() => Boolean(props.isStreaming) && props.processDurationMs === undefined)
const renderSegments = computed(() =>
buildProcessSegments(visibleBlocks.value, {
isFoldableProcessBlock,
isTextBlock,
mode: isAgentRunActive.value ? 'timeline' : 'completed',
})
)
const recordedProcessDurationMs = computed(() =>
renderSegments.value.reduce((total, segment) => total + getProcessSegmentDurationMs(segment, getBlockDurationMs), 0)
)
const processElapsedMs = computed(() =>
resolveProcessElapsedMs({
isStreaming: isAgentRunActive.value,
liveElapsedMs: 0,
settledElapsedMs: props.processDurationMs ?? 0,
recordedElapsedMs: recordedProcessDurationMs.value,
})
)
@@ -217,7 +237,10 @@ function isProcessSegmentOpen(segmentIndex: number): boolean {
const key = getProcessSegmentKey(segmentIndex)
const override = processSegmentOpenOverrides.value[key]
if (override !== undefined) return override
return hasProcessSegmentError(renderSegments.value[segmentIndex])
return (
!isCompletedProcessSummary(renderSegments.value[segmentIndex]) &&
hasProcessSegmentError(renderSegments.value[segmentIndex])
)
}
function toggleProcessSegment(segmentIndex: number): void {
@@ -233,8 +256,11 @@ function isLastVisibleBlock(block: ContentBlock): boolean {
}
function isProcessingProcessSegment(segmentIndex: number): boolean {
const segment = renderSegments.value[segmentIndex]
return segment?.type === 'process' && !!props.isStreaming
return isActiveProcessSegment(renderSegments.value, segmentIndex, isAgentRunActive.value)
}
function isCompletedProcessSummary(segment: ProcessSegment<ContentBlock>): boolean {
return segment.type === 'process' && !isAgentRunActive.value
}
function getProcessSegmentStepCount(segment: ProcessSegment<ContentBlock>): number {
@@ -299,6 +325,12 @@ function getProcessHeaderActivity(segment: ProcessSegment<ContentBlock>, segment
}
function getProcessHeaderTitle(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (isCompletedProcessSummary(segment)) {
return t('ai.chat.message.process.duration', {
duration: formatProcessDuration(processElapsedMs.value, locale.value),
})
}
if (!isProcessingProcessSegment(segmentIndex)) {
const thinkBlock = getRepresentativeThinkBlock(segment)
if (thinkBlock) return getThinkLabel(thinkBlock.tag)
@@ -323,6 +355,8 @@ function getProcessHeaderTitle(segment: ProcessSegment<ContentBlock>, segmentInd
}
function getProcessHeaderMeta(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (isCompletedProcessSummary(segment)) return ''
if (isProcessingProcessSegment(segmentIndex)) {
const activity = getProcessHeaderActivity(segment, segmentIndex)
if (activity.type === 'tool' && activity.progressPhase) {
@@ -332,16 +366,12 @@ function getProcessHeaderMeta(segment: ProcessSegment<ContentBlock>, segmentInde
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(' · ')
return getProcessSegmentStepLabel(segment)
}
function getProcessHeaderPreview(segment: ProcessSegment<ContentBlock>, segmentIndex: number): string {
if (segment.type !== 'process') return ''
if (isCompletedProcessSummary(segment)) return ''
if (!isProcessingProcessSegment(segmentIndex)) {
const thinkBlock = getRepresentativeThinkBlock(segment)
@@ -802,7 +832,10 @@ async function handleCopyMarkdown() {
:aria-expanded="isProcessSegmentOpen(segmentIdx)"
@click="toggleProcessSegment(segmentIdx)"
>
<span class="relative mr-1.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span
v-if="!isCompletedProcessSummary(segment)"
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"
@@ -823,6 +856,11 @@ async function handleCopyMarkdown() {
<span class="shrink-0 text-gray-600 dark:text-gray-300">
{{ getProcessHeaderTitle(segment, segmentIdx) }}
</span>
<UIcon
v-if="isCompletedProcessSummary(segment)"
:name="isProcessSegmentOpen(segmentIdx) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="ml-1 h-3.5 w-3.5 shrink-0 text-gray-500 dark:text-gray-400"
/>
<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"
@@ -1248,7 +1286,7 @@ async function handleCopyMarkdown() {
color-mix(in srgb, var(--color-page-bg) 60%, transparent) 55%,
transparent 100%
);
animation: ai-live-row-sweep 4s ease-out infinite;
animation: ai-live-row-sweep 2.5s ease-in-out infinite;
pointer-events: none;
}
@@ -1265,7 +1303,7 @@ async function handleCopyMarkdown() {
0% {
left: -300px;
}
75%,
80%,
100% {
left: 100%;
}
@@ -5,7 +5,9 @@ import {
findRepresentativeProcessThought,
getProcessSegmentStatusLabel,
getVisibleSegmentBlocks,
isActiveProcessSegment,
resolveProcessHeaderActivity,
resolveProcessElapsedMs,
} from './chatMessageProcessSegments'
type TestBlock =
@@ -28,7 +30,7 @@ const getBlockDurationMs = (block: TestBlock): number => {
}
describe('chat message process segments', () => {
it('folds tool work and interstitial text before the final answer', () => {
it('folds tool work and interstitial text after the answer completes', () => {
const blocks: TestBlock[] = [
{ type: 'think', text: '分析问题' },
{ type: 'tool', name: 'search_messages' },
@@ -37,7 +39,11 @@ describe('chat message process segments', () => {
{ type: 'text', text: '最终结论' },
]
const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
const segments = buildProcessSegments(blocks, {
isFoldableProcessBlock,
isTextBlock,
mode: 'completed',
})
assert.deepEqual(segments, [
{ type: 'process', blocks: blocks.slice(0, 4) },
@@ -58,7 +64,11 @@ describe('chat message process segments', () => {
{ type: 'text', text: '最终解释' },
]
const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
const segments = buildProcessSegments(blocks, {
isFoldableProcessBlock,
isTextBlock,
mode: 'completed',
})
assert.deepEqual(segments, [
{ type: 'process', blocks: [blocks[0], blocks[2], blocks[3], blocks[4], blocks[5]] },
@@ -69,20 +79,81 @@ 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', () => {
it('keeps live blocks in chronological process groups', () => {
const blocks: TestBlock[] = [
{ type: 'think', text: '先看这周在聊什么' },
{ type: 'tool', name: 'search_messages' },
{ type: 'text', text: '证据拿到了,我再核对统计。' },
{ type: 'tool', name: 'get_time_stats' },
{ type: 'think', text: '核对统计结果' },
{ type: 'text', text: '最终结论' },
]
const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
const segments = buildProcessSegments(blocks, {
isFoldableProcessBlock,
isTextBlock,
mode: 'timeline',
})
assert.deepEqual(segments, [
{ type: 'process', blocks: blocks.slice(0, 2) },
{ type: 'visible', block: blocks[2] },
{ type: 'process', blocks: blocks.slice(3, 5) },
{ type: 'visible', block: blocks[5] },
])
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[2]])
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[2], blocks[5]])
})
it('does not move visible text when later process work arrives', () => {
const blocks: TestBlock[] = [
{ type: 'think', text: '分析问题' },
{ type: 'text', text: '先说明一部分结果。' },
]
const beforeTool = buildProcessSegments(blocks, {
isFoldableProcessBlock,
isTextBlock,
mode: 'timeline',
})
blocks.push({ type: 'tool', name: 'search_messages' })
const afterTool = buildProcessSegments(blocks, {
isFoldableProcessBlock,
isTextBlock,
mode: 'timeline',
})
assert.deepEqual(beforeTool, [
{ type: 'process', blocks: [blocks[0]] },
{ type: 'visible', block: blocks[1] },
])
assert.deepEqual(afterTool, [
{ type: 'process', blocks: [blocks[0]] },
{ type: 'visible', block: blocks[1] },
{ type: 'process', blocks: [blocks[2]] },
])
assert.equal(isActiveProcessSegment(afterTool, 0, true), false)
assert.equal(isActiveProcessSegment(afterTool, 2, true), true)
})
it('allows the completed duration to exclude final response generation', () => {
assert.equal(
resolveProcessElapsedMs({
isStreaming: true,
liveElapsedMs: 20_000,
settledElapsedMs: 15_000,
recordedElapsedMs: 5_500,
}),
20_000
)
assert.equal(
resolveProcessElapsedMs({
isStreaming: false,
liveElapsedMs: 20_000,
settledElapsedMs: 15_000,
recordedElapsedMs: 5_500,
}),
15_000
)
})
it('uses natural-language thinking for the collapsed preview instead of plan JSON', () => {
@@ -100,7 +171,11 @@ describe('chat message process segments', () => {
{ type: 'evidence', title: '引用' },
]
const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
const segments = buildProcessSegments(blocks, {
isFoldableProcessBlock,
isTextBlock,
mode: 'completed',
})
assert.deepEqual(segments, [
{ type: 'process', blocks: [blocks[0]] },
@@ -3,6 +3,7 @@ export type ProcessSegment<T> = { type: 'process'; blocks: T[] } | { type: 'visi
export interface ProcessSegmentOptions<T> {
isFoldableProcessBlock: (block: T) => boolean
isTextBlock: (block: T) => boolean
mode: 'timeline' | 'completed'
}
export interface ProcessSegmentStatusLabelOptions<T> {
@@ -32,11 +33,39 @@ function hasLaterFoldableProcessBlock<T>(
return false
}
export function buildProcessSegments<T>(blocks: T[], options: ProcessSegmentOptions<T>): ProcessSegment<T>[] {
function buildTimelineSegments<T>(blocks: T[], isFoldableProcessBlock: (block: T) => boolean): ProcessSegment<T>[] {
const segments: ProcessSegment<T>[] = []
let processBlocks: T[] = []
const flushProcess = () => {
if (processBlocks.length === 0) return
segments.push({ type: 'process', blocks: processBlocks })
processBlocks = []
}
for (const block of blocks) {
if (isFoldableProcessBlock(block)) {
processBlocks.push(block)
continue
}
flushProcess()
segments.push({ type: 'visible', block })
}
flushProcess()
return segments
}
function buildCompletedSegments<T>(
blocks: T[],
isFoldableProcessBlock: (block: T) => boolean,
isTextBlock: (block: T) => boolean
): ProcessSegment<T>[] {
const isProcessBlock = blocks.map(
(block, index) =>
options.isFoldableProcessBlock(block) ||
(options.isTextBlock(block) && hasLaterFoldableProcessBlock(blocks, index, options.isFoldableProcessBlock))
isFoldableProcessBlock(block) ||
(isTextBlock(block) && hasLaterFoldableProcessBlock(blocks, index, isFoldableProcessBlock))
)
const processBlocks = blocks.filter((_block, index) => isProcessBlock[index])
const firstProcessIndex = isProcessBlock.findIndex(Boolean)
@@ -55,6 +84,21 @@ export function buildProcessSegments<T>(blocks: T[], options: ProcessSegmentOpti
return segments
}
export function buildProcessSegments<T>(blocks: T[], options: ProcessSegmentOptions<T>): ProcessSegment<T>[] {
if (options.mode === 'timeline') {
return buildTimelineSegments(blocks, options.isFoldableProcessBlock)
}
return buildCompletedSegments(blocks, options.isFoldableProcessBlock, options.isTextBlock)
}
export function isActiveProcessSegment<T>(
segments: ProcessSegment<T>[],
segmentIndex: number,
isStreaming: boolean
): boolean {
return isStreaming && segmentIndex === segments.length - 1 && segments[segmentIndex]?.type === 'process'
}
export function getVisibleSegmentBlocks<T>(segments: ProcessSegment<T>[]): T[] {
return segments.flatMap((segment) => (segment.type === 'visible' ? [segment.block] : []))
}
@@ -106,6 +150,16 @@ export function getProcessSegmentDurationMs<T>(
return segment.blocks.reduce((total, block) => total + (getBlockDurationMs(block) ?? 0), 0)
}
export function resolveProcessElapsedMs(input: {
isStreaming: boolean
liveElapsedMs: number
settledElapsedMs: number
recordedElapsedMs: number
}): number {
if (input.isStreaming) return input.liveElapsedMs
return input.settledElapsedMs || input.recordedElapsedMs
}
export function getProcessSegmentStatusLabel<T>(
segment: ProcessSegment<T>,
options: ProcessSegmentStatusLabelOptions<T>
+4 -2
View File
@@ -35,7 +35,8 @@ export function useAIChat(
sessionName: string,
timeFilter?: { startTs: number; endTs: number },
chatType: 'group' | 'private' = 'group',
locale: string = 'zh-CN'
locale: string = 'zh-CN',
initialAIChatId?: string | null
) {
const aiChatStore = useAIChatStore()
const { chatKey, state } = aiChatStore.ensureSessionState({
@@ -47,12 +48,13 @@ export function useAIChat(
})
// 每次进入 AI Tab 时确保默认选中助手(从浮动任务条返回时除外)
void aiChatStore.resetToSelectorOnEnter(chatKey)
const initialization = aiChatStore.resetToSelectorOnEnter(chatKey, initialAIChatId)
// 当前可见的 AI 页应恢复自己的助手上下文,避免不同会话之间串助手选择。
aiChatStore.applySessionAssistantSelection(chatKey)
return {
initialization,
messages: toRef(state, 'messages'),
sourceMessages: toRef(state, 'sourceMessages'),
currentKeywords: toRef(state, 'currentKeywords'),
+1
View File
@@ -46,6 +46,7 @@
"label": "Process",
"processing": "Processing",
"processed": "Processed",
"duration": "Took {duration}",
"working": "Working",
"planning": "Planning",
"failed": "Incomplete",
+1
View File
@@ -46,6 +46,7 @@
"label": "実行過程",
"processing": "処理中",
"processed": "処理済み",
"duration": "所要時間 {duration}",
"working": "処理中",
"planning": "計画中",
"failed": "未完了",
+1
View File
@@ -46,6 +46,7 @@
"label": "执行过程",
"processing": "处理中",
"processed": "已处理",
"duration": "耗时 {duration}",
"working": "正在处理",
"planning": "正在规划",
"failed": "未完成",
+1
View File
@@ -46,6 +46,7 @@
"label": "執行過程",
"processing": "處理中",
"processed": "已處理",
"duration": "耗時 {duration}",
"working": "正在處理",
"planning": "正在規劃",
"failed": "未完成",
+2
View File
@@ -23,6 +23,7 @@ export interface AgentStreamChunk {
| 'plan_delta'
| 'plan'
| 'plan_skipped'
| 'turn_end'
| 'done'
| 'error'
content?: string
@@ -41,6 +42,7 @@ export interface AgentStreamChunk {
routeDecision?: RouteDecision
planDelta?: string
plan?: PlanContentBlock
hadToolCalls?: boolean
compressionResult?: {
summaryContent: string
tokensBefore: number
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AIChat {
}
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'text'; text: string; processDurationMs?: number }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| { type: 'evidence'; evidence: ChatEvidencePayload }
+80 -6
View File
@@ -38,7 +38,11 @@ import {
type PlanContentBlock,
type PlanDraftContentBlock,
} from '@/services/ai/planBlocks'
import { toSerializableContentBlocks } from './aiChatContentBlocks'
import {
getPersistedProcessDurationMs,
persistProcessDurationMs,
toSerializableContentBlocks,
} from './aiChatContentBlocks'
import { createToolLifecycleTracker, type ToolStatus } from './aiToolLifecycle'
export type { ToolStatus } from './aiToolLifecycle'
@@ -81,7 +85,7 @@ export interface MentionedMemberContext {
// 内容块类型(用于 AI 消息的流式混合渲染)
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'text'; text: string; processDurationMs?: number }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| { type: 'evidence'; evidence: ChatEvidencePayload }
@@ -115,6 +119,8 @@ export interface ChatMessage {
/** AI 消息的内容块数组(按时序排列的文本和工具调用) */
contentBlocks?: ContentBlock[]
isStreaming?: boolean
/** Runtime wall-clock duration from request start until the final response begins. */
processDurationMs?: number
}
// 搜索结果消息类型(保留用于数据源面板)
@@ -244,13 +250,15 @@ function normalizeSerializedError(error: unknown): SerializedErrorInfo {
}
function toRuntimeMessage(msg: PersistedAIMessage): ChatMessage {
const contentBlocks = msg.contentBlocks as ContentBlock[] | undefined
return {
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp * 1000,
parentId: msg.parentId,
contentBlocks: msg.contentBlocks as ContentBlock[] | undefined,
contentBlocks,
processDurationMs: getPersistedProcessDurationMs(contentBlocks),
}
}
@@ -519,7 +527,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
try {
const conversation = await useAIService().getAIChat(aiChatId)
const buffer = getOrCreateBuffer(state, aiChatId, conversation?.assistantId ?? null)
if (!conversation || conversation.sessionId !== state.sessionId) return false
const buffer = getOrCreateBuffer(state, aiChatId, conversation.assistantId)
if (!buffer.loaded) {
const [history, tokenUsage] = await Promise.all([
@@ -533,7 +543,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
buffer.loaded = true
}
buffer.assistantId = conversation?.assistantId ?? buffer.assistantId ?? null
buffer.assistantId = conversation.assistantId
bindDisplayedBuffer(state, aiChatId)
applySessionAssistantSelection(chatKey)
return true
@@ -568,7 +578,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
* 如果存在有效的记忆助手则直接进入对应助手,否则回到助手选择页。
* 从浮动任务条返回(pendingFocusReturn)时跳过重置以保留对话状态。
*/
async function resetToSelectorOnEnter(chatKey: string): Promise<void> {
async function resetToSelectorOnEnter(chatKey: string, preferredAIChatId?: string | null): Promise<void> {
if (pendingFocusReturn) {
pendingFocusReturn = false
return
@@ -580,6 +590,10 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
await assistantStore.loadAssistants()
}
if (preferredAIChatId && (await loadAIChat(chatKey, preferredAIChatId))) {
return
}
if (!state.selectedAssistantId) {
const defaultId = getDefaultGeneralAssistantId(state.locale)
selectAssistantForSession(chatKey, defaultId)
@@ -648,20 +662,38 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
status: 'done' | 'error',
completion?: { toolCallId?: string; result?: string; displayResult?: string; isError?: boolean }
) => void
completeTurn: (hadToolCalls: boolean) => void
settleProcessDuration: () => void
}
function createStreamBlockHelpers(targetBuffer: AIChatBuffer, getAiMessageIndex: () => number): StreamBlockHelpers {
let currentTurnFirstContentAt: number | undefined
const updateAIMessage = (updates: Partial<ChatMessage>) => {
const idx = getAiMessageIndex()
targetBuffer.messages[idx] = { ...targetBuffer.messages[idx], ...updates }
}
const settleProcessDuration = () => {
const idx = getAiMessageIndex()
const message = targetBuffer.messages[idx]
if (!message || message.processDurationMs !== undefined) return
const durationMs = Math.max(0, Date.now() - message.timestamp)
updateAIMessage({
processDurationMs: durationMs,
contentBlocks: persistProcessDurationMs(message.contentBlocks, durationMs),
})
}
const appendTextToBlocks = (text: string) => {
if (!text) return
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
const lastBlock = blocks[blocks.length - 1]
if (text.trim().length === 0 && (!lastBlock || lastBlock.type !== 'text')) return
if (text.trim().length > 0 && currentTurnFirstContentAt === undefined) {
currentTurnFirstContentAt = Date.now()
}
if (lastBlock && lastBlock.type === 'text') {
lastBlock.text += text
} else {
@@ -670,6 +702,21 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateAIMessage({ contentBlocks: [...blocks], content: targetBuffer.messages[idx].content + text })
}
const completeTurn = (hadToolCalls: boolean) => {
const firstContentAt = currentTurnFirstContentAt
currentTurnFirstContentAt = undefined
if (hadToolCalls || firstContentAt === undefined) return
const idx = getAiMessageIndex()
const message = targetBuffer.messages[idx]
if (!message || message.processDurationMs !== undefined) return
const durationMs = Math.max(0, firstContentAt - message.timestamp)
updateAIMessage({
processDurationMs: durationMs,
contentBlocks: persistProcessDurationMs(message.contentBlocks, durationMs),
})
}
const appendThinkToBlocks = (text: string, tag?: string, durationMs?: number) => {
if (!text && durationMs === undefined) return
const idx = getAiMessageIndex()
@@ -837,6 +884,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
addRenderOnlyToolPendingBlock,
updateRenderOnlyToolResult,
updateToolBlockStatus,
completeTurn,
settleProcessDuration,
}
}
@@ -962,6 +1011,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
addRenderOnlyToolPendingBlock,
updateRenderOnlyToolResult,
updateToolBlockStatus,
completeTurn,
settleProcessDuration,
} = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex)
const currentAssistantId = targetBuffer.assistantId ?? getDefaultGeneralAssistantId(state.locale)
@@ -969,12 +1020,14 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '')
const conversation = await useAIService().createAIChat(state.sessionId, title, currentAssistantId)
if (state.isAborted) {
settleProcessDuration()
updateAIMessage({ isStreaming: false })
clearActiveTask(chatKey, thisRequestId)
return { success: false, reason: 'aborted' }
}
if (state.currentRequestId !== thisRequestId) {
settleProcessDuration()
updateAIMessage({ isStreaming: false })
clearActiveTask(chatKey, thisRequestId)
return { success: false, reason: 'busy', activeTask: activeTask.value }
@@ -1129,6 +1182,10 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
removePlanDraftsFromBlocks()
break
case 'turn_end':
completeTurn(Boolean(chunk.hadToolCalls))
break
case 'status':
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
@@ -1150,6 +1207,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
break
case 'done':
settleProcessDuration()
toolLifecycle.clear()
state.currentToolStatus = null
if (!hasStreamError) updatePlanBlockStatus('done')
@@ -1167,6 +1225,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
break
case 'error':
settleProcessDuration()
removePlanDraftsFromBlocks()
updatePlanBlockStatus('skipped')
if (state.currentToolStatus) {
@@ -1289,6 +1348,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const blocks = lastMessage.contentBlocks || []
blocks.push({ type: 'error', error: errInfo })
lastMessage.contentBlocks = [...blocks]
const durationMs = lastMessage.processDurationMs ?? Math.max(0, Date.now() - lastMessage.timestamp)
lastMessage.processDurationMs = durationMs
lastMessage.contentBlocks = persistProcessDurationMs(lastMessage.contentBlocks, durationMs)
lastMessage.isStreaming = false
// 优先使用当前轮次的用户消息,避免多轮对话取到第一条历史消息
@@ -1571,6 +1633,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
addRenderOnlyToolPendingBlock,
updateRenderOnlyToolResult,
updateToolBlockStatus,
completeTurn,
settleProcessDuration,
} = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex)
try {
@@ -1718,12 +1782,16 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
case 'plan_skipped':
removePlanDraftsFromBlocks()
break
case 'turn_end':
completeTurn(Boolean(chunk.hadToolCalls))
break
case 'status':
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
}
break
case 'done':
settleProcessDuration()
toolLifecycle.clear()
state.currentToolStatus = null
if (!hasStreamError) updatePlanBlockStatus('done')
@@ -1731,6 +1799,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
setAgentPhase(state, 'completed', chunk.usage ? { totalUsage: chunk.usage } : undefined)
break
case 'error': {
settleProcessDuration()
removePlanDraftsFromBlocks()
updatePlanBlockStatus('skipped')
hasStreamError = true
@@ -1795,9 +1864,11 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
serializableContentBlocks,
lastDoneUsage
)
const processDurationMs = targetBuffer.messages[aiMessageIndex].processDurationMs
targetBuffer.messages[aiMessageIndex] = {
...toRuntimeMessage(savedAiMsg),
dataSource: targetBuffer.messages[aiMessageIndex].dataSource,
processDurationMs,
isStreaming: false,
}
targetBuffer.messages[editIndex] = {
@@ -1864,6 +1935,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const runningBuffer = state.aiChatBuffers[runningBufferKey]
const lastMessage = runningBuffer ? runningBuffer.messages[runningBuffer.messages.length - 1] : undefined
if (lastMessage && lastMessage.role === 'assistant' && lastMessage.isStreaming) {
const durationMs = lastMessage.processDurationMs ?? Math.max(0, Date.now() - lastMessage.timestamp)
lastMessage.processDurationMs = durationMs
lastMessage.contentBlocks = persistProcessDurationMs(lastMessage.contentBlocks, durationMs)
lastMessage.isStreaming = false
lastMessage.content += '\n\n_(已停止生成)_'
}
+28 -1
View File
@@ -1,6 +1,33 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { toSerializableContentBlocks } from './aiChatContentBlocks'
import {
getPersistedProcessDurationMs,
persistProcessDurationMs,
toSerializableContentBlocks,
} from './aiChatContentBlocks'
describe('process duration metadata', () => {
it('persists the final processing duration on the final response text', () => {
const blocks = [
{ type: 'text' as const, text: 'interstitial response' },
{
type: 'tool' as const,
tool: { name: 'search_messages', displayName: 'search_messages', status: 'done' as const },
},
{ type: 'text' as const, text: 'final response' },
]
const persisted = persistProcessDurationMs(blocks, 15_000)
assert.equal(getPersistedProcessDurationMs(persisted), 15_000)
assert.deepEqual(persisted, [
blocks[0],
blocks[1],
{ type: 'text', text: 'final response', processDurationMs: 15_000 },
])
assert.equal(getPersistedProcessDurationMs(blocks), undefined)
})
})
describe('toSerializableContentBlocks', () => {
it('drops full displayResult text from persisted tool blocks', () => {
+24
View File
@@ -1,5 +1,29 @@
import type { ContentBlock } from './aiChat'
export function getPersistedProcessDurationMs(blocks: ContentBlock[] | undefined): number | undefined {
if (!blocks) return undefined
for (let index = blocks.length - 1; index >= 0; index -= 1) {
const block = blocks[index]
if (block.type === 'text' && block.processDurationMs !== undefined) {
return block.processDurationMs
}
}
return undefined
}
export function persistProcessDurationMs(
blocks: ContentBlock[] | undefined,
processDurationMs: number
): ContentBlock[] | undefined {
if (!blocks) return undefined
const textIndex = blocks.findLastIndex((block) => block.type === 'text')
if (textIndex < 0) return blocks
return blocks.map((block, index) =>
index === textIndex && block.type === 'text' ? { ...block, processDurationMs } : block
)
}
export function toSerializableContentBlocks(blocks: ContentBlock[] | undefined) {
if (!blocks) return undefined
const cloned = JSON.parse(JSON.stringify(blocks))
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createPinia, setActivePinia } from 'pinia'
import { ref } from 'vue'
test('restores the requested AI chat only when it belongs to the current session', async (t) => {
const aiService = {
getAIChat: async (id: string) =>
id === 'chat-one'
? {
id,
sessionId: 'session-one',
title: 'Saved chat',
assistantId: 'assistant-one',
createdAt: 1,
updatedAt: 1,
}
: null,
getMessages: async () => [
{
id: 'message-one',
aiChatId: 'chat-one',
role: 'assistant' as const,
content: 'Saved answer',
timestamp: 1,
},
],
getAIChatTokenUsage: async () => ({
promptTokens: 1,
completionTokens: 1,
totalTokens: 2,
}),
}
const assistantStore = {
isLoaded: true,
loadAssistants: async () => undefined,
selectAssistant: () => undefined,
clearSelection: () => undefined,
}
await Promise.all([
t.mock.module('@/stores/session', {
namedExports: { useSessionStore: () => ({ sessions: [] }) },
}),
t.mock.module('@/stores/settings', {
namedExports: { useSettingsStore: () => ({ aiPreprocessConfig: {} }) },
}),
t.mock.module('@/stores/assistant', {
namedExports: { useAssistantStore: () => assistantStore },
}),
t.mock.module('@/stores/skill', {
namedExports: { useSkillStore: () => ({ activeSkill: ref(null) }) },
}),
t.mock.module('@/stores/llm', {
namedExports: { useLLMStore: () => ({}) },
}),
t.mock.module('@/services', {
namedExports: {
useAIService: () => aiService,
useDataService: () => ({}),
useLLMService: () => ({}),
},
}),
t.mock.module('@/services/ai-stream/service', {
namedExports: { useAgentStreamService: () => ({}) },
}),
])
setActivePinia(createPinia())
const { useAIChatStore } = await import('./aiChat')
const store = useAIChatStore()
const first = store.ensureSessionState({
sessionId: 'session-one',
sessionName: 'First session',
chatType: 'private',
locale: 'zh-CN',
})
await store.resetToSelectorOnEnter(first.chatKey, 'chat-one')
assert.equal(first.state.currentAIChatId, 'chat-one')
assert.equal(first.state.messages[0]?.content, 'Saved answer')
const second = store.ensureSessionState({
sessionId: 'session-two',
sessionName: 'Second session',
chatType: 'private',
locale: 'zh-CN',
})
await store.resetToSelectorOnEnter(second.chatKey, 'chat-one')
assert.equal(second.state.currentAIChatId, null)
assert.equal(second.state.messages.length, 0)
})