diff --git a/TECHNOLOGY_OVERVIEW.md b/TECHNOLOGY_OVERVIEW.md
new file mode 100644
index 00000000..e69de29b
diff --git a/mateclaw-server/hello.txt b/mateclaw-server/hello.txt
new file mode 100644
index 00000000..557db03d
--- /dev/null
+++ b/mateclaw-server/hello.txt
@@ -0,0 +1 @@
+Hello World
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index 20f9ba28..b16d531e 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -233,7 +233,7 @@ public class AgentGraphBuilder {
ChatModel fallbackModel = buildFallbackModel(chatModel);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties);
- PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager);
+ PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DirectAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DirectAnswerNode.java
index 6a198959..550cbf3c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DirectAnswerNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DirectAnswerNode.java
@@ -3,6 +3,7 @@ package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
+import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.Map;
@@ -11,6 +12,10 @@ import java.util.Map;
*
* 当 PlanGenerationNode 判定用户消息是简单问答时,
* 将 direct_answer 透传为 final_summary,直接结束图执行。
+ *
+ * 如果 PlanGenerationNode 已通过 broadcastContent() 推送了内容
+ * (contentStreamed=true),则不再复制到 FINAL_SUMMARY,
+ * 避免 StreamAccumulator 重复收集导致持久化内容翻倍。
*
* @author MateClaw Team
*/
@@ -18,6 +23,11 @@ public class DirectAnswerNode implements NodeAction {
@Override
public Map apply(OverAllState state) {
+ boolean alreadyStreamed = state.value(MateClawStateKeys.CONTENT_STREAMED, false);
+ if (alreadyStreamed) {
+ // broadcastContent 已推送并被 accumulator 收集,不重复写入 FINAL_SUMMARY
+ return Map.of();
+ }
String directAnswer = state.value(PlanStateKeys.DIRECT_ANSWER, "");
return Map.of(PlanStateKeys.FINAL_SUMMARY, directAnswer);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java
index 848ce088..e4f2ac73 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java
@@ -10,6 +10,7 @@ import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
+import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
@@ -22,6 +23,7 @@ import vip.mate.planning.service.PlanningService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
/**
* 计划生成节点
@@ -47,6 +49,7 @@ public class PlanGenerationNode implements NodeAction {
private final PlanningService planningService;
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
+ private final AgentToolSet toolSet;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final String PLANNING_PROMPT = """
@@ -71,25 +74,27 @@ public class PlanGenerationNode implements NodeAction {
- 每个步骤必须是可执行动作,不要写空话。
- 默认不要把 MEMORY.md、PROFILE.md、记忆文件当成独立步骤;但如果用户目标明显依赖历史偏好、长期约束、过往决策或持续上下文,可以加入必要的记忆读取步骤。
- 不要把技能文件当成独立步骤,除非用户任务明确要求。
- - 如果用户目标包含执行、修改、搜索、分析、生成文件、调用工具等多步行为,优先返回规划。
+ - 如果用户目标需要调用任何工具才能完成(包括记忆读写、文件操作、搜索、命令执行等),必须返回 needs_planning: true。只有纯知识问答(不需要调用任何工具的简单问题)才返回 needs_planning: false。
- 如果无法确定,也必须返回合法 JSON,不能输出自然语言。
""";
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
- ConversationWindowManager conversationWindowManager) {
+ ConversationWindowManager conversationWindowManager,
+ AgentToolSet toolSet) {
this.chatModel = chatModel;
this.planningService = planningService;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
+ this.toolSet = toolSet;
}
/**
- * @deprecated Use constructor with NodeStreamingChatHelper
+ * @deprecated Use constructor with full parameters
*/
@Deprecated
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
- this(chatModel, planningService, null, null);
+ this(chatModel, planningService, null, null, null);
}
@Override
@@ -123,12 +128,24 @@ public class PlanGenerationNode implements NodeAction {
}
try {
- // 构建 prompt 消息列表:system + 历史上下文 + 当前规划请求
+ // 构建 prompt 消息列表:PLANNING_PROMPT 作为独立 system message,
+ // 不拼接完整 systemPrompt(wiki/技能/记忆指南等与规划决策无关,
+ // 拼接后会稀释 PLANNING_PROMPT 的指令优先级)
List promptMessages = new ArrayList<>();
- promptMessages.add(new SystemMessage(systemPrompt + "\n\n" + PLANNING_PROMPT));
+ promptMessages.add(new SystemMessage(PLANNING_PROMPT));
// 注入运行时上下文(当前时间)
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage()));
+ // 注入可用工具名称,帮助 LLM 判断用户目标是否需要工具
+ if (toolSet != null && !toolSet.callbacks().isEmpty()) {
+ String toolNames = toolSet.callbacks().stream()
+ .map(cb -> cb.getToolDefinition().name())
+ .collect(Collectors.joining(", "));
+ promptMessages.add(new UserMessage(
+ "你可以使用以下工具:" + toolNames
+ + "\n如果用户目标需要调用任何工具才能完成,必须返回 needs_planning: true。"));
+ }
+
// 注入 working context(对话历史摘要),让规划能感知之前对话的约束和补充条件
String workingContext = accessor.workingContext();
if (!workingContext.isEmpty()) {
diff --git a/mateclaw-ui/TEST_CASES.md b/mateclaw-ui/TEST_CASES.md
new file mode 100644
index 00000000..3151ac21
--- /dev/null
+++ b/mateclaw-ui/TEST_CASES.md
@@ -0,0 +1,187 @@
+# UI 精简改动 - 验证测试用例
+
+> 基于 MateClaw 内置的 3 个 Agent、19 个工具、31 条 Guard 规则和审批工作流设计。
+> 默认登录:admin / admin123
+
+---
+
+## 前置条件
+
+1. 后端启动:`cd mateclaw-server && mvn spring-boot:run`(需设置 `DASHSCOPE_API_KEY`)
+2. 前端启动:`cd mateclaw-ui && pnpm dev`
+3. 访问 http://localhost:5173,登录
+
+---
+
+## 一、StreamLoadingBar 状态简化验证
+
+### TC-1.1 思考中状态(Thinking)
+- **Agent**: MateClaw Assistant(ReAct)
+- **操作**: 发送 "请分析一下量子计算的发展趋势"
+- **预期**:
+ - 加载条显示 **"思考中…"** 和 ◐ 图标,不再显示 "准备上下文"/"读取记忆"/"推理中" 等内部阶段
+ - 无 statusDetail 第二行解释文本
+ - 无 slowHint(即使等待超过 8 秒也不出现 "耗时较长" 提示)
+ - 仅显示耗时计时器(如 "12s"),不显示 token 计数
+
+### TC-1.2 执行中状态(Working)
+- **Agent**: MateClaw Assistant(ReAct),绑定 WebSearch 工具
+- **操作**: 发送 "搜索一下今天的科技新闻"
+- **预期**:
+ - 工具调用时加载条切换为 **"执行中…"** 和 ⚙ 图标
+ - 显示工具名(如 `search`)
+ - 不显示 "正在执行工具" 的详情文本
+
+### TC-1.3 撰写中状态(Writing)
+- **Agent**: MateClaw Assistant(ReAct)
+- **操作**: 发送 "写一篇 500 字的短文"
+- **预期**:
+ - 内容流输出阶段,加载条显示 **"生成中…"** 和 ▸ 图标
+ - 流结束后加载条消失
+
+### TC-1.4 错误/中断状态
+- **操作**: 发送消息后立即点击停止按钮
+- **预期**:
+ - 加载条图标变为 ⊘,文本变红
+ - 无 amber/blue 等其他颜色状态
+
+---
+
+## 二、审批 UI 精简验证
+
+### TC-2.1 Shell 命令触发审批(高危操作)
+- **Agent**: MateClaw Assistant(ReAct),绑定 Shell 工具
+- **操作**: 发送 "帮我删除 /tmp/test 目录下的所有临时文件"
+- **预期**:
+ - Agent 推理后调用 `execute_shell_command`,参数含 `rm`
+ - Guard 规则 `SHELL_RM` 触发 → 进入审批流程
+ - **ChatInput 区域**:替换为审批栏,显示工具名 + 批准/拒绝按钮(保留)
+ - **MessageBubble 中**:仅显示一行 "等待审批:`execute_shell_command`",无完整的审批卡片(无 severity 徽章、无 findings 列表、无参数展示、无等待 spinner)
+ - 点击"批准"后,气泡状态变为 "已批准:`execute_shell_command`"
+
+### TC-2.2 文件写入触发审批
+- **Agent**: MateClaw Assistant(ReAct),绑定 WriteFile 工具
+- **操作**: 发送 "创建一个 hello.txt 文件,内容写 Hello World"
+- **预期**:
+ - `write_file` 工具触发审批
+ - 输入栏显示审批操作,气泡仅一行状态
+ - 拒绝后,气泡状态变为 "已拒绝:`write_file`"
+
+### TC-2.3 危险命令直接阻断(CRITICAL 级别)
+- **Agent**: MateClaw Assistant(ReAct),绑定 Shell 工具
+- **操作**: 发送 "执行 rm -rf /"
+- **预期**:
+ - Guard 规则 `SHELL_RM_RF_ROOT` 直接 BLOCK
+ - 不进入审批流程,直接返回阻断消息
+ - 输入栏不显示审批栏
+
+---
+
+## 三、Plan-Execute 流程验证
+
+### TC-3.1 PlanStepsPanel 渲染唯一性
+- **Agent**: Task Planner(Plan-Execute)
+- **操作**: 发送 "帮我调研 MateClaw 项目的技术栈,列出前端和后端分别用了哪些核心技术,然后生成一个技术概览文档"
+- **预期**:
+ - 生成计划后,PlanStepsPanel 只在消息气泡中出现**一次**
+ - 步骤进度正确显示(pending → running → completed)
+ - 不在分段式视图和传统模式中同时出现两个 PlanStepsPanel
+
+### TC-3.2 Plan 中触发审批的步骤暂停与恢复
+- **Agent**: Task Planner(Plan-Execute),绑定 Shell + WriteFile 工具
+- **操作**: 发送 "查看当前目录结构,然后创建一个 project-summary.md 文件"
+- **预期**:
+ - 计划包含多个步骤
+ - 涉及文件写入的步骤触发审批
+ - 审批期间,PlanStepsPanel 该步骤显示 running 状态
+ - 气泡中审批为一行极简文本
+ - 批准后步骤继续执行,状态更新为 completed
+
+---
+
+## 四、BrowserTimeline 默认收起验证
+
+### TC-4.1 浏览器操作时间线默认折叠
+- **Agent**: MateClaw Assistant(ReAct),绑定 BrowserUse 工具
+- **操作**: 发送 "打开浏览器访问 baidu.com,截图"
+- **预期**:
+ - 浏览器操作完成后,时间线默认**收起**
+ - 仅显示标题栏 "Browser: N actions"
+ - 点击标题栏可展开查看操作细节和截图
+
+---
+
+## 五、动画与视觉一致性验证
+
+### TC-5.1 无 Typing Bounce Dots
+- **Agent**: 任意 Agent
+- **操作**: 发送消息,观察 AI 响应开始前
+- **预期**:
+ - 不再出现三个弹跳圆点的加载动画
+ - 使用 TypingCursor(闪烁光标)代替
+
+### TC-5.2 动画一致性
+- **操作**: 在不同场景触发加载状态
+- **预期**:
+ - StreamLoadingBar 的 icon-pulse 动画时长统一为 1.2s
+ - 所有 spinner 使用相同的旋转动画
+ - 无竞争性的多重脉冲动画
+
+---
+
+## 六、ChatInput 占位符验证
+
+### TC-6.1 加载中占位符不暴露键盘操作
+- **Agent**: 任意 Agent
+- **操作**: 发送消息,在 AI 生成过程中观察输入框
+- **预期**:
+ - 占位符仅显示原始 placeholder 文本
+ - 不再显示 "(Enter to send / interrupt)"
+ - 发送按钮变为红色停止图标已足够提示
+
+---
+
+## 七、深色模式主题变量验证
+
+### TC-7.1 深色模式颜色正确性
+- **操作**: 切换到深色模式(侧边栏底部主题切换)
+- **检查项**:
+ - StreamLoadingBar 文本颜色使用主题主色调(非硬编码 #f97316)
+ - 审批状态文字颜色正确(成功绿/失败红均跟随主题)
+ - 中断按钮使用 `--mc-warning` 变量的深色模式值 (#fbbf24)
+ - 排队指示器使用 `--mc-info` 变量的深色模式值 (#60a5fa)
+ - 工具调用状态图标(成功/失败/等待)颜色均来自 CSS 变量
+
+### TC-7.2 浅色/深色快速切换
+- **操作**: 在生成过程中快速切换浅色/深色模式
+- **预期**:
+ - 所有颜色即时切换,无残留的硬编码颜色
+
+---
+
+## 八、回归测试
+
+### TC-8.1 普通对话流程(无工具调用)
+- **Agent**: MateClaw Assistant
+- **操作**: 发送 "你好,介绍一下你自己"
+- **预期**: 正常生成回复,无 UI 异常
+
+### TC-8.2 多轮对话 + 工具调用
+- **Agent**: MateClaw Assistant,绑定多个工具
+- **操作**: 连续发送 3-5 条消息,触发不同工具
+- **预期**: 每轮消息的加载条、工具调用显示、内容输出均正常
+
+### TC-8.3 消息中断与重试
+- **操作**: 发送消息 → 中断 → 重试
+- **预期**: 中断指示器正常显示,重试后正常生成
+
+### TC-8.4 会话切换
+- **操作**: 在多个会话间切换
+- **预期**: 历史消息正确加载,审批状态(已批准/已拒绝)正确回显
+
+### TC-8.5 移动端响应式
+- **操作**: 浏览器宽度缩小到 768px 以下
+- **预期**:
+ - 审批栏在 ChatInput 中正常自适应
+ - 气泡中审批状态一行文本不溢出
+ - 加载条内容不截断
diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css
index 036b6fa1..1a8a40eb 100644
--- a/mateclaw-ui/src/assets/main.css
+++ b/mateclaw-ui/src/assets/main.css
@@ -122,7 +122,12 @@
--mc-thinking-border: rgba(217, 119, 87, 0.2);
--mc-danger: #C0392B;
--mc-danger-bg: #fee2e2;
+ --mc-danger-border: #fca5a5;
+ --mc-danger-hover: #a93226;
--mc-success: #5A8A5A;
+ --mc-warning: #f59e0b;
+ --mc-warning-hover: #d97706;
+ --mc-info: #3b82f6;
/* scrollbar */
--mc-scrollbar-thumb: #C4B5A8;
@@ -232,7 +237,12 @@ html.dark {
--mc-thinking-border: rgba(224, 136, 96, 0.2);
--mc-danger: #E05A4A;
--mc-danger-bg: rgba(224, 90, 74, 0.15);
+ --mc-danger-border: rgba(224, 90, 74, 0.4);
+ --mc-danger-hover: #c94a3a;
--mc-success: #7AB87A;
+ --mc-warning: #fbbf24;
+ --mc-warning-hover: #f59e0b;
+ --mc-info: #60a5fa;
/* scrollbar */
--mc-scrollbar-thumb: #5A4438;
diff --git a/mateclaw-ui/src/components/chat/BrowserTimeline.vue b/mateclaw-ui/src/components/chat/BrowserTimeline.vue
index 1b7a4217..e48c6ffe 100644
--- a/mateclaw-ui/src/components/chat/BrowserTimeline.vue
+++ b/mateclaw-ui/src/components/chat/BrowserTimeline.vue
@@ -59,7 +59,7 @@ const props = defineProps<{
actions: BrowserAction[]
}>()
-const expanded = ref(true)
+const expanded = ref(false)
const latestScreenshot = computed(() => {
for (let i = props.actions.length - 1; i >= 0; i--) {
diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue
index 8aef9f0c..3684012c 100644
--- a/mateclaw-ui/src/components/chat/ChatInput.vue
+++ b/mateclaw-ui/src/components/chat/ChatInput.vue
@@ -279,7 +279,7 @@ const canSend = computed(() => {
const inputPlaceholder = computed(() => {
if (props.loading) {
if (props.queuedMessage) return t('chat.queuedReplace')
- return props.placeholder + ' (Enter to send / interrupt)'
+ return props.placeholder
}
return props.placeholder
})
@@ -704,9 +704,9 @@ defineExpose({
}
.approval-bar__btn--deny:hover {
- background: #fee2e2;
- color: #dc2626;
- border-color: #fca5a5;
+ background: var(--mc-danger-bg, #fee2e2);
+ color: var(--mc-danger, #ef4444);
+ border-color: var(--mc-danger-border, #fca5a5);
}
/* 输入区域容器 */
@@ -733,7 +733,7 @@ defineExpose({
align-items: center;
gap: 6px;
font-size: 12px;
- color: #3b82f6;
+ color: var(--mc-info, #3b82f6);
}
@media (max-width: 768px) {
@@ -779,19 +779,19 @@ defineExpose({
}
.queued-indicator__cancel:hover {
- color: #ef4444;
- border-color: #fca5a5;
- background: #fee2e2;
+ color: var(--mc-danger, #ef4444);
+ border-color: var(--mc-danger-border, #fca5a5);
+ background: var(--mc-danger-bg, #fee2e2);
}
/* 中断发送按钮样式 */
.send-btn.is-interrupt {
- background: #f59e0b;
+ background: var(--mc-warning, #f59e0b);
color: white;
}
.send-btn.is-interrupt:hover:not(:disabled) {
- background: #d97706;
+ background: var(--mc-warning-hover, #d97706);
}
/* ===== 移动端适配 ===== */
diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue
index 956195d2..f203fff0 100644
--- a/mateclaw-ui/src/components/chat/MessageBubble.vue
+++ b/mateclaw-ui/src/components/chat/MessageBubble.vue
@@ -21,6 +21,8 @@
+
+
@@ -71,21 +73,7 @@
-
-
Plan ({{ planMeta.steps.length }} steps)
-
- {{ i + 1 }}
- {{ step }}
-
-
+
@@ -97,9 +85,9 @@
>
-
-
-
+
+
+
{{ tc.name }}
{{ truncateArgs(tc.arguments) }}
@@ -116,42 +104,18 @@
-
-
-
-
-
Tool: {{ pendingApproval.toolName }}
-
{{ pendingApproval.summary }}
-
Reason: {{ pendingApproval.reason }}
-
- Args: {{ truncateArgs(pendingApproval.arguments) }}
-
-
-
-
-
-
- {{ finding.category }}
- {{ finding.title }}
- {{ finding.remediation }}
-
-
-
-
- {{ $t('chat.approvalWaiting') }}
-
-
- {{ $t('chat.approved') }}
-
-
- {{ $t('chat.denied') }}
-
+
+
+
+
+ {{ $t('chat.approvalWaiting') }} {{ pendingApproval.toolName }}
+
+
+ {{ $t('chat.approved') }}: {{ pendingApproval.toolName }}
+
+
+ {{ $t('chat.denied') }}: {{ pendingApproval.toolName }}
+
@@ -164,10 +128,6 @@
-
-
-
-
@@ -311,6 +271,7 @@ import BrowserTimeline from './BrowserTimeline.vue'
import ToolCallSegment from './ToolCallSegment.vue'
import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue'
+import PlanStepsPanel from './PlanStepsPanel.vue'
import type { BrowserAction } from './BrowserTimeline.vue'
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError'
@@ -730,13 +691,19 @@ const showExecutionPanel = computed(() => {
|| !!pendingApproval.value
})
-// 自动展开执行面板(工具调用时或审批时)
+// 自动展开执行面板(工具调用时、审批时或计划创建时)
watch(toolCallsMeta, (calls) => {
if (calls.length > 0 && isGenerating.value) {
executionExpanded.value = true
}
}, { deep: true })
+watch(planMeta, (plan) => {
+ if (plan && plan.steps?.length > 0 && isGenerating.value) {
+ executionExpanded.value = true
+ }
+})
+
watch(pendingApproval, (approval) => {
if (approval?.status === 'pending_approval') {
executionExpanded.value = true
@@ -1051,6 +1018,9 @@ watch(isGenerating, (generating) => {
align-items: center;
flex-shrink: 0;
}
+.tc-icon--warning { color: var(--mc-warning, #f59e0b); }
+.tc-icon--success { color: var(--mc-success, #10b981); }
+.tc-icon--error { color: var(--mc-danger, #ef4444); }
.tool-call__name {
font-weight: 600;
@@ -1065,61 +1035,7 @@ watch(isGenerating, (generating) => {
flex: 1;
}
-.plan-steps {
- margin-bottom: 10px;
-}
-
-.plan-steps__title {
- font-size: 12px;
- font-weight: 600;
- color: var(--mc-text-secondary, #64748b);
- margin-bottom: 6px;
-}
-
-.plan-step {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 0;
- font-size: 12px;
- color: var(--mc-text-tertiary, #94a3b8);
-}
-
-.plan-step--done {
- color: #10b981;
-}
-
-.plan-step--active {
- color: var(--mc-primary, #D97757);
- font-weight: 600;
-}
-
-.plan-step__index {
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- background: var(--mc-bg-elevated, #f8fafc);
- font-size: 11px;
- font-weight: 600;
- flex-shrink: 0;
-}
-
-.plan-step--done .plan-step__index {
- background: rgba(16, 185, 129, 0.1);
-}
-
-.plan-step--active .plan-step__index {
- background: rgba(217, 119, 87, 0.1);
-}
-
-.plan-step__text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
+/* plan-steps 样式已迁移到 PlanStepsPanel.vue 组件 */
.execution-empty {
font-size: 12px;
@@ -1137,172 +1053,44 @@ watch(isGenerating, (generating) => {
}
/* ==================== 审批面板 ==================== */
-.approval-section {
- margin-bottom: 12px;
- padding: 12px 14px;
- border: 1px solid #f59e0b;
- border-left: 3px solid #f59e0b;
- border-radius: 10px;
- background: rgba(245, 158, 11, 0.06);
-}
-
-.approval-section.approval-severity-critical {
- border-color: #ef4444;
- border-left-color: #ef4444;
- background: rgba(239, 68, 68, 0.06);
-}
-
-.approval-section.approval-severity-high {
- border-color: #f97316;
- border-left-color: #f97316;
- background: rgba(249, 115, 22, 0.06);
-}
-
-.approval-section.approval-severity-medium {
- border-color: #f59e0b;
- border-left-color: #f59e0b;
- background: rgba(245, 158, 11, 0.06);
-}
-
-.approval-section.approval-severity-low {
- border-color: #3b82f6;
- border-left-color: #3b82f6;
- background: rgba(59, 130, 246, 0.06);
-}
-
-.approval-section.approval-severity-info {
- border-color: #6b7280;
- border-left-color: #6b7280;
- background: rgba(107, 114, 128, 0.06);
-}
-
-.approval-header {
+/* 极简审批状态(一行式) */
+.approval-inline {
display: flex;
align-items: center;
- gap: 8px;
- color: #f59e0b;
- font-weight: 600;
- font-size: 14px;
+ gap: 6px;
+ padding: 8px 12px;
margin-bottom: 8px;
-}
-
-.approval-title {
font-size: 13px;
-}
-
-.approval-detail {
- font-size: 12px;
color: var(--mc-text-secondary, #64748b);
- display: flex;
- flex-direction: column;
- gap: 4px;
+ background: var(--mc-bg-muted, #f9f7f5);
+ border-radius: 8px;
}
-.approval-detail code {
- font-size: 11px;
- background: var(--mc-inline-code-bg, #f1f5f9);
- padding: 1px 4px;
- border-radius: 4px;
-}
-
-.approval-waiting {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-top: 10px;
- font-size: 12px;
- color: var(--mc-text-tertiary, #94a3b8);
-}
-
-.approval-waiting__spin {
- animation: spin 1s linear infinite;
+.approval-inline__icon {
+ color: var(--mc-warning, #f59e0b);
flex-shrink: 0;
}
-.approval-resolved {
- margin-top: 10px;
- font-size: 13px;
- font-weight: 600;
-}
-
-.approval-resolved--approved {
- color: #10b981;
-}
-
-.approval-severity-badge {
- display: inline-block;
- padding: 1px 7px;
- border-radius: 4px;
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- margin-left: auto;
-}
-
-.approval-severity-badge.severity-critical { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
-.approval-severity-badge.severity-high { background: rgba(249, 115, 22, 0.15); color: #f97316; }
-.approval-severity-badge.severity-medium { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
-.approval-severity-badge.severity-low { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
-.approval-severity-badge.severity-info { background: rgba(107, 114, 128, 0.15); color: #6b7280; }
-
-.approval-summary {
+.approval-inline__text code {
font-size: 12px;
- color: var(--mc-text-primary, #334155);
- font-weight: 500;
- padding: 4px 0;
-}
-
-.approval-findings {
- margin-top: 8px;
- padding: 8px 10px;
- background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.03));
- border-radius: 6px;
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-
-.approval-finding-item {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
-}
-
-.finding-severity-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.finding-severity-dot.dot-critical { background: #ef4444; }
-.finding-severity-dot.dot-high { background: #f97316; }
-.finding-severity-dot.dot-medium { background: #f59e0b; }
-.finding-severity-dot.dot-low { background: #3b82f6; }
-.finding-severity-dot.dot-info { background: #6b7280; }
-
-.finding-category-tag {
- font-family: 'SF Mono', 'Fira Code', monospace;
- font-size: 10px;
- color: var(--mc-text-tertiary, #94a3b8);
background: var(--mc-inline-code-bg, #f1f5f9);
padding: 1px 5px;
- border-radius: 3px;
+ border-radius: 4px;
+ font-weight: 500;
}
-.finding-text {
- color: var(--mc-text-secondary, #64748b);
+.approval-inline--approved {
+ color: var(--mc-success, #10b981);
+}
+.approval-inline--approved .approval-inline__icon {
+ color: var(--mc-success, #10b981);
}
-.finding-fix {
- color: var(--mc-text-tertiary, #94a3b8);
- font-style: italic;
- margin-left: auto;
+.approval-inline--denied {
+ color: var(--mc-danger, #ef4444);
}
-
-.approval-resolved--denied {
- color: #ef4444;
+.approval-inline--denied .approval-inline__icon {
+ color: var(--mc-danger, #ef4444);
}
/* ==================== 操作栏 ==================== */
@@ -1376,30 +1164,6 @@ watch(isGenerating, (generating) => {
display: inline;
}
-/* 加载指示器 */
-.typing-indicator {
- display: flex;
- gap: 4px;
- padding: 4px 0;
-}
-
-.typing-indicator span {
- width: 6px;
- height: 6px;
- background: var(--mc-text-tertiary, #94a3b8);
- border-radius: 50%;
- animation: bounce 1.2s infinite;
-}
-
-.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
-.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
-
-@keyframes bounce {
- 0%, 60%, 100% { transform: translateY(0); }
- 30% { transform: translateY(-6px); }
-}
-
-
/* 状态指示器 */
.stopped-indicator {
display: flex;
diff --git a/mateclaw-ui/src/components/chat/PlanStepsPanel.vue b/mateclaw-ui/src/components/chat/PlanStepsPanel.vue
new file mode 100644
index 00000000..38313ec9
--- /dev/null
+++ b/mateclaw-ui/src/components/chat/PlanStepsPanel.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ truncateResult(plan.stepResults[i].result, 500) }}
+
+
+
+
+
+
+
+
+
diff --git a/mateclaw-ui/src/components/chat/StreamLoadingBar.vue b/mateclaw-ui/src/components/chat/StreamLoadingBar.vue
index 857871d8..69345878 100644
--- a/mateclaw-ui/src/components/chat/StreamLoadingBar.vue
+++ b/mateclaw-ui/src/components/chat/StreamLoadingBar.vue
@@ -5,8 +5,6 @@
{{ statusText }}
{{ runningToolName }}
- {{ statusDetail }}
- {{ slowHint }}
@@ -20,7 +18,6 @@
{{ elapsedTime }}
- ↓ {{ tokenDisplay }} tokens
@@ -62,125 +59,84 @@ const props = withDefaults(defineProps
(), {
const { t } = useI18n()
-// Phase-aware 状态文本(i18n)
-const phaseI18nMap: Record = {
- preparing_context: 'chat.streamPreparingContext',
- reading_memory: 'chat.streamReadingMemory',
- reasoning: 'chat.streamReasoning',
- drafting_answer: 'chat.streamDraftingAnswer',
- summarizing_observations: 'chat.streamSummarizingObservations',
+// 将 14 个内部阶段映射为 3 个面向用户的状态:思考中 / 执行中 / 撰写中
+const userFacingPhase = computed(() => {
+ switch (props.phase) {
+ case 'preparing_context':
+ case 'reading_memory':
+ case 'reasoning':
+ case 'thinking':
+ case 'summarizing_observations':
+ case 'queued':
+ case 'reconnecting':
+ return 'thinking'
+ case 'executing_tool':
+ case 'awaiting_approval':
+ return 'working'
+ case 'drafting_answer':
+ case 'streaming':
+ case 'finalizing':
+ return 'writing'
+ case 'failed':
+ return 'failed'
+ case 'interrupting':
+ case 'stopped':
+ return 'stopped'
+ default:
+ return 'thinking'
+ }
+})
+
+const userPhaseI18nMap: Record = {
thinking: 'chat.streamThinking',
- streaming: 'chat.streamGenerating',
- executing_tool: 'chat.streamExecutingTool',
- awaiting_approval: 'chat.streamAwaitingApproval',
- finalizing: 'chat.streamFinalizing',
+ working: 'chat.streamExecutingTool',
+ writing: 'chat.streamGenerating',
failed: 'chat.streamFailed',
- interrupting: 'chat.streamInterrupting',
- queued: 'chat.streamQueued',
- reconnecting: 'chat.streamReconnecting',
stopped: 'chat.streamStopped',
- completed: 'chat.streamCompleted',
- idle: '',
}
const statusText = computed(() => {
- const key = phaseI18nMap[props.phase]
+ const key = userPhaseI18nMap[userFacingPhase.value]
if (!key) return ''
return t(key)
})
const phaseIcon = computed(() => {
- switch (props.phase) {
- case 'preparing_context': return '◔'
- case 'reading_memory': return '⌕'
- case 'reasoning': return '◐'
- case 'drafting_answer': return '✎'
- case 'summarizing_observations': return '≋'
+ switch (userFacingPhase.value) {
case 'thinking': return '◐'
- case 'streaming': return '▸'
- case 'executing_tool': return '⚙'
- case 'awaiting_approval': return '⏸'
- case 'finalizing': return '✓'
+ case 'working': return '⚙'
+ case 'writing': return '▸'
case 'failed': return '!'
- case 'interrupting': return '⊘'
- case 'queued': return '◷'
- case 'reconnecting': return '↻'
- default: return '+'
+ case 'stopped': return '⊘'
+ default: return '◐'
}
})
const phaseIconClass = computed(() => {
- switch (props.phase) {
- case 'awaiting_approval': return 'icon-paused'
- case 'failed': return 'icon-warning'
- case 'interrupting': return 'icon-warning'
- case 'queued': return 'icon-queued'
- default: return 'icon-active'
+ switch (userFacingPhase.value) {
+ case 'failed':
+ case 'stopped':
+ return 'icon-warning'
+ default:
+ return 'icon-active'
}
})
const phaseTextClass = computed(() => {
- switch (props.phase) {
- case 'awaiting_approval': return 'text-amber'
- case 'failed': return 'text-red'
- case 'interrupting': return 'text-red'
- case 'queued': return 'text-blue'
- default: return ''
+ switch (userFacingPhase.value) {
+ case 'failed':
+ case 'stopped':
+ return 'text-red'
+ default:
+ return ''
}
})
-const detailI18nMap: Record = {
- preparing_context: 'chat.streamPreparingContextDetail',
- reading_memory: 'chat.streamReadingMemoryDetail',
- reasoning: 'chat.streamReasoningDetail',
- drafting_answer: 'chat.streamDraftingAnswerDetail',
- summarizing_observations: 'chat.streamSummarizingObservationsDetail',
- thinking: 'chat.streamThinkingDetail',
- streaming: 'chat.streamGeneratingDetail',
- executing_tool: 'chat.streamExecutingToolDetail',
- awaiting_approval: 'chat.streamAwaitingApprovalDetail',
- finalizing: 'chat.streamFinalizingDetail',
- failed: 'chat.streamFailedDetail',
-}
-
-const statusDetail = computed(() => {
- if (props.phaseInfo?.phase) {
- const key = detailI18nMap[props.phaseInfo.phase]
- if (key) return t(key)
- }
- const key = detailI18nMap[props.phase]
- return key ? t(key) : ''
-})
-
-const slowHint = computed(() => {
- const secs = elapsedSeconds.value
- if (props.phase === 'summarizing_observations' && secs >= 15) {
- return t('chat.streamSlowSummarizing')
- }
- if ((props.phase === 'reasoning' || props.phase === 'thinking') && secs >= 20) {
- return t('chat.streamSlowReasoning')
- }
- if (secs >= 45) {
- return t('chat.streamSlowGeneral')
- }
- if (secs >= 8) {
- return t('chat.streamSlowShort')
- }
- return ''
-})
// 耗时统计
const elapsedSeconds = ref(0)
const elapsedTime = ref('0s')
-// Token 计数
-const estimatedTokens = ref(0)
-const tokenDisplay = computed(() => {
- if (props.completionTokens && props.completionTokens > 0) {
- return props.completionTokens
- }
- return estimatedTokens.value
-})
let timerInterval: ReturnType | null = null
@@ -189,7 +145,6 @@ watch(() => props.isLoading, (loading) => {
if (timerInterval) clearInterval(timerInterval)
elapsedSeconds.value = 0
elapsedTime.value = '0s'
- estimatedTokens.value = 0
timerInterval = setInterval(() => {
elapsedSeconds.value += 1
@@ -210,11 +165,6 @@ watch(() => props.isLoading, (loading) => {
}
}, { immediate: true })
-watch(() => props.completionTokens, (tokens) => {
- if (tokens && tokens > 0) {
- estimatedTokens.value = 0
- }
-})
onBeforeUnmount(() => {
if (timerInterval) {
@@ -239,7 +189,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: flex-start;
gap: 6px;
- color: #f97316;
+ color: var(--mc-primary, #d96d46);
}
.loading-copy {
@@ -255,58 +205,36 @@ onBeforeUnmount(() => {
}
.icon-active {
- animation: icon-pulse 1s ease-in-out infinite;
- color: #f97316;
-}
-
-.icon-paused {
- color: #f59e0b;
- animation: none;
+ animation: icon-pulse 1.2s ease-in-out infinite;
+ color: var(--mc-primary, #d96d46);
}
.icon-warning {
- color: #ef4444;
- animation: icon-pulse 0.5s ease-in-out infinite;
-}
-
-.icon-queued {
- color: #3b82f6;
+ color: var(--mc-danger, #ef4444);
animation: none;
}
.loading-text {
font-weight: 500;
- color: #f97316;
+ color: var(--mc-primary, #d96d46);
}
-.text-amber { color: #f59e0b; }
-.text-red { color: #ef4444; }
-.text-blue { color: #3b82f6; }
+.text-red { color: var(--mc-danger, #ef4444); }
.loading-tool {
align-self: flex-start;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
- background: rgba(249, 115, 22, 0.1);
+ background: var(--mc-primary-light, rgba(217, 119, 87, 0.1));
padding: 1px 6px;
border-radius: 4px;
- color: #f97316;
+ color: var(--mc-primary, #d96d46);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.loading-detail {
- font-size: 12px;
- color: var(--mc-text-secondary, #94a3b8);
-}
-
-.loading-slow {
- font-size: 12px;
- color: #f59e0b;
-}
-
.loading-right {
display: flex;
align-items: center;
@@ -318,7 +246,7 @@ onBeforeUnmount(() => {
align-items: center;
gap: 4px;
font-size: 12px;
- color: #3b82f6;
+ color: var(--mc-info, #3b82f6);
background: rgba(59, 130, 246, 0.08);
padding: 2px 8px;
border-radius: 10px;
@@ -329,7 +257,7 @@ onBeforeUnmount(() => {
align-items: center;
gap: 8px;
font-size: 12px;
- color: #cbd5e1;
+ color: var(--mc-text-tertiary, #94a3b8);
}
.stat {
diff --git a/project-summary.md b/project-summary.md
new file mode 100644
index 00000000..e69de29b