Version: 0.9.53.dev.260429

后端:
1. 流式思考链路从 raw reasoning_content 切到 `thinking_summary` 摘要协议,补齐摘要 prompt、digestor 与 Lite 压缩链路,plan / execute / fallback 统一改为“只出摘要、不透原始推理”,正文开始后自动关停摘要流。
2. thinking_summary 打通 timeline / SSE / outbox 持久化闭环,只落 detail_summary 与必要 metadata,并补强 seq 自检、冲突幂等识别与补 seq 回填,提升重放恢复稳定性。
3. 会话历史口径继续收紧,assistant 正文与时间线不再回写 raw reasoning_content,仅保留正文与思考耗时,避免刷新恢复时再次暴露内部推理文本。

前端:
4. 助手页开始接入 thinking_summary 实时流与历史恢复,补齐短摘要状态、长摘要折叠区、正文开流后自动收口,并增加调试入口用于协议联调与验收。
5. 当前前端助手页仍是残次过渡态,本版先以 thinking_summary 协议接通和基础渲染为主,样式、交互与细节体验暂未收平,下一版集中修复。

仓库:
6. 补充 thinking_summary 对接说明,明确 SSE 协议、timeline 恢复口径与 short/detail summary 的使用边界。
This commit is contained in:
Losita
2026-04-29 01:00:38 +08:00
parent d89e2830a9
commit f81f137791
21 changed files with 8566 additions and 229 deletions

View File

@@ -71,6 +71,16 @@ export interface TaskRecordCardData {
export type BusinessCardType = 'task_query' | 'task_record'
export type TaskRecordSource = 'quick_note' | 'create_task'
export interface TimelineThinkingSummaryPayload {
stage?: string
block_id?: string
display_mode?: 'append'
summary_seq?: number
detail_summary?: string
duration_seconds?: number
final?: boolean
}
export interface TimelineBusinessCardPayload {
card_type: BusinessCardType
title?: string
@@ -92,16 +102,21 @@ export interface TimelineEvent {
| 'interrupt'
| 'status'
| 'business_card'
| 'thinking_summary'
role?: 'user' | 'assistant'
content?: string
payload?: {
reasoning_content?: string
stage?: string
block_id?: string
display_mode?: 'card'
display_mode?: 'card' | 'append'
tool?: TimelineToolPayload
confirm?: TimelineConfirmPayload
business_card?: TimelineBusinessCardPayload
summary_seq?: number
detail_summary?: string
duration_seconds?: number
final?: boolean
}
tokens_consumed?: number
created_at: string

View File

@@ -256,6 +256,7 @@ const unavailableHistoryMap = reactive<Record<string, boolean>>({})
const thinkingMessageMap = reactive<Record<string, boolean>>({})
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
const reasoningStartedAtMap = reactive<Record<string, number>>({})
const reasoningCurrentShortSummaryMap = reactive<Record<string, string>>({})
const reasoningDurationMap = reactive<Record<string, number>>({})
const confirmOnlyStreamMap = reactive<Record<string, boolean>>({})
const confirmVisiblePrefixMap = reactive<Record<string, boolean>>({})
@@ -931,8 +932,8 @@ function appendAssistantReasoningChunk(messageId: string, chunk: string) {
// 记录块级别的起始时间和初始折叠状态
reasoningStartedAtMap[blockId] = Date.now()
reasoningCollapsedMap[blockId] = false
reasoningCollapsedMap[blockId] = true
assistantTimelineLastKindMap[messageId] = 'reasoning'
}
@@ -1130,6 +1131,7 @@ function cleanupHiddenAssistantMessageState(messageId: string) {
delete thinkingMessageMap[messageId]
delete reasoningCollapsedMap[messageId]
delete reasoningStartedAtMap[messageId]
delete reasoningCurrentShortSummaryMap[messageId]
delete reasoningDurationMap[messageId]
delete confirmOnlyStreamMap[messageId]
delete confirmVisiblePrefixMap[messageId]
@@ -1318,8 +1320,24 @@ function syncConversationListItemFromMeta(
}
}
function renderMessageMarkdown(content: string) {
return renderMarkdown(content)
function renderMessageMarkdown(content: string, isStreaming = false) {
let html = renderMarkdown(content)
if (isStreaming) {
const dotHtml = '<span class="thinking-dot-inline"></span>'
// 1. 找到最后一个能容纳行内文本的闭合标签(如 </p>、</li>、</code> 等),
// 并在该标签之前插入圆点,这样圆点就始终位于文字流的末端。
// 2. 需要从后往前搜索,避免匹配到中间段落的闭合标签。
// 3. 如果找不到(纯文本无标签),则直接追加到末尾。
// 4. code 属于行内文本容器pre 属于外层包裹容器,保证代码块场景下圆点深入到代码内部。
const inlineContainerPattern = /<\/(p|li|td|th|h[1-6]|code)>\s*(<\/(ol|ul|table|div|blockquote|pre)>\s*)*$/i
const match = html.match(inlineContainerPattern)
if (match && match.index !== undefined) {
html = html.substring(0, match.index) + dotHtml + html.substring(match.index)
} else {
html += dotHtml
}
}
return html
}
function isStreamingMessage(message: AssistantMessage) {
@@ -1431,6 +1449,11 @@ function markReasoningFinished(blockId: string, messageId: string) {
reasoningDurationMap[blockId] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
}
thinkingMessageMap[messageId] = false
// 若被展开,则思考完毕后自动闭合
if (reasoningCollapsedMap[blockId] === false) {
reasoningCollapsedMap[blockId] = true
}
}
function getReasoningDurationSeconds(blockId: string) {
@@ -1448,13 +1471,15 @@ function getReasoningDurationSeconds(blockId: string) {
}
function getReasoningStatusLabel(block: DisplayAssistantBlock) {
const durationSeconds = getReasoningDurationSeconds(block.id)
if (durationSeconds > 0) {
return `已思考(用时 ${durationSeconds} 秒)`
const isThinking = block.sourceId === activeStreamingMessageId.value && thinkingMessageMap[block.sourceId]
if (isThinking) {
// 状态栏显示当前阶段的短摘要
return reasoningCurrentShortSummaryMap[block.id] || '正在思考...'
}
const isThinking = block.sourceId === activeStreamingMessageId.value && thinkingMessageMap[block.sourceId]
return isThinking ? '思考中' : '已思考'
// 思考结束后,状态栏显示固定文案
return '已完成深度思考'
}
/**
@@ -1635,16 +1660,25 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
})
}
if (shouldShowDisplayAnsweringIndicator(dm)) {
const maxSeq = blocks.length > 0 ? Math.max(...blocks.map((item) => item.seq)) : 0
blocks.push({
id: `${dm.id}:content-indicator`,
type: 'content_indicator',
seq: maxSeq + 1,
})
if (shouldShowDisplayAnsweringIndicator(dm) && blocks.length === 0) {
const maxSeq = blocks.length > 0 ? Math.max(...blocks.map((item) => item.seq)) : 0
blocks.push({
id: `${dm.id}:content-indicator`,
type: 'content_indicator',
seq: maxSeq + 1,
} as any)
}
const sortedBlocks = blocks.sort((left, right) => left.seq - right.seq)
// 核心修复:确保全消息流中只有一个点。
// 只有当整个 DisplayMessage 处于流式状态,且当前块是最后一块时,才标记为 isStreaming。
if (isDisplayStreaming(dm) && sortedBlocks.length > 0) {
const lastBlock = sortedBlocks[sortedBlocks.length - 1] as any
lastBlock.isStreaming = true
}
return blocks.sort((left, right) => left.seq - right.seq)
return sortedBlocks
}
function getToolTraceStateLabel(state: ToolTraceState): string {
@@ -1661,9 +1695,8 @@ function getToolTraceStateLabel(state: ToolTraceState): string {
}
function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
return isDisplayStreaming(dm) &&
dm.sources.every(m => thinkingMessageMap[m.id] !== true) &&
!dm.content.trim()
// 基础判断:处于流式,且还没有任何实质性内容(包括推理和正文)
return isDisplayStreaming(dm) && !dm.content.trim()
}
function getDisplayReasoningStatusLabel(dm: DisplayMessage): string {
@@ -2534,7 +2567,7 @@ function prepareAssistantMessageForStreaming(message: AssistantMessage, createdA
message.reasoning = ''
message.createdAt = createdAt
thinkingMessageMap[message.id] = isManualThinkingEnabled(selectedThinkingMode.value)
reasoningCollapsedMap[message.id] = false
reasoningCollapsedMap[message.id] = true
delete reasoningStartedAtMap[message.id]
delete reasoningDurationMap[message.id]
clearToolTraceState(message.id)
@@ -2914,7 +2947,7 @@ async function sendMessageInternal(options: SendMessageOptions = {}) {
})
thinkingMessageMap[assistantMessage.id] = isManualThinkingEnabled(selectedThinkingMode.value)
reasoningCollapsedMap[assistantMessage.id] = false
reasoningCollapsedMap[assistantMessage.id] = true
activeStreamingMessageId.value = assistantMessage.id
messageInput.value = ''
@@ -2966,7 +2999,7 @@ async function sendMessageInternal(options: SendMessageOptions = {}) {
}
ElMessage.error(error instanceof Error ? error.message : '发送消息失败,请稍后重试')
}
reasoningCollapsedMap[assistantMessage.id] = false
reasoningCollapsedMap[assistantMessage.id] = true
} finally {
streamAbortController.value = null
activeStreamingMessageId.value = ''
@@ -3181,7 +3214,7 @@ onBeforeUnmount(() => {
</div>
</div>
</template>
<div v-else class="chat-message__markdown" v-html="renderMessageMarkdown(dm.content)" />
<div v-else class="chat-message__markdown" v-html="renderMessageMarkdown(dm.content, false)" />
</div>
<div v-if="!isEditingUserMessage(dm.id)" class="chat-message__action-bar chat-message__action-bar--user">
<button
@@ -3207,7 +3240,7 @@ onBeforeUnmount(() => {
</svg>
</button>
</div>
<span class="chat-message__time chat-message__time--user">{{ formatMessageTime(dm.createdAt) }}</span>
</div>
<div v-else class="chat-message__assistant-flow">
@@ -3245,7 +3278,10 @@ onBeforeUnmount(() => {
<div v-else-if="block.type === 'reasoning'" class="chat-message__reasoning">
<div class="chat-message__reasoning-head">
<div class="chat-message__reasoning-title">
<div
class="chat-message__reasoning-title"
:class="{ 'chat-message__reasoning-title--shimmering': activeStreamingMessageId === block.sourceId && thinkingMessageMap[block.sourceId] }"
>
<span class="chat-message__reasoning-icon">
<svg
class="chat-message__reasoning-icon-svg"
@@ -3267,53 +3303,61 @@ onBeforeUnmount(() => {
</svg>
</span>
<span class="chat-message__reasoning-status">{{ getReasoningStatusLabel(block) }}</span>
<button
type="button"
class="chat-message__reasoning-toggle"
:aria-label="isReasoningCollapsed(block.id) ? '展开深度思考' : '折叠深度思考'"
@click="toggleReasoningCollapse(block.id)"
>
<span class="chat-message__reasoning-chevron">
<svg
class="chat-message__reasoning-chevron-icon"
:class="{ 'chat-message__reasoning-chevron-icon--expanded': !isReasoningCollapsed(block.id) }"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M5.5 2.15137L5.92383 2.57617L8.65137 5.30273C8.90706 5.55843 9.13382 5.78438 9.29785 5.98828C9.46883 6.20088 9.61756 6.44405 9.66602 6.75C9.69222 6.91565 9.69222 7.08435 9.66602 7.25C9.61756 7.55595 9.46883 7.79912 9.29785 8.01172C9.13382 8.21561 8.90706 8.44157 8.65137 8.69727L5.92383 11.4238L5.5 11.8486L4.65137 11L5.07617 10.5762L7.80273 7.84863C8.07732 7.57405 8.24849 7.40124 8.3623 7.25977C8.46904 7.12709 8.47813 7.07728 8.48047 7.0625C8.48703 7.02105 8.48703 6.97895 8.48047 6.9375C8.47813 6.92272 8.46904 6.87291 8.3623 6.74023C8.24848 6.59876 8.07732 6.42595 7.80273 6.15137L5.07617 3.42383L4.65137 3L5.5 2.15137Z"
fill="currentColor"
/>
</svg>
</span>
</button>
</div>
<button
type="button"
class="chat-message__reasoning-toggle"
:aria-label="isReasoningCollapsed(block.id) ? '展开深度思考' : '折叠深度思考'"
@click="toggleReasoningCollapse(block.id)"
>
<span class="chat-message__reasoning-chevron">
<svg
class="chat-message__reasoning-chevron-icon"
:class="{ 'chat-message__reasoning-chevron-icon--expanded': !isReasoningCollapsed(block.id) }"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M5.5 2.15137L5.92383 2.57617L8.65137 5.30273C8.90706 5.55843 9.13382 5.78438 9.29785 5.98828C9.46883 6.20088 9.61756 6.44405 9.66602 6.75C9.69222 6.91565 9.69222 7.08435 9.66602 7.25C9.61756 7.55595 9.46883 7.79912 9.29785 8.01172C9.13382 8.21561 8.90706 8.44157 8.65137 8.69727L5.92383 11.4238L5.5 11.8486L4.65137 11L5.07617 10.5762L7.80273 7.84863C8.07732 7.57405 8.24849 7.40124 8.3623 7.25977C8.46904 7.12709 8.47813 7.07728 8.48047 7.0625C8.48703 7.02105 8.48703 6.97895 8.48047 6.9375C8.47813 6.92272 8.46904 6.87291 8.3623 6.74023C8.24848 6.59876 8.07732 6.42595 7.80273 6.15137L5.07617 3.42383L4.65137 3L5.5 2.15137Z"
fill="currentColor"
/>
</svg>
</span>
</button>
</div>
<div v-if="isReasoningCollapsed(block.id) === false" class="chat-message__reasoning-body">
<div
v-if="block.text"
class="chat-message__markdown chat-message__markdown--reasoning"
v-html="renderMessageMarkdown(block.text || '')"
/>
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
<Transition name="reasoning-bounce">
<div v-if="isReasoningCollapsed(block.id) === false" class="chat-message__reasoning-body">
<div
v-if="block.text"
class="chat-message__markdown chat-message__markdown--reasoning"
:class="{ 'chat-message__markdown--streaming': (block as any).isStreaming }"
v-html="renderMessageMarkdown(block.text || '', (block as any).isStreaming)"
/>
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
<div class="thinking-dot"></div>
</div>
</div>
</div>
</Transition>
</div>
<div v-else-if="block.type === 'business_card' && block.businessCard" class="chat-message__business-card">
<BusinessCardRenderer :payload="block.businessCard" />
</div>
<div v-else-if="block.type === 'content'" class="chat-message__assistant-content">
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" />
<div
v-else-if="block.type === 'content'"
class="chat-message__assistant-content"
>
<div
class="chat-message__markdown chat-message__markdown--assistant"
:class="{ 'chat-message__markdown--streaming': (block as any).isStreaming }"
v-html="renderMessageMarkdown(block.text || '', (block as any).isStreaming)"
/>
</div>
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
@@ -3324,9 +3368,7 @@ onBeforeUnmount(() => {
</template>
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
</div>
<div class="thinking-dot"></div>
</div>
</div>
</TransitionGroup>
@@ -3344,7 +3386,7 @@ onBeforeUnmount(() => {
</svg>
</button>
</div>
<span class="chat-message__time">{{ formatMessageTime(dm.createdAt) }}</span>
</div>
</article>
</TransitionGroup>
@@ -3727,6 +3769,7 @@ onBeforeUnmount(() => {
filter: blur(8px);
}
.assistant-shell {
height: 100%;
min-height: 0;
@@ -4891,6 +4934,7 @@ onBeforeUnmount(() => {
align-items: center;
gap: 8px;
color: #5a6577;
position: relative;
}
/* --- Tooling & Selector Beautification --- */
@@ -4966,8 +5010,8 @@ onBeforeUnmount(() => {
}
.chat-message__reasoning-status {
font-size: 13px;
font-weight: 600;
font-size: 15px;
font-weight: 500;
line-height: 1.35;
}
@@ -4975,7 +5019,7 @@ onBeforeUnmount(() => {
width: 16px;
height: 16px;
display: inline-flex;
color: #4f76ea;
color: #94a3b8;
}
.chat-message__reasoning-icon-svg {
@@ -4998,8 +5042,8 @@ onBeforeUnmount(() => {
}
.chat-message__reasoning-toggle:hover {
background: rgba(79, 118, 234, 0.1);
color: #4f76ea;
background: rgba(148, 163, 184, 0.1);
color: #64748b;
}
.chat-message__reasoning-chevron {
@@ -5020,7 +5064,7 @@ onBeforeUnmount(() => {
.chat-message__reasoning-body {
margin: 10px 0 10px 7px;
padding-left: 16px;
border-left: 2px dashed rgba(59, 130, 246, 0.3); /* 改为虚线,更具“思考中”的科技感 */
border-left: 2px dashed rgba(148, 163, 184, 0.4); /* 灰色虚线(同 debug 页) */
font-style: italic;
color: #64748b;
}
@@ -5171,6 +5215,19 @@ onBeforeUnmount(() => {
font-size: 11px;
}
/* 消息流式输出时的右侧呼吸圆点(直接嵌入 HTML */
:deep(.thinking-dot-inline) {
display: inline-block;
width: 8px;
height: 8px;
background-color: #94a3b8;
border-radius: 50%;
margin-left: 8px;
vertical-align: middle;
animation: thinking-pulse 1.5s infinite ease-in-out;
flex-shrink: 0;
}
.assistant-actions {
flex-wrap: wrap;
gap: 8px;
@@ -5368,28 +5425,19 @@ onBeforeUnmount(() => {
border-color: rgba(15, 23, 42, 0.08);
}
.thinking-indicator {
display: inline-flex;
align-items: center;
.thinking-dot {
width: 8px;
height: 8px;
background-color: #94a3b8;
border-radius: 50%;
margin: 0;
animation: thinking-pulse 1.5s infinite ease-in-out;
}
.thinking-indicator__text {
font-size: 15px;
font-weight: 600;
color: #64748b;
background: linear-gradient(
90deg,
#64748b 0%,
#64748b 25%,
#e2e8f0 50%,
#64748b 75%,
#64748b 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: thinking-shimmer 2s infinite linear;
@keyframes thinking-pulse {
0% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.8); opacity: 0.5; }
}
@keyframes thinking-shimmer {
@@ -5543,6 +5591,51 @@ onBeforeUnmount(() => {
max-height: 260px;
}
}
/* 扫光动效:位于标题上的白色光线从左到右划过 */
.chat-message__reasoning-title--shimmering {
overflow: hidden;
}
.chat-message__reasoning-title--shimmering::after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.9),
transparent
);
transform: skewX(-20deg);
animation: shimmer-sweep 1.2s infinite linear;
pointer-events: none;
}
@keyframes shimmer-sweep {
from { left: -150%; }
to { left: 150%; }
}
/* 推理框展开收起弹性动效 */
.reasoning-bounce-enter-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: top center;
}
.reasoning-bounce-leave-active {
transition: all 0.2s ease;
transform-origin: top center;
}
.reasoning-bounce-enter-from,
.reasoning-bounce-leave-to {
opacity: 0;
transform: translateY(-15px);
}
</style>
<style>
/* --- AI 助手确认卡片 & 弹窗高级样式 --- */

View File

@@ -5,6 +5,7 @@ import AuthView from '@/views/AuthView.vue'
import AssistantView from '@/views/AssistantView.vue'
import DashboardView from '@/views/DashboardView.vue'
import ScheduleView from '@/views/ScheduleView.vue'
import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue'
const router = createRouter({
history: createWebHistory(),
@@ -55,6 +56,11 @@ const router = createRouter({
name: 'debug-tool-cards',
component: () => import('@/views/debug/ToolCardMockPage.vue'),
},
{
path: '/debug/assistant/:id?',
name: 'debug-assistant',
component: AssistantReasoningDebug,
},
],
})

View File

@@ -97,6 +97,31 @@ export interface ConversationMeta {
status: string
}
export interface ThinkingSummaryPayload {
summary_seq?: number
short_summary?: string
detail_summary?: string
final?: boolean
duration_seconds?: number
}
export interface ThinkingSummaryBlock {
key: string
stage?: string
blockId?: string
latestSeq: number
globalSeq: number
latestShort: string
details: Array<{
seq: number
text: string
durationSeconds?: number
final?: boolean
}>
active: boolean
collapsed: boolean
}
export interface AssistantMessage {
id: string
role: 'user' | 'assistant' | 'system'
@@ -104,6 +129,7 @@ export interface AssistantMessage {
createdAt: string
reasoning?: string
extra?: any
thinkingSummaryBlocks?: ThinkingSummaryBlock[]
}
export type ThinkingModeType = 'auto' | 'true' | 'false'
@@ -130,6 +156,7 @@ export interface ChatStreamRequest {
model?: string
thinking?: ThinkingModeType
extra?: ChatRequestExtra
thinking_summary?: ThinkingSummaryPayload
}
export interface HybridScheduleEntry {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import AssistantPanelDebug from './AssistantPanelDebug.vue'
</script>
<template>
<div class="assistant-debug-page">
<AssistantPanelDebug class="assistant-debug-panel" view-mode="standalone" />
</div>
</template>
<style scoped>
.assistant-debug-page {
width: 100vw;
height: 100vh;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
}
.assistant-debug-panel {
width: 100%;
height: 100%;
}
</style>