From a4b5b549d3a72bbdf71fcbba85e795c9d8db6503 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Wed, 25 Mar 2026 11:52:14 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.7.7.dev.260325=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=20=E5=AF=B9=E4=B8=BB=E9=A1=B5=E5=81=9A?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E4=BA=9B=E6=94=B9=E8=BF=9B=EF=BC=8C=E4=BD=86?= =?UTF-8?q?=E6=98=AF=E4=BE=9D=E7=84=B6=E5=AD=98=E5=9C=A8=E8=AE=B8=E5=A4=9A?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/AssistantPanel.vue | 1198 ++++++++--------- .../components/dashboard/TodayTimeline.vue | 101 +- frontend/src/utils/markdown.ts | 11 +- frontend/src/views/DashboardView.vue | 59 +- 4 files changed, 687 insertions(+), 682 deletions(-) diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 948d6b0..f6091f0 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -35,10 +35,15 @@ interface StreamErrorPayload { interface StreamEventPayload { choices?: StreamChoicePayload[] + delta?: StreamDeltaPayload + content?: string + reasoning_content?: string + finish_reason?: string | null error?: StreamErrorPayload } const authStore = useAuthStore() + const assistantBodyRef = ref(null) const messageViewportRef = ref(null) @@ -50,7 +55,7 @@ const selectedConversationId = ref('') const selectedModel = ref<'worker' | 'strategist'>('worker') const thinkingEnabled = ref(false) const messageInput = ref('') -const historyPanelWidth = ref(220) +const historyPanelWidth = ref(228) const activeStreamingMessageId = ref('') const conversationPage = ref(1) @@ -63,14 +68,19 @@ const conversationMetaMap = reactive>({}) const conversationMessagesMap = reactive>({}) const unavailableHistoryMap = reactive>({}) const thinkingMessageMap = reactive>({}) +const reasoningCollapsedMap = reactive>({}) -const quickActions = ['帮我梳理今天最重要的三件事', '把当前任务拆成可执行步骤', '总结这段对话的关键结论', '给我一个更稳妥的推进方案'] -const capabilityPoints = ['流式输出', 'Markdown 渲染', '会话懒加载', '深度思考展示'] +const quickActions = [ + '帮我梳理今天最重要的三件事', + '把当前任务拆成可执行步骤', + '总结这段对话的关键结论', + '给我一个更稳妥的推进方案', +] let messageScrollRaf = 0 const assistantBodyStyle = computed(() => ({ - '--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 64}px`, + '--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`, })) const selectedConversation = computed(() => @@ -81,13 +91,12 @@ const selectedMessages = computed(() => { if (!selectedConversationId.value) { return [] } - return conversationMessagesMap[selectedConversationId.value] ?? [] }) const selectedConversationTitle = computed(() => { if (!selectedConversationId.value) { - return '新的会话' + return '新对话' } const meta = conversationMetaMap[selectedConversationId.value] @@ -105,7 +114,7 @@ const selectedConversationTitle = computed(() => { const selectedConversationSubtitle = computed(() => { if (!selectedConversationId.value) { - return '支持流式输出、Markdown 渲染与深度思考展示' + return '发送后立即上屏,思考流和正文流会连续更新' } const meta = conversationMetaMap[selectedConversationId.value] @@ -135,19 +144,89 @@ function ensureConversationBucket(conversationId: string) { function appendConversationMessage(conversationId: string, message: AssistantMessage) { ensureConversationBucket(conversationId) - conversationMessagesMap[conversationId].push(message) - thinkingMessageMap[message.id] = Boolean(message.reasoning?.trim()) + const bucket = conversationMessagesMap[conversationId] + bucket.push(message) + const appended = bucket[bucket.length - 1]! + thinkingMessageMap[appended.id] = Boolean(appended.reasoning?.trim()) + return appended +} + +function createDraftConversationId() { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function createMessageId(role: AssistantMessage['role']) { + return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function isDraftConversationId(conversationId: string) { + return conversationId.startsWith('draft-') } function upsertConversationMeta(meta: ConversationMeta) { conversationMetaMap[meta.conversation_id] = meta } +// migrateConversationState 负责把“本地 draft 会话”迁移成后端返回的真实会话 ID。 +// 职责边界: +// 1. 先迁移消息、元信息、异常状态,再切换当前选中会话,避免流式过程中界面抖动。 +// 2. 若列表里同时存在 draft 和真实会话,则按真实会话 ID 去重,保留较新的字段。 +// 3. 这里只处理前端状态搬迁,不额外发网络请求;标题和条数的最终修正仍交给后续 meta/list 刷新。 +function migrateConversationState(fromConversationId: string, toConversationId: string) { + if (!fromConversationId || !toConversationId || fromConversationId === toConversationId) { + return + } + + if (conversationMessagesMap[fromConversationId]) { + conversationMessagesMap[toConversationId] = + conversationMessagesMap[toConversationId] ?? conversationMessagesMap[fromConversationId] + delete conversationMessagesMap[fromConversationId] + } + + if (typeof unavailableHistoryMap[fromConversationId] !== 'undefined') { + unavailableHistoryMap[toConversationId] = unavailableHistoryMap[fromConversationId] + delete unavailableHistoryMap[fromConversationId] + } + + if (conversationMetaMap[fromConversationId]) { + conversationMetaMap[toConversationId] = { + ...conversationMetaMap[fromConversationId], + conversation_id: toConversationId, + } + delete conversationMetaMap[fromConversationId] + } + + const latestMap = new Map() + const deduplicated: ConversationListItem[] = [] + const seen = new Set() + + for (const item of conversationList.value) { + const nextItem = + item.conversation_id === fromConversationId ? { ...item, conversation_id: toConversationId } : item + latestMap.set(nextItem.conversation_id, nextItem) + } + + for (const item of conversationList.value) { + const nextId = item.conversation_id === fromConversationId ? toConversationId : item.conversation_id + if (seen.has(nextId)) { + continue + } + seen.add(nextId) + deduplicated.push(latestMap.get(nextId)!) + } + + conversationList.value = deduplicated + + if (selectedConversationId.value === fromConversationId) { + selectedConversationId.value = toConversationId + } +} + // mergeConversationList 负责把分页拿到的会话列表按会话 ID 合并进本地状态。 // 职责边界: // 1. 负责“保留现有顺序 + 更新最新字段 + 去重”,避免懒加载时列表闪烁。 // 2. 不负责决定选中哪个会话,选中逻辑交给 ensureSelectedConversationAfterListLoad。 -// 3. 若后端返回重复项,以后出现的记录覆盖前面的字段,保证时间和标题尽量新。 +// 3. 本地尚未完成 round-trip 的 draft 会话会原样保留,避免用户发出首条消息后列表瞬间丢失。 function mergeConversationList(items: ConversationListItem[]) { const merged = [...conversationList.value, ...items] const latestMap = new Map() @@ -181,7 +260,10 @@ function prependConversationPreview(conversationId: string, previewText: string, created_at: current?.created_at || createdAt, } - conversationList.value = [nextItem, ...conversationList.value.filter((item) => item.conversation_id !== conversationId)] + conversationList.value = [ + nextItem, + ...conversationList.value.filter((item) => item.conversation_id !== conversationId), + ] } function normalizeHistoryMessage(message: ConversationHistoryMessage, index: number): AssistantMessage { @@ -195,6 +277,7 @@ function normalizeHistoryMessage(message: ConversationHistoryMessage, index: num } thinkingMessageMap[id] = Boolean(message.reasoning_content?.trim()) + reasoningCollapsedMap[id] = Boolean(message.reasoning_content?.trim()) return normalized } @@ -210,9 +293,21 @@ function isThinkingMessage(message: AssistantMessage) { return thinkingMessageMap[message.id] === true } -function shouldShowReasoningBox(message: AssistantMessage) { - return message.role === 'assistant' && (Boolean(message.reasoning?.trim()) || (isStreamingMessage(message) && isThinkingMessage(message))) +function isReasoningCollapsed(messageId: string) { + return reasoningCollapsedMap[messageId] === true } + +function toggleReasoningCollapse(messageId: string) { + reasoningCollapsedMap[messageId] = !reasoningCollapsedMap[messageId] +} + +function shouldShowReasoningBox(message: AssistantMessage) { + return message.role === 'assistant' && ( + Boolean(message.reasoning?.trim()) || + (isStreamingMessage(message) && isThinkingMessage(message)) + ) +} + function scheduleScrollMessagesToBottom(smooth = false) { if (messageScrollRaf) { cancelAnimationFrame(messageScrollRaf) @@ -241,9 +336,9 @@ async function ensureSelectedConversationAfterListLoad() { // loadConversationListData 负责按页读取会话列表,并驱动首屏选中与懒加载状态。 // 职责边界: -// 1. reset=true 时重置分页并重新获取第一页,适合新消息发送后刷新列表顺序。 +// 1. reset=true 时重置分页并重新获取第一页,适合新消息发送完成后刷新标题和时间。 // 2. reset=false 时只在还有更多数据且当前不在加载时继续拉下一页,避免重复请求。 -// 3. 接口失败时只提示并保留现有列表,防止用户当前聊天内容被清空。 +// 3. 接口失败时保留现有列表,不清空本地草稿会话,防止用户当前上下文丢失。 async function loadConversationListData(reset = false) { if (reset) { conversationPage.value = 1 @@ -264,12 +359,10 @@ async function loadConversationListData(reset = false) { status: 'active', }) - const currentItems = result?.list ?? [] if (reset) { - conversationList.value = currentItems - } else { - mergeConversationList(currentItems) + conversationList.value = conversationList.value.filter((item) => isDraftConversationId(item.conversation_id)) } + mergeConversationList(result?.list ?? []) conversationHasMore.value = Boolean(result?.has_more) conversationPage.value += 1 @@ -297,9 +390,9 @@ function handleHistoryScroll(event: Event) { // startResizeHistoryPanel 负责处理会话列表与聊天主区之间的横向拖拽。 // 职责边界: -// 1. 只负责更新内部历史面板宽度,不修改整个 Dashboard 的左右布局。 -// 2. 为聊天区保留最小宽度,避免拖拽后消息正文被压到无法阅读。 -// 3. 拖拽结束后必须解绑事件并清理全局样式,避免页面残留 col-resize 状态。 +// 1. 只负责更新助手面板内部的历史区宽度,不修改外层 Dashboard 的左右二分布局。 +// 2. 会为正文区保留最小阅读宽度,避免把长回答挤压到难以阅读。 +// 3. 拖拽结束后统一解绑事件并清理全局样式,防止页面残留 col-resize 状态。 function startResizeHistoryPanel(event: PointerEvent) { const body = assistantBodyRef.value if (!body || window.innerWidth <= 960 || !historyExpanded.value) { @@ -312,7 +405,7 @@ function startResizeHistoryPanel(event: PointerEvent) { const handlePointerMove = (moveEvent: PointerEvent) => { const deltaX = moveEvent.clientX - startX - const minHistoryWidth = 180 + const minHistoryWidth = 188 const minChatWidth = 420 const splitterWidth = 8 const maxHistoryWidth = rect.width - splitterWidth - minChatWidth @@ -335,7 +428,11 @@ function toggleHistoryPanel() { } async function loadConversationMessages(conversationId: string) { - if (!conversationId || conversationMessagesMap[conversationId]) { + if (!conversationId) { + return + } + + if (conversationMessagesMap[conversationId] && unavailableHistoryMap[conversationId] !== true) { return } @@ -350,7 +447,7 @@ async function loadConversationMessages(conversationId: string) { } async function ensureConversationMeta(conversationId: string) { - if (!conversationId || conversationMetaMap[conversationId]) { + if (!conversationId || isDraftConversationId(conversationId) || conversationMetaMap[conversationId]) { return } @@ -358,9 +455,8 @@ async function ensureConversationMeta(conversationId: string) { const meta = await getConversationMeta(conversationId) upsertConversationMeta(meta) } catch { - // 这里故意静默失败: // 1. 标题和条数属于增强信息,不应阻塞聊天主链路。 - // 2. 即使元信息失败,列表里已有的回退标题仍可保证界面可用。 + // 2. 即使元信息失败,列表里的回退标题仍可保证界面可用。 // 3. 后续再次刷新列表或重新进入会话时,还会有机会补齐这些字段。 } } @@ -377,7 +473,7 @@ function startNewConversation() { activeStreamingMessageId.value = '' } -// fetchChatStream 负责以 fetch 方式发起聊天请求并处理一次 refresh token 自动重试。 +// fetchChatStream 负责以 fetch 方式发起聊天请求,并处理一次 refresh token 自动重试。 // 职责边界: // 1. 只负责把请求发出去并返回原始 Response,不在这里解析 SSE 数据。 // 2. 401 时优先尝试用 refresh token 换新 access token,并只重试一次,避免死循环。 @@ -407,8 +503,8 @@ async function fetchChatStream(body: ChatStreamRequest, attempt = 0): Promise line.trim()) .filter((line) => line.startsWith('data:')) + .map((line) => line.replace(/^data:\s*/, '')) - for (const line of dataLines) { - const payload = line.replace(/^data:\s*/, '') - if (!payload || payload === '[DONE]') { - continue - } - - let parsed: StreamEventPayload - try { - parsed = JSON.parse(payload) as StreamEventPayload - } catch { - continue - } - - if (parsed.error?.message) { - throw new Error(parsed.error.message) - } - - const choice = parsed.choices?.[0] - const delta = choice?.delta - - if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content) { - assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}` - thinkingMessageMap[assistantMessage.id] = true - } - - if (typeof delta?.content === 'string' && delta.content) { - assistantMessage.content += delta.content - } - - if (choice?.finish_reason) { - activeStreamingMessageId.value = '' - } - - scheduleScrollMessagesToBottom(false) + if (dataLines.length === 0) { + return } + + const payload = dataLines.join('\n').trim() + if (!payload) { + return + } + + if (payload === '[DONE]') { + activeStreamingMessageId.value = '' + reasoningCollapsedMap[assistantMessage.id] = true + return + } + + let parsed: StreamEventPayload + try { + parsed = JSON.parse(payload) as StreamEventPayload + } catch { + return + } + + if (parsed.error?.message) { + throw new Error(parsed.error.message) + } + + const choice = parsed.choices?.[0] + const delta = choice?.delta ?? parsed.delta ?? parsed + const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null + + if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content) { + assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}` + thinkingMessageMap[assistantMessage.id] = true + } + + if (typeof delta?.content === 'string' && delta.content) { + assistantMessage.content += delta.content + } + + if (finishReason) { + activeStreamingMessageId.value = '' + reasoningCollapsedMap[assistantMessage.id] = true + } + + scheduleScrollMessagesToBottom(false) } +// sendMessage 负责执行“本地先上屏,再异步接流”的发送链路。 +// 职责边界: +// 1. 先创建用户消息和 assistant 占位消息,让发送动作立即反馈到界面,等待建连过程无感化。 +// 2. 若当前是新会话,则先使用 draft 会话承接本地状态,等响应头返回真实 conversation_id 后再整体迁移。 +// 3. 网络错误只中断当前这轮 assistant 占位,不回滚用户已发送的内容,避免“点了发送却像没发出去”。 async function sendMessage(preset?: string) { const text = (preset ?? messageInput.value).trim() if (!text || chatLoading.value) { @@ -478,55 +591,56 @@ async function sendMessage(preset?: string) { } chatLoading.value = true - let assistantMessage: AssistantMessage | null = null + + const draftConversationId = selectedConversationId.value || createDraftConversationId() + if (!selectedConversationId.value) { + selectedConversationId.value = draftConversationId + } + + ensureConversationBucket(draftConversationId) + unavailableHistoryMap[draftConversationId] = false + + const now = new Date().toISOString() + appendConversationMessage(draftConversationId, { + id: createMessageId('user'), + role: 'user', + content: text, + createdAt: now, + }) + + const assistantMessage = appendConversationMessage(draftConversationId, { + id: createMessageId('assistant'), + role: 'assistant', + content: '', + createdAt: now, + reasoning: '', + }) + + thinkingMessageMap[assistantMessage.id] = thinkingEnabled.value + reasoningCollapsedMap[assistantMessage.id] = false + activeStreamingMessageId.value = assistantMessage.id + + messageInput.value = '' + prependConversationPreview(draftConversationId, text, now) + scheduleScrollMessagesToBottom(false) try { const response = await fetchChatStream({ - conversation_id: selectedConversationId.value || undefined, + conversation_id: isDraftConversationId(draftConversationId) ? undefined : draftConversationId, message: text, model: selectedModel.value, thinking: thinkingEnabled.value, }) - const conversationId = - response.headers.get('X-Conversation-ID')?.trim() || selectedConversationId.value || `draft-${Date.now()}` + const responseConversationId = response.headers.get('X-Conversation-ID')?.trim() + const actualConversationId = responseConversationId || draftConversationId - if (!selectedConversationId.value) { - selectedConversationId.value = conversationId - unavailableHistoryMap[conversationId] = false + if (actualConversationId !== draftConversationId) { + migrateConversationState(draftConversationId, actualConversationId) + prependConversationPreview(actualConversationId, text, now) } - ensureConversationBucket(conversationId) - - const now = new Date().toISOString() - appendConversationMessage(conversationId, { - id: `user-${Date.now()}`, - role: 'user', - content: text, - createdAt: now, - }) - - assistantMessage = { - id: `assistant-${Date.now()}`, - role: 'assistant', - content: '', - createdAt: now, - reasoning: '', - } - appendConversationMessage(conversationId, assistantMessage) - thinkingMessageMap[assistantMessage.id] = thinkingEnabled.value - activeStreamingMessageId.value = assistantMessage.id - - messageInput.value = '' - prependConversationPreview(conversationId, text, now) - scheduleScrollMessagesToBottom(false) - - const responseBody = response.body - if (!responseBody) { - throw new Error('流式响应体为空,无法继续接收消息') - } - - const reader = responseBody.getReader() + const reader = response.body!.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' @@ -537,7 +651,7 @@ async function sendMessage(preset?: string) { } buffer += decoder.decode(value, { stream: true }) - const blocks = buffer.split('\n\n') + const blocks = buffer.split(/\r?\n\r?\n/) buffer = blocks.pop() ?? '' for (const block of blocks) { @@ -550,25 +664,19 @@ async function sendMessage(preset?: string) { processSseBlock(buffer, assistantMessage) } - if (!assistantMessage.content.trim() && assistantMessage.reasoning?.trim()) { - assistantMessage.content = '已完成深度思考,但当前响应未返回正文内容。' + if (!assistantMessage.content.trim()) { + assistantMessage.content = assistantMessage.reasoning?.trim() + ? '已完成深度思考,但当前响应未返回正文内容。' + : '暂未收到回复正文,请稍后重试。' } await loadConversationListData(true) - - try { - const meta = await getConversationMeta(conversationId) - upsertConversationMeta(meta) - } catch { - // 这里保持静默兜底: - // 1. 会话元信息失败不影响当前已经拿到的正文与历史消息。 - // 2. 列表刷新后仍可继续点击、继续追问,不阻断主流程。 - // 3. 等后端元信息接口稳定后,重新进入页面即可自然补齐。 - } + await ensureConversationMeta(actualConversationId) } catch (error) { - if (assistantMessage && !assistantMessage.content.trim()) { + if (!assistantMessage.content.trim()) { assistantMessage.content = '本次回复已中断,请稍后重试。' } + reasoningCollapsedMap[assistantMessage.id] = false ElMessage.error(error instanceof Error ? error.message : '发送消息失败,请稍后重试') } finally { activeStreamingMessageId.value = '' @@ -597,19 +705,14 @@ onBeforeUnmount(() => { - +
-
-
-

{{ selectedConversationTitle }}

-

{{ selectedConversationSubtitle }}

-
- -
- - {{ chatLoading ? '流式输出中' : '待命中' }} - -
-
- -
- - {{ point }} - -
- 当前会话的历史消息接口暂时不可用,但你仍然可以继续在这个会话里追问;等接口补齐后,历史内容会自动恢复。 + 当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
从这里开始和 AI 协作 -

你可以直接输入问题,也可以点下方快捷操作。回答支持流式输出,长内容会按 Markdown 渲染。

+

右侧采用更接近 DeepSeek 的阅读式布局,只保留用户气泡,AI 回复直接按正文流展示。

{ class="chat-message" :class="`chat-message--${message.role}`" > -
- {{ message.role === 'assistant' ? 'AI' : message.role === 'user' ? '我' : '系统' }} +
+
+
+
+ {{ formatMessageTime(message.createdAt) }}
-
+ +
- - {{ isStreamingMessage(message) ? '深度思考中' : '思考过程' }} +
+ + {{ isStreamingMessage(message) ? '深度思考中' : '深度思考' }} +
+
-
-
- 正在接收 reasoning 增量... -
- - - +
+
+
+ 正在接收 reasoning 增量... +
+ + + +
-
-
- {{ message.reasoning ? '正在生成正文内容...' : '正在建立流式输出...' }} +
+
+
+
+ {{ message.reasoning ? '正在生成正文内容...' : '正在建立连接...' }}
@@ -778,7 +863,7 @@ onBeforeUnmount(() => {