mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): refine chat process experience
This commit is contained in:
1 parent
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':
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"label": "Process",
|
||||
"processing": "Processing",
|
||||
"processed": "Processed",
|
||||
"duration": "Took {duration}",
|
||||
"working": "Working",
|
||||
"planning": "Planning",
|
||||
"failed": "Incomplete",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"label": "実行過程",
|
||||
"processing": "処理中",
|
||||
"processed": "処理済み",
|
||||
"duration": "所要時間 {duration}",
|
||||
"working": "処理中",
|
||||
"planning": "計画中",
|
||||
"failed": "未完了",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"label": "执行过程",
|
||||
"processing": "处理中",
|
||||
"processed": "已处理",
|
||||
"duration": "耗时 {duration}",
|
||||
"working": "正在处理",
|
||||
"planning": "正在规划",
|
||||
"failed": "未完成",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"label": "執行過程",
|
||||
"processing": "處理中",
|
||||
"processed": "已處理",
|
||||
"duration": "耗時 {duration}",
|
||||
"working": "正在處理",
|
||||
"planning": "正在規劃",
|
||||
"failed": "未完成",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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_(已停止生成)_'
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user