mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(ui): publish tested chat UI simplification
This commit is contained in:
parent
795fb5eb0a
commit
f98f4d68b9
0
TECHNOLOGY_OVERVIEW.md
Normal file
0
TECHNOLOGY_OVERVIEW.md
Normal file
1
mateclaw-server/hello.txt
Normal file
1
mateclaw-server/hello.txt
Normal file
@ -0,0 +1 @@
|
||||
Hello World
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<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
187
mateclaw-ui/TEST_CASES.md
Normal 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 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 中正常自适应
|
||||
- 气泡中审批状态一行文本不溢出
|
||||
- 加载条内容不截断
|
||||
@ -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;
|
||||
|
||||
@ -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--) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/* ===== 移动端适配 ===== */
|
||||
|
||||
@ -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;
|
||||
|
||||
266
mateclaw-ui/src/components/chat/PlanStepsPanel.vue
Normal file
266
mateclaw-ui/src/components/chat/PlanStepsPanel.vue
Normal 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>
|
||||
@ -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
0
project-summary.md
Normal file
Loading…
Reference in New Issue
Block a user