refactor(ui): publish tested chat UI simplification

This commit is contained in:
matevip 2026-04-11 14:25:37 +08:00
parent 795fb5eb0a
commit f98f4d68b9
13 changed files with 618 additions and 435 deletions

0
TECHNOLOGY_OVERVIEW.md Normal file
View File

View File

@ -0,0 +1 @@
Hello World

View File

@ -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();

View File

@ -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;
* <p>
* PlanGenerationNode 判定用户消息是简单问答时
* direct_answer 透传为 final_summary直接结束图执行
* <p>
* 如果 PlanGenerationNode 已通过 broadcastContent() 推送了内容
* contentStreamed=true则不再复制到 FINAL_SUMMARY
* 避免 StreamAccumulator 重复收集导致持久化内容翻倍
*
* @author MateClaw Team
*/
@ -18,6 +23,11 @@ public class DirectAnswerNode implements NodeAction {
@Override
public Map<String, Object> 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);
}

View File

@ -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.mdPROFILE.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
// 不拼接完整 systemPromptwiki/技能/记忆指南等与规划决策无关
// 拼接后会稀释 PLANNING_PROMPT 的指令优先级
List<Message> 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()) {

187
mateclaw-ui/TEST_CASES.md Normal file
View File

@ -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 AssistantReAct
- **操作**: 发送 "请分析一下量子计算的发展趋势"
- **预期**:
- 加载条显示 **"思考中…"** 和 ◐ 图标,不再显示 "准备上下文"/"读取记忆"/"推理中" 等内部阶段
- 无 statusDetail 第二行解释文本
- 无 slowHint即使等待超过 8 秒也不出现 "耗时较长" 提示)
- 仅显示耗时计时器(如 "12s"),不显示 token 计数
### TC-1.2 执行中状态Working
- **Agent**: MateClaw AssistantReAct绑定 WebSearch 工具
- **操作**: 发送 "搜索一下今天的科技新闻"
- **预期**:
- 工具调用时加载条切换为 **"执行中…"** 和 ⚙ 图标
- 显示工具名(如 `search`
- 不显示 "正在执行工具" 的详情文本
### TC-1.3 撰写中状态Writing
- **Agent**: MateClaw AssistantReAct
- **操作**: 发送 "写一篇 500 字的短文"
- **预期**:
- 内容流输出阶段,加载条显示 **"生成中…"** 和 ▸ 图标
- 流结束后加载条消失
### TC-1.4 错误/中断状态
- **操作**: 发送消息后立即点击停止按钮
- **预期**:
- 加载条图标变为 ⊘,文本变红
- 无 amber/blue 等其他颜色状态
---
## 二、审批 UI 精简验证
### TC-2.1 Shell 命令触发审批(高危操作)
- **Agent**: MateClaw AssistantReAct绑定 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 AssistantReAct绑定 WriteFile 工具
- **操作**: 发送 "创建一个 hello.txt 文件,内容写 Hello World"
- **预期**:
- `write_file` 工具触发审批
- 输入栏显示审批操作,气泡仅一行状态
- 拒绝后,气泡状态变为 "已拒绝:`write_file`"
### TC-2.3 危险命令直接阻断CRITICAL 级别)
- **Agent**: MateClaw AssistantReAct绑定 Shell 工具
- **操作**: 发送 "执行 rm -rf /"
- **预期**:
- Guard 规则 `SHELL_RM_RF_ROOT` 直接 BLOCK
- 不进入审批流程,直接返回阻断消息
- 输入栏不显示审批栏
---
## 三、Plan-Execute 流程验证
### TC-3.1 PlanStepsPanel 渲染唯一性
- **Agent**: Task PlannerPlan-Execute
- **操作**: 发送 "帮我调研 MateClaw 项目的技术栈,列出前端和后端分别用了哪些核心技术,然后生成一个技术概览文档"
- **预期**:
- 生成计划后PlanStepsPanel 只在消息气泡中出现**一次**
- 步骤进度正确显示pending → running → completed
- 不在分段式视图和传统模式中同时出现两个 PlanStepsPanel
### TC-3.2 Plan 中触发审批的步骤暂停与恢复
- **Agent**: Task PlannerPlan-Execute绑定 Shell + WriteFile 工具
- **操作**: 发送 "查看当前目录结构,然后创建一个 project-summary.md 文件"
- **预期**:
- 计划包含多个步骤
- 涉及文件写入的步骤触发审批
- 审批期间PlanStepsPanel 该步骤显示 running 状态
- 气泡中审批为一行极简文本
- 批准后步骤继续执行,状态更新为 completed
---
## 四、BrowserTimeline 默认收起验证
### TC-4.1 浏览器操作时间线默认折叠
- **Agent**: MateClaw AssistantReAct绑定 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 中正常自适应
- 气泡中审批状态一行文本不溢出
- 加载条内容不截断

View File

@ -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;

View File

@ -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--) {

View File

@ -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);
}
/* ===== 移动端适配 ===== */

View File

@ -21,6 +21,8 @@
<!-- ===== 分段式渲染模式Claude Code 风格===== -->
<template v-if="useSegmentedView">
<div class="segments-view">
<!-- 计划步骤面板始终显示在 segments 之上 -->
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
<template v-for="(seg, index) in segments" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" />
<ToolCallSegment v-if="seg.type === 'tool_call'" :segment="seg" />
@ -71,21 +73,7 @@
<Transition name="thinking-slide">
<div v-if="executionExpanded" class="execution-content">
<!-- Plan 步骤进度 -->
<div v-if="planMeta" class="plan-steps">
<div class="plan-steps__title">Plan ({{ planMeta.steps.length }} steps)</div>
<div
v-for="(step, i) in planMeta.steps"
:key="i"
class="plan-step"
:class="{
'plan-step--done': planMeta.stepResults?.[i]?.status === 'completed',
'plan-step--active': i === planMeta.currentStep && isGenerating
}"
>
<span class="plan-step__index">{{ i + 1 }}</span>
<span class="plan-step__text">{{ step }}</span>
</div>
</div>
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
<!-- 工具调用列表 -->
<div v-if="toolCallsMeta.length" class="tool-calls">
@ -97,9 +85,9 @@
>
<span class="tool-call__status">
<el-icon v-if="tc.status === 'running'" class="spin"><Loading /></el-icon>
<el-icon v-else-if="tc.status === 'awaiting_approval'" style="color: #f59e0b;"><WarningFilled /></el-icon>
<el-icon v-else-if="tc.success !== false" style="color: #10b981;"><Select /></el-icon>
<el-icon v-else style="color: #ef4444;"><CloseBold /></el-icon>
<el-icon v-else-if="tc.status === 'awaiting_approval'" class="tc-icon--warning"><WarningFilled /></el-icon>
<el-icon v-else-if="tc.success !== false" class="tc-icon--success"><Select /></el-icon>
<el-icon v-else class="tc-icon--error"><CloseBold /></el-icon>
</span>
<span class="tool-call__name">{{ tc.name }}</span>
<span class="tool-call__args" v-if="tc.arguments">{{ truncateArgs(tc.arguments) }}</span>
@ -116,42 +104,18 @@
<!-- 浏览器执行时间线 -->
<BrowserTimeline v-if="browserActionsMeta.length" :actions="browserActionsMeta" />
<!-- 工具审批面板 -->
<div v-if="pendingApproval" class="approval-section" :class="approvalSeverityClass">
<div class="approval-header">
<el-icon><WarningFilled /></el-icon>
<span class="approval-title">{{ $t('chat.approvalRequired') || 'Approval Required' }}</span>
<span v-if="pendingApproval.maxSeverity" class="approval-severity-badge" :class="'severity-' + pendingApproval.maxSeverity?.toLowerCase()">
{{ pendingApproval.maxSeverity }}
</span>
</div>
<div class="approval-detail">
<div class="approval-tool"><strong>Tool:</strong> {{ pendingApproval.toolName }}</div>
<div v-if="pendingApproval.summary" class="approval-summary">{{ pendingApproval.summary }}</div>
<div class="approval-reason"><strong>Reason:</strong> {{ pendingApproval.reason }}</div>
<div class="approval-args" v-if="pendingApproval.arguments">
<strong>Args:</strong> <code>{{ truncateArgs(pendingApproval.arguments) }}</code>
</div>
</div>
<!-- Findings List -->
<div v-if="pendingApproval.findings?.length" class="approval-findings">
<div v-for="(finding, idx) in pendingApproval.findings" :key="idx" class="approval-finding-item">
<span class="finding-severity-dot" :class="'dot-' + finding.severity?.toLowerCase()"></span>
<span class="finding-category-tag">{{ finding.category }}</span>
<span class="finding-text">{{ finding.title }}</span>
<span v-if="finding.remediation" class="finding-fix">{{ finding.remediation }}</span>
</div>
</div>
<div v-if="pendingApproval.status === 'pending_approval'" class="approval-waiting">
<el-icon class="approval-waiting__spin"><Loading /></el-icon>
<span>{{ $t('chat.approvalWaiting') }}</span>
</div>
<div v-else-if="pendingApproval.status === 'approved'" class="approval-resolved approval-resolved--approved">
{{ $t('chat.approved') }}
</div>
<div v-else class="approval-resolved approval-resolved--denied">
{{ $t('chat.denied') }}
</div>
<!-- 工具审批状态极简一行操作在输入栏 -->
<div v-if="pendingApproval" class="approval-inline">
<el-icon class="approval-inline__icon"><WarningFilled /></el-icon>
<span v-if="pendingApproval.status === 'pending_approval'" class="approval-inline__text">
{{ $t('chat.approvalWaiting') }} <code>{{ pendingApproval.toolName }}</code>
</span>
<span v-else-if="pendingApproval.status === 'approved'" class="approval-inline__text approval-inline--approved">
{{ $t('chat.approved') }}: <code>{{ pendingApproval.toolName }}</code>
</span>
<span v-else class="approval-inline__text approval-inline--denied">
{{ $t('chat.denied') }}: <code>{{ pendingApproval.toolName }}</code>
</span>
</div>
<!-- 主要内容 -->
@ -164,10 +128,6 @@
<TypingCursor v-if="showCursor" :typing="isGenerating" />
</div>
<!-- 加载指示器 -->
<div v-if="showLoadingIndicator" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<!-- 停止指示器 -->
<div v-if="status === 'stopped' || status === 'interrupted'" class="stopped-indicator">
@ -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;

View File

@ -0,0 +1,266 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { Loading, Select, ArrowDown } from '@element-plus/icons-vue'
import type { PlanMeta } from '@/types'
const props = defineProps<{
plan: PlanMeta
isGenerating: boolean
}>()
const collapsed = ref(false)
const completedCount = computed(() =>
props.plan.stepResults?.filter(r => r?.status === 'completed').length || 0
)
const allDone = computed(() =>
completedCount.value === props.plan.steps.length && !props.isGenerating
)
type StepStatus = 'pending' | 'running' | 'completed'
const stepStatuses = computed<StepStatus[]>(() =>
props.plan.steps.map((_, i) => {
const result = props.plan.stepResults?.[i]
if (result?.status === 'completed') return 'completed'
if (i === props.plan.currentStep && props.isGenerating) return 'running'
return 'pending'
})
)
const expandedSteps = reactive(new Set<number>())
function toggleStep(index: number) {
const result = props.plan.stepResults?.[index]
if (!result?.result) return
if (expandedSteps.has(index)) {
expandedSteps.delete(index)
} else {
expandedSteps.add(index)
}
}
function truncateResult(text: string, max: number): string {
if (!text || text.length <= max) return text
return text.slice(0, max) + '...'
}
</script>
<template>
<div class="plan-panel" :class="{ 'is-done': allDone }">
<!-- 标题栏 -->
<div class="plan-panel__header" @click="collapsed = !collapsed">
<span class="plan-panel__icon">
<el-icon v-if="isGenerating && !allDone" class="is-loading" :size="14"><Loading /></el-icon>
<el-icon v-else :size="14"><Select /></el-icon>
</span>
<span class="plan-panel__title">
Plan
</span>
<span class="plan-panel__progress">{{ completedCount }}/{{ plan.steps.length }}</span>
<el-icon
class="plan-panel__arrow"
:class="{ 'is-open': !collapsed }"
:size="12"
><ArrowDown /></el-icon>
</div>
<!-- 步骤列表 -->
<Transition name="plan-slide">
<div v-if="!collapsed" class="plan-panel__body">
<div
v-for="(step, i) in plan.steps"
:key="i"
class="plan-step"
:class="{
'is-pending': stepStatuses[i] === 'pending',
'is-running': stepStatuses[i] === 'running',
'is-completed': stepStatuses[i] === 'completed',
}"
@click="toggleStep(i)"
>
<div class="plan-step__header">
<span class="plan-step__status">
<el-icon v-if="stepStatuses[i] === 'running'" class="is-loading" :size="13"><Loading /></el-icon>
<el-icon v-else-if="stepStatuses[i] === 'completed'" :size="13"><Select /></el-icon>
<span v-else class="plan-step__dot"></span>
</span>
<span class="plan-step__index">{{ i + 1 }}.</span>
<span class="plan-step__text">{{ step }}</span>
<el-icon
v-if="plan.stepResults?.[i]?.result"
class="plan-step__arrow"
:class="{ 'is-open': expandedSteps.has(i) }"
:size="11"
><ArrowDown /></el-icon>
</div>
<!-- 步骤结果可展开 -->
<Transition name="plan-slide">
<div v-if="expandedSteps.has(i) && plan.stepResults?.[i]?.result" class="plan-step__result">
<pre>{{ truncateResult(plan.stepResults[i].result, 500) }}</pre>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.plan-panel {
margin: 4px 0 6px;
border: 1px solid var(--mc-border-light);
border-radius: var(--mc-radius-sm, 6px);
overflow: hidden;
transition: border-color 0.3s;
}
.plan-panel.is-done {
border-color: var(--mc-success, #67c23a);
}
.plan-panel__header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--mc-bg-muted, #f9f7f5);
cursor: pointer;
user-select: none;
font-size: 13px;
transition: background 0.15s;
}
.plan-panel__header:hover {
background: var(--mc-bg-hover, #f0ece8);
}
.plan-panel__icon {
display: flex;
align-items: center;
color: var(--mc-primary, #d96d46);
}
.plan-panel.is-done .plan-panel__icon {
color: var(--mc-success, #67c23a);
}
.plan-panel__title {
font-weight: 600;
color: var(--mc-text-primary);
}
.plan-panel__progress {
font-size: 12px;
color: var(--mc-text-tertiary);
margin-left: 2px;
}
.plan-panel__arrow {
margin-left: auto;
color: var(--mc-text-tertiary);
transition: transform 0.2s;
}
.plan-panel__arrow.is-open {
transform: rotate(180deg);
}
.plan-panel__body {
padding: 4px 0;
}
.plan-step {
transition: background 0.15s;
}
.plan-step:hover {
background: var(--mc-bg-muted, #f9f7f5);
}
.plan-step__header {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px 4px 12px;
font-size: 13px;
cursor: pointer;
user-select: none;
}
.plan-step__status {
display: flex;
align-items: center;
flex-shrink: 0;
width: 16px;
justify-content: center;
}
.is-completed .plan-step__status { color: var(--mc-success, #67c23a); }
.is-running .plan-step__status { color: var(--mc-primary, #d96d46); }
.plan-step__dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid var(--mc-text-quaternary, #c0bfbc);
background: transparent;
}
.plan-step__index {
font-size: 12px;
color: var(--mc-text-tertiary);
font-weight: 500;
flex-shrink: 0;
}
.plan-step__text {
flex: 1;
min-width: 0;
color: var(--mc-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-running .plan-step__text {
color: var(--mc-text-primary);
font-weight: 500;
}
.is-completed .plan-step__text {
color: var(--mc-text-tertiary);
}
.plan-step__arrow {
flex-shrink: 0;
color: var(--mc-text-quaternary);
transition: transform 0.2s;
margin-left: auto;
}
.plan-step__arrow.is-open {
transform: rotate(180deg);
}
.plan-step__result {
padding: 0 10px 4px 38px;
}
.plan-step__result pre {
margin: 0;
padding: 6px 8px;
background: var(--mc-bg-sunken, #f3f0ed);
border-radius: 4px;
border: 1px solid var(--mc-border-light);
font-family: var(--mc-font-mono, 'SF Mono', 'Menlo', 'Consolas', monospace);
font-size: 12px;
line-height: 1.5;
color: var(--mc-text-secondary);
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* Transitions */
.plan-slide-enter-active, .plan-slide-leave-active {
transition: all 0.2s ease;
}
.plan-slide-enter-from, .plan-slide-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@ -5,8 +5,6 @@
<div class="loading-copy">
<span class="loading-text" :class="phaseTextClass">{{ statusText }}</span>
<span v-if="runningToolName" class="loading-tool">{{ runningToolName }}</span>
<span v-if="statusDetail" class="loading-detail">{{ statusDetail }}</span>
<span v-if="slowHint" class="loading-slow">{{ slowHint }}</span>
</div>
</div>
<div class="loading-right">
@ -20,7 +18,6 @@
</span>
<div v-if="showStats" class="loading-stats">
<span class="stat">{{ elapsedTime }}</span>
<span v-if="tokenDisplay > 0" class="stat"> {{ tokenDisplay }} tokens</span>
</div>
</div>
</div>
@ -62,125 +59,84 @@ const props = withDefaults(defineProps<Props>(), {
const { t } = useI18n()
// Phase-aware i18n
const phaseI18nMap: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<typeof setInterval> | 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 {

0
project-summary.md Normal file
View File