Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/agents/ai-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ description: AI 对话编排领域。任务涉及编排器 AgentOrchestrator、T
- **仍然存在的已知局限(刻意没做)**:① 埋点 `TelemetryTurnTracker` 仍按 conversationId 记,被取代那一轮的 `ai.turn` 不会闭合;② 跨进程重启的 `AgentRunRecoveryService` 仍按 conversationId 回收(重启后进程内一个 RunGuard 都不剩,`agent_run_record` 每会话一行仍然正确,加 runId 列没有消费者);③ 旧轮次收尾时仍会执行 `editorBridgeService.setStreamingMode(cid, false)`,把新轮次正在进行的编辑器流式写入模式一起关掉。**这里刻意没加闸**:流式模式是工具打开、收尾关闭的会话级开关,给旧轮次加闸只会换成「旧轮次开的流式模式永远不关」这种更难查的泄漏;正确修法是把流式模式本身做成轮次级状态,属 ai-doc-bridge 领域。

**前端消费**
- **对话分层与常驻进度(2026-09-10,dev-board#584)**:`ChatInterface` 通过 `AgentMessage/chatTurns.mjs` 按 USER 分轮,保留原 bubbles 与全局索引。主消息中的 `RootBubble` 使用 `hideActivity`,只保留正文/产物/反问/停止提示;每轮一个入口打开 `TurnActivityPanel`(任务、模型返回的思考记录、完整执行记录、轮次定位)。固定入口在消息滚动区外,浮层内部限高滚动,待回答/审批有常驻定位按钮;后台任务取消仍走原来的输入区与 BackgroundTaskIndicator,不混用生命周期。
- 任务跨轮继承直到 todo_write 覆写,不能在 sendMessage 清空全局 planTodos;新 assistant bubble 复制当前快照。历史从成功的 JSON todo_write 参数恢复快照,无法解析的旧格式保留原执行记录,不猜造任务。历史/暂停清单的 in_progress 是最后记录状态,TodoProgressCard `live=false` 不显示动态执行文案或转圈。
- `useChatReadingPosition` 观察实际消息内容高度(ResizeObserver),仅在 followLatest 时贴底。向上阅读/定位历史暂停跟随,点击“回到最新”恢复;禁止重新加 bubbles 全树 deep watcher。导航仅滚动本聊天容器。
- 历史 root thinking/final 在剥离 process 后全量提取,保留多段回复;嵌套 thinking 留在对应 process。此改动不重写 bubble_start 或服务端消息持久化边界。`input_applied` 切换 assistant 段前必须先 `flushRemainingBuffer()`,否则上一段无标签正文会被 resetParser 静默丢弃(question-stream.test.mjs 定向回归)。
- 回归:`npm run test:project-home`、`npm run test:tag-protocol`;浏览器真实 Vue 组件 + 合成历史/SSE 边界夹具:`node tests/chat-presentation-ui/run.mjs`(Chrome 路径可用 CHROME_PATH 覆盖;无需真实模型或业务数据)。
- `frontend/src/composables/useAgentStream.js`(1233 行)— SSE 核心:connectSSE(fetch+ReadableStream,非 EventSource)、sendMessage、abort、handleEvent(~:352 分派)、handleTag/processTextDelta(XML 标签驱动气泡组装)、handleStateRecovery。
- `frontend/src/components/ChatInterface.vue`(3620 行)+ `AgentMessage/`(RootBubble/ProcessCard/ThinkingCard/TodoProgressCard/WalkthroughCard/TitleCard/**QuestionCard**/**SubtaskResultCard**)。
- **反问卡(`AgentMessage/QuestionCard.vue`)**:`<question>`/`<option>` 由 useAgentStream 解析成气泡上的 `question={text,options,answered}`,**反问正文不进 `bubble.content`**——卡片不接这个字段等于正文对用户不可见。`<option>` 只在 question 作用域内当标签(正文里的字面量 `<option>` 不造问题卡)。卡片挂在 RootBubble 的 main-content 之后,正文为空时只渲染选项(兼容正文仍在 content 的旧格式,防显示两遍);可操作性沿用 `isLatest && !isStreaming && !answered` 那条链;`RootBubble.isReady/hasContent` **必须把「只有 question」也算作有可见产出**,漏了整条气泡会卡在 ghost thinking 态。点选项 = `sendMessage({prompt: 选项原文})`,**不拼「我选择了 X」**(契约 D:选项本来就短、像用户自己打的)。
Expand Down
35 changes: 35 additions & 0 deletions docs/AI_CHAT_PRESENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# AI 对话内容分层与展示

2026-09-10 · dev-board #584

## 对标与差距

Claude Code Desktop 的 Normal 模式折叠工具调用、保留回复全文;Verbose 展示全部过程;Summary 聚焦最终回复和变更。[官方视图说明](https://code.claude.com/docs/en/desktop#switch-view-modes)。Claude 对思考说明提供可展开区域,而不是与正文等权连续显示。[官方思考设置说明](https://support.claude.com/en/articles/8664678-change-the-model-effort-and-thinking-settings)。

本机 Codex 152 协议将 assistant commentary/final_answer 与 Plan、Reasoning、CommandExecution、FileChange、McpToolCall、SubAgentActivity 分开建模。它是分类设计的本机证据,不代表其他版本界面完全相同。OpenAI Responses 也分别建模回复、工具项、reasoning summary 和生命周期状态:[官方 API](https://developers.openai.com/api/reference/cli/resources/responses/methods/create)。

WorkDeck 已有思考、过程、工具结果、任务、产物和问题卡,但任务与思考都在消息流内,过程组在运行时展开,没有固定定位入口;自动滚动只观察气泡数量,内容增长时不能可靠跟随。历史解析还只保留首个 final。

## 实现

| 内容 | 默认展示 | 查看细节 |
| --- | --- | --- |
| 回复及成果 | 留在主对话,回复单独标识 | 文档使用操作及原产物入口保留 |
| 问题与审批 | 主对话保留可操作卡片 | 顶部“待你处理”直接定位 |
| 任务清单 | 顶部显示完成数/总数,运行中显示当前工作 | 展开任务页签,状态原位更新 |
| 思考记录 | 顶部计数 | 只展示服务端实际返回的说明,不生成或声称取得隐藏推理 |
| 工具与执行记录 | 每轮一个入口及顶部执行数量 | 限高浮层,原工具输出和子任务结果仍可核验 |
| 长对话 | 顶部定位入口 | 跳到某轮请求、回复起点或待处理卡 |
| 系统状态 | 顶部状态文字 | 区分等待应用、运行、等待回答、等待确认、暂停、中断、停止、错误和历史记录 |

已基于主分支 6c5c5930 整合插话、任务队列、记忆入口与可选组件下载。待应用的插话单独标为等待应用,前一轮仍显示实际运行/出错状态。插话切换消息段前先冲出解析缓冲,避免未带标签的上一段回复丢失;该场景已先复现失败再修复。消息存储/SSE 协议不变,展示按 USER 分轮投影;正文、成果、问题卡不会因处于中间轮次被丢弃。任务按后端既有约定跨轮保留,直到工具更新;历史从已成功的 JSON todo_write 参数恢复。不可解析的旧调用仍能查看原文,但不猜造任务清单。历史未完成项仅代表最后记录状态,不持续转圈。

滚动跟随基于实际渲染高度,不遍历整棵历史消息。读者上滚时暂停跟随;点击回到最新后恢复。浮层在本聊天容器内,Esc/点外部可关闭,定位后收起,不挡输入区。

## 验证与范围

- 467 项前端回归、8 项标签协议测试通过。
- 真实 Vue 组件浏览器夹具覆盖历史多段回复、任务恢复、工具结果展开、连续输出贴底、上滚不抢位置、回复/待处理定位、问题/审批保留、320/420/640px、中英文与深色主题。
- emits、locale 对拍、全量导航检查通过;H5 生产构建通过(既有 Sass/分包提示)。
- 浏览器夹具使用合成内容和模拟网络,没有调用真实模型、写业务数据或验证安装包内完整业务链路。
- 不调整服务端 assistant 消息分段/持久化方式,不新增模型调用,不改后台任务取消逻辑。本次交付为代码变更,尚未发版到已安装客户端。
24 changes: 18 additions & 6 deletions frontend/src/components/AgentMessage/RootBubble.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div class="root-bubble-wrapper">

<!-- GHOST STATE: Only Show Thinking if not ready -->
<div v-if="!isReady && bubble.thinking.status === 'thinking'" class="ghost-thinking">
<div v-if="!hideActivity && !isReady && bubble.thinking.status === 'thinking'" class="ghost-thinking">
<ThinkingCard
:status="bubble.thinking.status"
:duration="bubble.thinking.duration"
Expand All @@ -17,7 +17,7 @@
<!-- ACTIVE STATE: Full Card -->
<div v-else class="active-bubble-wrapper">
<!-- 1. Thinking Card (Moved Out as Ghost) -->
<div class="ghost-thinking-wrapper">
<div v-if="!hideActivity" class="ghost-thinking-wrapper">
<ThinkingCard
:status="bubble.thinking.status"
:duration="bubble.thinking.duration"
Expand All @@ -34,14 +34,14 @@
<!-- 2b. 计划卡(线性时序结构):思考结束、制定计划后先展示计划框,
下方才是各步骤的执行进展;每步内部再嵌工具调用记录。
快照挂在气泡上(plan_update 时写入),历史消息各自保留当轮计划。 -->
<div v-if="bubble.planTodos && bubble.planTodos.length" class="inline-plan">
<div v-if="!hideActivity && bubble.planTodos && bubble.planTodos.length" class="inline-plan">
<TodoProgressCard :todos="bubble.planTodos" />
</div>

<!-- 3. Process Stream(工具执行一律收进可折叠组:有 plan 步骤的按步骤分组,
无归属的收进「执行过程」组;流式进行中展开最新组,结束后全部收起——
后台操作细节默认不刷屏,点开才看) -->
<div class="process-stream">
<div v-if="!hideActivity" class="process-stream">
<template v-for="(group, gi) in processGroups" :key="group.key + '-' + gi">
<div class="step-group">
<div class="step-group-header" @click="toggleGroup(group.key, gi)">
Expand Down Expand Up @@ -76,6 +76,7 @@
v-for="art in bubble.artifacts"
:key="art.id"
class="artifact-wrapper"
:data-chat-attention="isApprovalPending(art) ? '' : null"
:class="{ 'artifact-wrapper--approval': isApprovalPending(art) }"
>
<div v-if="isApprovalPending(art)" class="approval-flag">
Expand All @@ -97,7 +98,8 @@
</div>

<!-- 5. Main Content (The Answer) -->
<div v-if="bubble.content" class="main-content">
<div v-if="bubble.content" class="main-content" data-chat-answer>
<div v-if="replyLabel" class="reply-label">{{ replyLabel }}</div>
<MarkdownPreview :content="bubble.content" />
</div>

Expand Down Expand Up @@ -125,6 +127,7 @@
选项也要出现在那段话下面,读起来才是「先问、再给选项」。
可操作性与计划卡同一条链(仅最新一条助手消息、且流已结束)。 -->
<QuestionCard
data-chat-attention
v-if="bubble.question"
:text="bubble.question.text || ''"
:options="bubble.question.options || []"
Expand Down Expand Up @@ -166,7 +169,9 @@ import { t } from '@/i18n'
const props = defineProps({
bubble: { type: Object, required: true },
/** 是否为最新一条助手消息(决定计划卡是否可操作) */
isLatest: { type: Boolean, default: false }
isLatest: { type: Boolean, default: false },
hideActivity: { type: Boolean, default: false },
replyLabel: { type: String, default: '' }
})

const emit = defineEmits(['open-artifact-tab', 'approve', 'message-action', 'answer-question'])
Expand Down Expand Up @@ -243,6 +248,7 @@ const isReady = computed(() => {
})

const hasContent = computed(() => {
if (props.hideActivity) return !!(props.bubble.content || props.bubble.artifacts.length || hasQuestion.value)
// Check if the bubble has any content to display
return !!(
props.bubble.title ||
Expand Down Expand Up @@ -353,6 +359,12 @@ const hasContent = computed(() => {
margin-bottom: 14px;
}

.reply-label {
color: var(--awd-accent-text);
font-size: 11px;
font-weight: 600;
margin: 4px 0 10px;
}
.main-content {
padding: 6px 12px;
font-size: 13px;
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/AgentMessage/TodoProgressCard.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<!-- SPDX-FileCopyrightText: 2026 北京京微资易科技有限公司 and AI WorkDeck contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<template>
<div class="todo-progress-card" v-if="todos && todos.length > 0">
<div class="todo-progress-card" :class="{ 'is-snapshot': !live }" v-if="todos && todos.length > 0">
<div class="todo-header" @click="isCollapsed = !isCollapsed">
<div class="todo-header-left">
<span class="todo-title">{{ $t('chat.taskProgress') }}</span>
<span class="todo-counter">{{ completedCount }}/{{ todos.length }}</span>
</div>
<div class="todo-header-right">
<span v-if="currentTodo" class="todo-current-hint">{{ currentTodo.activeForm || currentTodo.content }}</span>
<span v-if="currentTodo && live" class="todo-current-hint">{{ currentTodo.activeForm || currentTodo.content }}</span>
<div class="chevron" :class="{ collapsed: isCollapsed }">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
Expand All @@ -24,7 +24,7 @@
<svg v-else-if="todo.status === 'failed'" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
<span v-else class="marker-dot"></span>
</span>
<span class="todo-text">{{ todo.status === 'in_progress' ? (todo.activeForm || todo.content) : todo.content }}</span>
<span class="todo-text">{{ todo.status === 'in_progress' && live ? (todo.activeForm || todo.content) : todo.content }}</span>
</div>
</div>
</div>
Expand All @@ -34,7 +34,8 @@
import { computed, ref } from 'vue'

const props = defineProps({
todos: { type: Array, default: () => [] }
todos: { type: Array, default: () => [] },
live: { type: Boolean, default: true }
})

const isCollapsed = ref(false)
Expand All @@ -44,6 +45,7 @@ const currentTodo = computed(() => props.todos.find(t => t.status === 'in_progre
</script>

<style scoped>
.is-snapshot .marker-spinner { animation: none; }
.todo-progress-card {
background: var(--awd-surface);
border: 1px solid var(--awd-border);
Expand Down
Loading
Loading