feat(goal): add autonomous goal mode workflow

Introduce workspace-level goal state persistence, goal prompt injection, and after-turn review handling so an active task can continue until the configured completion conditions are met.

Add a dedicated goal review agent with readonly and active evidence modes, configurable model settings, review prompt, token/turn boundaries, idle-no-tool protection, and progress/completed/stopped events.

Wire goal_mode through task creation, task restoration, compression handoff, runtime user messages, API message sanitization, and tool-call ordering so goal continuations survive long-running tasks and deep compression.

Add Vue UI for arming goal mode from the quick menu, showing running/completed banners, displaying progress metrics, restoring running goal state, and exposing personalization settings for review mode and stop limits.

Include goal mode research notes and default goal review configuration.
This commit is contained in:
JOJO 2026-05-30 12:51:42 +08:00
parent 77752a22d4
commit 8a1e17b9a0
31 changed files with 2533 additions and 77 deletions

13
config/goal_review.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "goal-review-agent",
"url": "https://opencode.ai/zen/go/v1",
"key": "${API_KEY_DEEPSEEK}",
"model": "deepseek-v4-flash",
"extra_params": {
"thinking": {
"type": "enabled"
}
},
"timeout_seconds": 60,
"max_command_timeout": 60
}

339
goal_research.md Normal file
View File

@ -0,0 +1,339 @@
# AI 编码 Agent 自主工作循环调研报告
> **一句话总结:`/goal` 命令真实存在。** Claude Codev2.1.139, 2026年5月12日发布和 OpenAI Codex CLIv0.128.0+)都内置了 `/goal` 这一斜杠命令,用于设置"完成条件"让 Agent 跨多轮持续工作直到目标达成。两者实现机制不同但目标一致:打破"模型停 = 任务完"的默认假设,通过额外的验证步骤判断任务是否真正完成。
---
## 第一部分:命令真实性核查
### 1. Claude Code 的 `/goal` 命令
**✅ 真实存在。**
Claude Code 于 2026 年 5 月 12 日发布的版本 2.1.139 中引入了 `/goal` 命令。Anthropic 官方文档将其描述为:
> "Set a completion condition with /goal and Claude keeps working across turns until the condition is met."(用 /goal 设置完成条件Claude 会跨多轮持续工作直到条件满足。)
**官方文档核心描述:**
- 用法:`/goal all tests in test/auth pass and the lint step is clean`
- 每个 turn 结束后,一个独立的轻量级 evaluator model**默认使用 Claude Haiku**)会读取对话 transcript检查条件是否满足
- Evaluator **不运行命令、不读取文件**,仅基于对话中已有的输出来判断
- 条件必须是**可观测的**如测试通过、lint clean而非模糊描述如"让应用变得更专业"
- 可用 `/goal`(无参数)查看当前状态:已用 turn 数、token 消耗、最后 evaluator 的判断理由
**来源:**
- 官方文档https://code.claude.com/docs/en/goal
- 官方对比 `/goal` vs `/loop` vs Stop hooks 三种自主机制:同上页面的 "Compare to other autonomous workflows" 表格
- 社区分析文章非官方但引用了官方文档https://blog.dailydoseofds.com/p/claude-codes-goal-command
---
### 2. Codex CLI 的 `/goal` 命令
**✅ 真实存在。** 在 Codex 社区中常被称为 "Ralph Loop"。
OpenAI 官方 Cookbook 中有一篇专门的指南 "[Using Goals in Codex](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex)",详细介绍了其用法和设计哲学。从 **Codex 0.128.0** 版本开始可用。
**官方 Cookbook 核心定义:**
> "A Goal gives Codex a completion condition: what should be true, how success should be checked, and what constraints must stay intact. ... Goals are for tasks where the next step depends on what Codex learns along the way."
**用法示例:**
```bash
/goal Reduce p95 latency below 120 ms without regressing correctness tests
```
**生命周期管理:**
- `/goal` — 查看当前 Goal
- `/goal pause` — 暂停
- `/goal resume` — 恢复
- `/goal clear` — 清除
**官方推荐 Goal 写作六要素:**
1. 期望结束状态
2. 验证方式(具体证据)
3. 约束条件(什么不能改变)
4. 允许的输入/工具/边界
5. 各轮次之间如何选择下一步
6. 被阻塞时如何报告
**来源:**
- 官方 Cookbookhttps://developers.openai.com/cookbook/examples/codex/using_goals_in_codex
- 官方 CLI 参考https://developers.openai.com/codex/cli/reference
- OpenAI 官方博客Agent Loop 技术细节https://openai.com/index/unrolling-the-codex-agent-loop
- 社区评测非官方https://kingy.ai/ai/openai-codex-goal-the-new-long-horizon-mode-for-agentic-coding
- GitHub 讨论非官方https://github.com/openai/codex/discussions/22749
---
### 3. 结论来源汇总
| 结论 | 来源类型 | 链接 |
|------|---------|------|
| Claude Code `/goal` 真实存在 | 官方文档 | https://code.claude.com/docs/en/goal |
| Codex CLI `/goal` 真实存在 | 官方 Cookbook | https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex |
| Claude Code `/goal` 使用 Haiku 做 evaluator | 社区分析(引用官方文档) | https://blog.dailydoseofds.com/p/claude-codes-goal-command |
| Codex 从 v0.128.0 起支持 Goals | 官方 Cookbook | 同上 Cookbook |
| Codex Agent Loop 技术细节 | OpenAI 官方博客Michael Bolin 撰写) | https://openai.com/index/unrolling-the-codex-agent-loop |
| Codex 开源代码仓库 | GitHub官方 | https://github.com/openai/codex |
---
## 第二部分:自主循环的实现机制(核心)
### 4. Agent 如何判断"任务还没做完、需要继续"
这是核心技术问题。两个工具采用了不同的方案:
#### Claude Code `/goal` 的循环控制逻辑
1. **每个 turn 结束后触发 evaluator**Claude 完成一个 turn读取文件、运行命令、编辑代码等工具调用完成并输出 assistant messageevaluator model 被调用
2. **Evaluator 接收**:完整的对话 transcript + 用户设置的完成条件
3. **Evaluator 判断**:条件是否已满足(基于 transcript 中的证据)
4. **不满足 → 自动开启新 turn**条件未满足时Claude 继续工作
5. **满足 → 停止**evaluator 判断条件达成,循环结束
**关键设计**Evaluator model 是**独立的、轻量级的模型调用**(默认 Claude Haiku不共享主工作模型的上下文。它仅做"裁判",不参与实际工作。这是打破"模型停止输出 = 任务完成"假设的核心机制。
#### Codex `/goal` 的循环控制逻辑
Codex 的实现更加工程化,它是一个 **thread-scoped线程级的持久化状态机**
1. **Goal 作为持久化线程状态**Goal 不是全局内存或项目指令,而是绑定到当前线程的持久状态记录(包含目标、生命周期、预算、进度)
2. **事件驱动的 continuation**Codex 仅在安全的边界检查是否应继续:
- 上一个 turn 已完成
- 没有其他工作正在 pending
- 没有用户输入排队
- 线程处于空闲状态
3. **Plan-only 不触发继续**:如果上一个 turn 只是"计划模式"(只读),不会自动触发下一轮
4. **空转抑制**:如果 continuation turn 没有做任何 tool call下一次自动继续被抑制防止空转烧钱
5. **基于证据的完成判定**Codex 必须将"目标 vs 具体证据"进行对比——修改的文件、运行的命令输出、测试结果、benchmark 输出等
#### 两种方案的本质差异
| 维度 | Claude Code /goal | Codex /goal |
|------|-------------------|-------------|
| 完成判定者 | 独立 evaluator modelHaiku | 主工作模型 + 硬编码规则 |
| 判定依据 | 对话 transcript | 对话 transcript + 硬编码状态检查 |
| 继续触发 | evaluator 判断"未完成" | 事件驱动idle + active + no input |
| 空转防护 | 依赖 evaluator 判断 | 硬编码抑制(无 tool call 则停止下次自动继续) |
**来源:**
- Claude Code 官方文档evaluator 说明https://code.claude.com/docs/en/goal
- Claude Code /goal 社区深度分析https://blog.dailydoseofds.com/p/claude-codes-goal-command
- Codex 官方 Cookbook架构设计详解含架构图https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex
- Codex Agent Loop 官方博客turn 概念https://openai.com/index/unrolling-the-codex-agent-loop
---
### 5. "模型停止输出文本 = 任务完成"的默认假设,它们是怎么打破的?
#### Claude Code独立的 Evaluator 审查步骤
- **不是模型自己说"我做完了"**:主工作模型完成 turn 后输出 assistant message如"我已经修好了 bug"),但这不等同于 Goal 完成
- **Evaluator model 是独立的审查者**:它是一个独立的模型调用(默认 Haiku只读取对话 transcript对照完成条件做判断
- **模型"相信"不算,证据才算**官方文档要求条件必须基于可观测输出如测试通过、lint clean排斥模糊条件如"看起来很棒"
#### Codex硬编码的"证据审计"步骤
- **must be evidence-based**(必须基于证据):官方 Cookbook 反复强调,"A Goal should not be marked complete because the model believes it is probably done"
- **多层次的验证**
- 硬编码检查:文件变更、命令执行结果、测试输出
- 模型辅助判断:对比目标与证据,给出是否完成的判断
- **每轮结束后都有一个"检查点"**Codex 在 turn 完成后检查 Goal 状态,而不是模型随时可以声明"完成"
- **预算耗尽时明确区分**budget 耗尽 ≠ 目标完成Codex 必须区分两者并总结 blocker
#### 关键差异
| 审查方式 | Claude Code | Codex |
|---------|-------------|-------|
| 审查者 | 独立 evaluator model | 主模型 + 硬编码规则 |
| 审查时机 | 每个 turn 后 | 每个 turn 后的 idle 检查点 |
| 证据要求 | 对话中可见的输出 | 对话中的证据 + 系统状态 |
**来源:**
- Claude Code 官方文档 How evaluation workshttps://code.claude.com/docs/en/goal
- 社区文章详解 evaluator loophttps://blog.dailydoseofds.com/p/claude-codes-goal-command
- Codex Cookbook "How Goals are designed" 章节https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex
- OpenAI 安全博客 Auto-review相关机制https://alignment.openai.com/auto-review
---
### 6. 是否用到 plan / todo / task list 结构来追踪进度?
#### Claude Code
- Claude Code 的 Agent SDK 有内置的 **TaskCreate / TaskUpdate** 工具(在 SDK 工具列表中明确列出)
- `/goal` 的官方文档**未明确提到**必须使用 task list 来驱动循环evaluator 直接判断条件是否满足
- 但从 SDK 文档看task 工具是可用的编排能力https://code.claude.com/docs/en/agent-sdk/agent-loop
#### Codex
- Codex 有内置的 **`update_plan`** 工具(在 OpenAI 官方博客的 Agent Loop 文章中有代码示例)
- 对于 Goal 模式Cookbook 的架构描述中提到 "Goal records the objective, lifecycle, budget, and **progress accounting**"
- Goal 本身作为 thread 的状态,承担了"进度追踪"的角色
- 使用模式是**"目标 + 当前证据 + 下一步"**,而非严格的 checklist 式 todo
#### 结论
两个工具都没有将"todo list 全部勾选"作为循环继续/停止的唯一依据。而是采用**"条件 vs 证据"**的判定模式。Plan/task 工具是辅助模型组织工作的手段,不是硬编码的循环控制逻辑。
**来源:**
- Claude Code SDK 工具列表(含 TaskCreate/TaskUpdatehttps://code.claude.com/docs/en/agent-sdk/agent-loop
- Codex blog Agent Loop 中 update_plan 工具代码https://openai.com/index/unrolling-the-codex-agent-loop
- Codex Cookbook How Goals are designedhttps://developers.openai.com/cookbook/examples/codex/using_goals_in_codex
---
### 7. 防止死循环和失控的机制
#### Claude Code `/goal`
| 机制 | 详情 | 来源 |
|------|------|------|
| **无内置 token budget** | `/goal` 本身没有 token 或金钱上限;官方建议在条件中显式加 `stop after 20 turns` | https://code.claude.com/docs/en/goal |
| **Agent SDK 层 max_turns** | 通过 SDK 编程使用时,可设置 `max_turns``max_budget_usd` | https://code.claude.com/docs/en/agent-sdk/agent-loop |
| **手动中断** | `Ctrl+C``/goal clear` 随时中断 | 同上 |
| **Trust dialog 限制** | `/goal` 仅在用户接受信任对话框的工作区可用 | 同上 |
| **Hooks 限制** | `disableAllHooks``allowManagedHooksOnly` 设置时 `/goal` 不可用 | 同上 |
| **Context window 管理** | SDK 支持自动 compaction`PreCompact` hook防止上下文溢出 | https://code.claude.com/docs/en/agent-sdk/agent-loop |
#### Codex `/goal`
| 机制 | 详情 | 来源 |
|------|------|------|
| **Budget accounting预算核算** | Goal 状态中包含预算;达到预算时停止实质性工作,总结进度和 blockers | https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex |
| **空转抑制** | 如果 continuation turn 未做任何 tool call下一次自动继续被抑制 | 同上 |
| **Safe boundary 检查** | 仅在 turn 完成 + 线程空闲 + 无用户输入排队时检查是否需要继续 | 同上 |
| **手动生命周期控制** | `/goal pause`(暂停)、`/goal resume`(恢复)、`/goal clear`(清除) | 同上 |
| **Auto-compaction** | 超过 `auto_compact_limit` 时自动压缩上下文 | https://openai.com/index/unrolling-the-codex-agent-loop |
| **Prompt caching** | 通过精确 prompt 前缀匹配实现缓存,降低长期运行成本 | 同上 |
#### 共同模式
两者都没有"检测到重复输出自动停止"的硬编码机制——这仍然依赖模型自身判断和 evaluator/证据检查。但两者都有**多层"软硬"防护**预算限制、turn 数限制、空转抑制、手动中断点。
---
### 8. AGENTS.md / CLAUDE.md / Plan Mode / Hooks 在自主循环中的角色
#### Codex 的 AGENTS.md
- **注入到初始 prompt 中**AGENTS.md 的内容作为 `role=user` 的消息注入到 Responses API 的 `input` 中(官方博客 Agent Loop 文章第3步
- **层级结构**
1. `$CODEX_HOME/AGENTS.override.md``AGENTS.md`(全局)
2. 从 Git/project root 向上到 `cwd`:读取 `AGENTS.override.md`、`AGENTS.md` 或 `project_doc_fallback_filenames` 中配置的文件
- **在 Goal 模式中的作用**AGENTS.md 在每次模型调用中都会被包含(作为 prompt 前缀),提供项目规范、代码风格、验收标准等"持久上下文",确保模型在长达 N 个 turn 的自主运行中保持一致的行为
- **Skills 系统**:如果配置了 skillsskill metadata 和如何使用 skills 的说明也会被注入
**来源:**
- 官方博客 Agent Loop "Building the initial prompt" 第3步https://openai.com/index/unrolling-the-codex-agent-loop
- 官方 AGENTS.md 指南https://developers.openai.com/codex/guides/agents-md
#### Claude Code 的 CLAUDE.md
- **通过 settingSources 加载**CLAUDE.md 在 session 开始时被加载,其完整内容在每次 API 请求中都被包含(但受益于 prompt caching只有第一次请求支付完整成本
- **在 Goal 模式中的作用**
- CLAUDE.md 定义架构决策、编码规范、验收标准
- 确保模型在 30+ 个 turn 的长运行中保持一致的上下文理解
- 可包含 summarization 指令,指导自动 compaction 时保留关键信息
**来源:**
- Claude Code Agent Loop SDK 文档"What consumes context"https://code.claude.com/docs/en/agent-sdk/agent-loop
- 社区最佳实践https://blog.dailydoseofds.com/p/claude-codes-goal-command
#### Claude Code 的 Plan Modeplan mode
- SDK 中有 `permission_mode: "plan"`在此模式下只允许只读工具运行Claude 只探索和产出计划而不编辑文件
- 官方 `/goal` 文档未明确说明 plan mode 与 goal 的交互,但 SDK 中 plan mode 是独立概念
- 来自非官方来源的分析提到 plan-only 工作不触发 Goal 的 continuation
#### Claude Code 的 Hooks 机制
官方 SDK 文档列出了完整的 hooks 体系:
| Hook | 触发时机 | 在 Goal 循环中的作用 |
|------|---------|-------------------|
| `PreToolUse` | 工具执行前 | 验证输入、阻止危险命令 |
| `PostToolUse` | 工具返回后 | **自动运行 lint / type-check**,在运行中发现错误 |
| `Stop` | Agent 完成时 | 验证结果、保存 session 状态 |
| `PreCompact` | 上下文压缩前 | 归档完整 transcript 后再摘要 |
Hooks 与 `/goal` 配合使用时可以在每次文件编辑后自动做验证PostToolUse hook减少返工。
**来源:**
- Claude Code Agent Loop SDK Hooks 文档https://code.claude.com/docs/en/agent-sdk/agent-loop
- Claude Code Hooks 参考https://code.claude.com/docs/en/hooks
#### Claude Code 的 Auto Mode
- Auto mode 是独立的权限模式(`permission_mode: "auto"`),用模型分类器自动审批/拒绝每个工具调用
- 两层分类:快速初筛 + 深度分析(处理不确定的危险操作)
- **Auto mode + /goal 是最佳组合**Auto mode 让每个 turn 不被权限确认打断,/goal 让多个 turn 自动衔接
**来源:**
- Anthropic 官方博客 Auto Modehttps://www.anthropic.com/engineering/claude-code-auto-mode
- InfoQ 分析文章https://www.infoq.com/news/2026/05/anthropic-claude-code-auto-mode
---
## 第三部分:同类开源方案(补充参考)
### 9. 其他做"自主循环直到完成"的方案
#### Aider
- **完成判定**Aider 的默认模式是"一次请求一次回复",但可通过配置实现循环。它主要依靠模型自身的判断(模型输出"任务完成")来结束。没有独立的 evaluator。
- **自我验证**:依赖用户提供测试命令(`/test`Aider 在每次修改后自动运行测试,测试失败则自动尝试修复。这形成了一种"修改→测试→修复"的内循环。
- **链接**https://github.com/paul-gauthier/aider
#### OpenHands原 OpenDevin
- **完成判定**OpenHands 使用 agent 自己判断任务是否完成(模型声明完成)。但社区最佳实践建议在 prompt 中要求 agent "运行测试套件后再声明完成"。
- **自我验证**:强调 "verification loop"——agent 被提示在完成任务前必须运行测试/验证命令并展示通过证据。这不是硬编码的,而是 prompt engineering 的结果。
- **链接**https://github.com/All-Hands-AI/OpenHands | 深度分析https://dev.to/truongpx396/openhands-deep-dive-build-your-own-guide-1al0
#### SWE-agent
- **完成判定**SWE-agent 使用固定步数限制(如 30 步)或模型自行判断。没有独立的完成条件 evaluator。
- **自我验证**SWE-agent 的核心创新是其 Agent-Computer Interface (ACI)——精心设计的工具和反馈格式。它通过让模型查看命令执行结果来做判断,但没有额外的验证层。
- **已转向更简化的 mini-SWE-agent**:当前主要开发精力已转移到 mini-swe-agent。
- **链接**https://github.com/SWE-agent/SWE-agent | 论文https://arxiv.org/abs/2405.15793
#### AutoGPT
- **完成判定**AutoGPT 是早期"自主循环"的典型代表。它使用**任务列表task list机制**:主 agent 将目标分解为子任务,逐一执行并勾选,直到列表清空。
- **自我验证**每个子任务执行后agent 会生成新的子任务并重新排序优先级。这是一种"任务队列为空 = 完成"的模式没有独立的验证步骤。容易出现死循环agent 不断生成新任务)。
- **链接**https://github.com/Significant-Gravitas/AutoGPT
#### 总结对比
| 方案 | 完成判定 | 验证机制 | 独立 evaluator |
|------|---------|---------|---------------|
| Claude Code /goal | 独立 Haiku evaluator | 模型审查 transcript | ✅ 有 |
| Codex /goal | 主模型 + 硬编码规则 | 证据 vs 目标审计 | ⚠️ 半独立(模型+规则) |
| Aider | 模型自判断 | 用户提供的测试命令 | ❌ 无 |
| OpenHands | 模型自判断 | Prompt 引导的验证 | ❌ 无 |
| SWE-agent | 模型自判断 + 步数限制 | 工具输出反馈 | ❌ 无 |
| AutoGPT | 任务列表清空 | 任务队列管理 | ❌ 无 |
---
## 附录:对你设计自己 Agent 项目的建议
基于以上调研Claude Code 和 Codex 的 `/goal` 设计可以提炼出以下**可复用的设计原则**
1. **分离"工作者"和"裁判"**Claude Code 的独立 evaluator model 是最优雅的方案。即使不用独立模型,至少应该有一个"审查步骤",不让工作模型自己声明"我做完了"。
2. **证据驱动,而非信心驱动**:两个工具都强调"条件必须是可观测的"。设计你的 Goal 系统时,要求用户定义可被自动化验证的完成标准(如测试通过、构建成功、文件存在等)。
3. **硬编码安全边界**Codex 的空转抑制和 event-driven continuation 值得借鉴。不要让 Agent 无限制地自循环——设置 turn 上限、空转检测、预算上限。
4. **分层权限**Claude Code 的 Auto mode模型分类器审批+ Goal 的组合模式很实用。设计时考虑"什么操作可以自动审批"和"什么操作需要人类确认"。
5. **持久化上下文很重要**AGENTS.md / CLAUDE.md 在长运行中提供一致性。你的系统也需要类似的"项目规范注入"机制。
6. **可中断 + 可恢复**:两者都支持 pause/resume/clear 生命周期。长时间运行的 Agent 必须支持这些操作。
7. **即使没有单独 evaluator model也可以在 prompt 中实现"审查步骤"**:在每轮结束后,向模型发送一个特殊的审查 prompt"请基于以上对话中可见的证据,判断以下条件是否已满足..."),这本质上就是 Codex 当前的做法。
---
> **报告完成时间**2026年5月29日
> **调研工具**web_search + extract_webpage
> **声明**:所有标注为"官方"的结论均来源于官方文档、GitHub 仓库或官方博客;标注为"社区/非官方"的来源于第三方博客或分析文章。未找到的信息已明确标注"未找到"。

View File

@ -0,0 +1,366 @@
"""目标审核智能体Goal Review Agent
modules/approval_agent.py fork 而来职责不同
- approval_agent 判断"工具调用是否越权/危险"
- goal_review_agent 判断"长期目标是否真正达成"
两种审核模式
- readonly只给 report_goal_status 一个工具不注入 run_commandsystem prompt 不含模式说明
- active额外注入只读 run_command 工具system prompt 追加一句允许其取证
返回结构{"status": "done"|"continue", "message": str, "source": "goal_review_agent"}
兜底超轮/异常/未产出结论保守返回 continue避免误判完成
"""
from __future__ import annotations
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import httpx
from config import PROMPTS_DIR
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "goal_review.json"
PROMPT_NAME = "goal_review_agent.txt"
DEFAULT_MAX_ROUNDS = 3
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_COMMAND_TIMEOUT = 60
DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT = True
DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "goal_review_agent"
# 兜底续命消息(审核智能体未能产出明确结论时使用)
FALLBACK_CONTINUE_MESSAGE = "审核未能给出明确结论。请重新对照目标核查当前进度,找出尚未完成的部分并继续推进。"
def _resolve_env_token(value: str) -> str:
text = str(value or "").strip()
if text.startswith("${") and text.endswith("}"):
key = text[2:-1].strip()
return os.environ.get(key, "")
return text
def load_goal_review_agent_config() -> Dict[str, Any]:
base = {
"name": "goal-review-agent",
"url": "",
"key": "",
"model": "",
"extra_params": {},
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
"max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT,
}
try:
if not CONFIG_PATH.exists():
return base
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(raw, dict):
base.update(raw)
base["url"] = _resolve_env_token(base.get("url", ""))
base["key"] = _resolve_env_token(base.get("key", ""))
base["model"] = str(base.get("model") or "").strip()
base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {}
base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT))
return base
except Exception:
return base
class GoalReviewAgent:
def __init__(self, *, web_terminal: Any):
self.web_terminal = web_terminal
self.cfg = load_goal_review_agent_config()
@staticmethod
def _system_prompt(review_mode: str) -> str:
try:
template = (Path(PROMPTS_DIR) / PROMPT_NAME).read_text(encoding="utf-8")
except Exception:
template = "你是目标审核智能体。你必须调用 report_goal_status 返回 done 或 continue。"
active_start = "{{ACTIVE_REVIEW_ONLY}}"
active_end = "{{/ACTIVE_REVIEW_ONLY}}"
if review_mode == REVIEW_MODE_ACTIVE:
return template.replace(active_start, "").replace(active_end, "").strip()
while active_start in template and active_end in template:
start = template.find(active_start)
end = template.find(active_end, start)
if start < 0 or end < 0:
break
template = template[:start] + template[end + len(active_end):]
return template.strip()
@staticmethod
def _report_tool() -> Dict[str, Any]:
return {
"type": "function",
"function": {
"name": "report_goal_status",
"description": (
"提交目标审核结论并结束本次审核。done=目标已达成,结束循环;"
"continue=尚未达成,需主执行模型继续工作。"
),
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["done", "continue"],
"description": "done=目标已达成continue=尚未达成,继续下一轮。",
},
"message": {
"type": "string",
"description": (
"status=done 时写给用户的完成总结;"
"status=continue 时写给主执行模型的下一步指示,需具体可执行。"
),
},
},
"required": ["status", "message"],
},
},
}
@staticmethod
def _run_command_tool() -> Dict[str, Any]:
return {
"type": "function",
"function": {
"name": "run_command",
"description": (
"在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、"
"查看 git 状态与差异、运行测试以核实目标是否达成。"
"禁止用于写入、删除、改权限或其他修改性操作。"
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": (
"要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、"
"rg、git status、git diff、pwd、stat、以及运行测试的命令等。"
),
}
},
"required": ["command"],
},
},
}
def _build_tools(self, review_mode: str) -> List[Dict[str, Any]]:
if review_mode == REVIEW_MODE_ACTIVE:
return [self._run_command_tool(), self._report_tool()]
return [self._report_tool()]
async def _run_readonly_command(self, command: str) -> Dict[str, Any]:
work_path = Path(getattr(self.web_terminal.context_manager, "project_path", "."))
timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)
try:
result = await self.web_terminal.terminal_ops.run_command(
command=command,
working_dir=str(work_path),
timeout=timeout,
sandbox_write_access=False,
)
return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"}
except Exception as exc:
return {"success": False, "error": str(exc)}
async def review(
self,
*,
payload_text: str,
review_mode: str = REVIEW_MODE_READONLY,
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
) -> Dict[str, Any]:
review_mode = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY
debug_transcript: List[Dict[str, Any]] = []
def _trace(event: str, payload: Dict[str, Any]) -> None:
if not DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT:
return
debug_transcript.append({"ts": int(time.time() * 1000), "event": event, "payload": payload})
def _flush_trace(final_result: Dict[str, Any]) -> None:
if not DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT:
return
try:
DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
row = {
"created_at": int(time.time() * 1000),
"review_mode": review_mode,
"messages": messages,
"trace": debug_transcript,
"final_result": final_result,
}
file = DEBUG_TRANSCRIPT_DIR / f"goal_review_{int(time.time() * 1000)}.json"
file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def _continue(message: str) -> Dict[str, Any]:
return {"status": "continue", "message": message or FALLBACK_CONTINUE_MESSAGE, "source": "goal_review_agent"}
url = str(self.cfg.get("url") or "").strip()
key = str(self.cfg.get("key") or "").strip()
model = str(self.cfg.get("model") or "").strip()
if not url or not key or not model:
out = _continue("目标审核智能体配置缺失,无法判断完成情况,请继续推进目标。")
_flush_trace(out)
return out
endpoint = f"{url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
extra_params = dict(self.cfg.get("extra_params") or {})
tools = self._build_tools(review_mode)
messages: List[Dict[str, Any]] = [
{"role": "system", "content": self._system_prompt(review_mode)},
{"role": "user", "content": payload_text},
]
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
rounds = 0
forced_tool_retry_used = False
while True:
rounds += 1
if cancel_check:
external = cancel_check()
if external:
return external
if progress_cb:
progress_cb({"stage": "model_call", "round": rounds, "message": f"审核轮次 {rounds}"})
req = {
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.0,
**extra_params,
}
_trace("request", {"round": rounds, "request": req})
try:
resp = await client.post(endpoint, headers=headers, json=req)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
body = ""
try:
body = exc.response.text
except Exception:
body = str(exc)
_trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body})
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params
if extra_params:
retry_req = {
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.0,
}
_trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req})
retry_resp = await client.post(endpoint, headers=headers, json=retry_req)
try:
retry_resp.raise_for_status()
resp = retry_resp
except httpx.HTTPStatusError:
out = _continue(f"目标审核请求失败({retry_resp.status_code}),请继续推进目标。")
_flush_trace(out)
return out
else:
out = _continue(
f"目标审核请求失败({exc.response.status_code if exc.response else 'unknown'}),请继续推进目标。"
)
_flush_trace(out)
return out
except Exception as exc:
_trace("request_exception", {"round": rounds, "error": str(exc)})
out = _continue("目标审核请求异常,请继续推进目标。")
_flush_trace(out)
return out
choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {}
reasoning_content = (
choice.get("reasoning_content") or choice.get("reasoning") or choice.get("thinking") or ""
)
_trace("response", {"round": rounds, "message": choice, "reasoning_content": reasoning_content})
tool_calls = choice.get("tool_calls") or []
content = choice.get("content")
if not tool_calls:
if content or reasoning_content:
messages.append(
{
"role": "assistant",
"content": str(content or ""),
"reasoning_content": str(reasoning_content or ""),
}
)
reminder = (
"禁止直接输出结论文本。必须调用工具:必要时先调用 run_command 核实,"
"再调用 report_goal_status 给出最终结论。"
)
if not forced_tool_retry_used:
forced_tool_retry_used = True
reminder = "你刚刚未通过工具给出明确结果。必须调用工具,禁止直接输出内容。请立即调用 report_goal_status 返回最终结论。"
messages.append({"role": "user", "content": reminder})
continue
# 有 tool_calls追加完整 assistant 消息(与主智能体结构对齐)
assistant_message = {
"role": "assistant",
"content": str(content or ""),
"reasoning_content": str(reasoning_content or ""),
"tool_calls": tool_calls,
}
messages.append(assistant_message)
for call in tool_calls:
fn = (call.get("function") or {}).get("name")
raw_args = (call.get("function") or {}).get("arguments") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
except Exception:
args = {}
if fn == "report_goal_status":
status = str(args.get("status") or "").strip().lower()
message = str(args.get("message") or "").strip()
if status == "done":
out = {"status": "done", "message": message or "目标已达成。", "source": "goal_review_agent"}
_flush_trace(out)
return out
if status == "continue":
out = _continue(message)
_flush_trace(out)
return out
# 非法 status保守续命
out = _continue(message or "审核返回了无法识别的状态,请继续推进目标。")
_flush_trace(out)
return out
if fn == "run_command":
cmd = str(args.get("command") or "").strip()
if progress_cb:
progress_cb({"stage": "run_command", "round": rounds, "command": cmd})
tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"}
_trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result})
tool_content = json.dumps(tool_result, ensure_ascii=False)
messages.append(
{
"role": "tool",
"content": tool_content,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
+ f".{int((time.time() % 1) * 1000000):06d}",
"message_id": f"msg_{uuid.uuid4().hex}",
"tool_call_id": call.get("id"),
"name": "run_command",
}
)

View File

@ -0,0 +1,281 @@
"""目标模式Goal Mode的工作区级状态管理。
每个工作区/项目最多存在一个活动目标状态落盘到 `{data_dir}/goal_state.json`
以便在切换对话对话压缩conversation_id 变化进程重启后仍能维持
设计要点
- 不依赖 web_terminal只接受一个 data_dir便于单测与复用
- token 基线对齐工作区级累计 `{data_dir}/token_totals.json`input/output/total
- review_history 保存历轮 {main_output, review_reply}用于构建交叉结构审核文本
"""
from __future__ import annotations
import json
import time
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
GOAL_STATE_FILENAME = "goal_state.json"
# 状态机
STATUS_RUNNING = "running"
STATUS_DONE = "done"
STATUS_STOPPED = "stopped"
# 停止原因
REASON_IDLE_NO_TOOL = "idle_no_tool"
REASON_MAX_TURNS = "max_turns"
REASON_MAX_TOKENS = "max_tokens"
REASON_USER_CANCEL = "user_cancel"
# 审核模式
REVIEW_MODE_READONLY = "readonly"
REVIEW_MODE_ACTIVE = "active"
PathLike = Union[str, Path]
def _empty_state() -> Dict[str, Any]:
return {
"active": False,
"status": STATUS_STOPPED,
"goal": "",
"review_mode": REVIEW_MODE_READONLY,
"max_turns": 5,
"max_tokens": None,
"start_conversation_id": None,
"started_at": None,
"turn_count": 0,
"token_baseline": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
"tool_call_count_baseline": 0,
"tool_calls_used": 0,
"review_history": [],
"final_summary": None,
"stopped_reason": None,
}
class GoalStateManager:
"""工作区级目标状态。一个实例对应一个工作区的 goal_state.json。"""
def __init__(self, data_dir: PathLike):
self.data_dir = Path(data_dir).expanduser()
self.state: Dict[str, Any] = self.load()
# ------------------------------------------------------------------ 路径/持久化
def _path(self) -> Path:
return self.data_dir / GOAL_STATE_FILENAME
@classmethod
def load_from(cls, data_dir: PathLike) -> "GoalStateManager":
"""便捷构造:等价于 GoalStateManager(data_dir)。"""
return cls(data_dir)
def load(self) -> Dict[str, Any]:
path = self._path()
if not path.exists():
self.state = _empty_state()
return self.state
try:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh) or {}
merged = _empty_state()
if isinstance(raw, dict):
merged.update(raw)
# 结构兜底
if not isinstance(merged.get("review_history"), list):
merged["review_history"] = []
if not isinstance(merged.get("token_baseline"), dict):
merged["token_baseline"] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
self.state = merged
except (OSError, json.JSONDecodeError, ValueError):
self.state = _empty_state()
return self.state
def save(self) -> None:
path = self._path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self.state, fh, ensure_ascii=False, indent=2)
tmp.replace(path)
# ------------------------------------------------------------------ 生命周期
def start(
self,
*,
goal: str,
review_mode: str,
max_turns: Optional[int],
max_tokens: Optional[int],
conversation_id: Optional[str],
token_baseline: Optional[Dict[str, int]] = None,
tool_call_baseline: int = 0,
) -> Dict[str, Any]:
rm = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY
baseline = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
if isinstance(token_baseline, dict):
for k in baseline:
try:
baseline[k] = max(0, int(token_baseline.get(k) or 0))
except (TypeError, ValueError):
baseline[k] = 0
self.state = {
"active": True,
"status": STATUS_RUNNING,
"goal": str(goal or "").strip(),
"review_mode": rm,
"max_turns": int(max_turns) if max_turns else None,
"max_tokens": int(max_tokens) if max_tokens else None,
"start_conversation_id": conversation_id,
"started_at": time.time(),
"turn_count": 0,
"token_baseline": baseline,
"tool_call_count_baseline": max(0, int(tool_call_baseline or 0)),
"tool_calls_used": 0,
"review_history": [],
"final_summary": None,
"stopped_reason": None,
}
self.save()
return deepcopy(self.state)
def is_active(self) -> bool:
return bool(self.state.get("active")) and self.state.get("status") == STATUS_RUNNING
def get_goal(self) -> str:
return str(self.state.get("goal") or "")
def get_review_mode(self) -> str:
rm = self.state.get("review_mode")
return rm if rm in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY
def get_turn(self) -> int:
try:
return int(self.state.get("turn_count") or 0)
except (TypeError, ValueError):
return 0
def increment_turn(self) -> int:
self.state["turn_count"] = self.get_turn() + 1
self.save()
return self.state["turn_count"]
def append_review(self, main_output: str, review_reply: str) -> None:
history = self.state.get("review_history")
if not isinstance(history, list):
history = []
history.append(
{
"main_output": str(main_output or ""),
"review_reply": str(review_reply or ""),
}
)
self.state["review_history"] = history
self.save()
def mark_done(self, summary: str) -> Dict[str, Any]:
self.state["active"] = False
self.state["status"] = STATUS_DONE
self.state["final_summary"] = str(summary or "")
self.state["stopped_reason"] = None
self.save()
return deepcopy(self.state)
def mark_stopped(self, reason: str) -> Dict[str, Any]:
self.state["active"] = False
self.state["status"] = STATUS_STOPPED
self.state["stopped_reason"] = reason
self.save()
return deepcopy(self.state)
def clear(self) -> None:
"""完全清除目标状态(用户取消)。"""
self.state = _empty_state()
self.save()
# ------------------------------------------------------------------ 边界判定
def reached_max_turns(self) -> bool:
mt = self.state.get("max_turns")
if not mt:
return False
return self.get_turn() >= int(mt)
def reached_max_tokens(self, current_total_tokens: int) -> bool:
"""current_total_tokens 为工作区当前累计 total_tokens。"""
mt = self.state.get("max_tokens")
if not mt:
return False
used = self.tokens_used(current_total_tokens)
return used >= int(mt)
def tokens_used(self, current_total_tokens: int) -> int:
baseline = self.state.get("token_baseline") or {}
try:
base_total = int(baseline.get("total_tokens") or 0)
except (TypeError, ValueError):
base_total = 0
return max(0, int(current_total_tokens or 0) - base_total)
# ------------------------------------------------------------------ 审核输入构建
def build_review_payload_text(self, current_main_output: str) -> str:
"""生成交叉结构的单条 user 文本:目标 + 历轮(主模型输出/审核反馈) + 当前轮待审。"""
goal = self.get_goal()
lines: List[str] = ["【本次目标】", goal or "(目标为空)", ""]
history = self.state.get("review_history") or []
for idx, entry in enumerate(history, start=1):
if not isinstance(entry, dict):
continue
lines.append(f"【第{idx}轮】")
lines.append(f"主执行模型输出:{str(entry.get('main_output') or '').strip()}")
lines.append(f"你的反馈:{str(entry.get('review_reply') or '').strip()}")
lines.append("")
lines.append("【当前轮·待审核】")
lines.append(f"主执行模型输出:{str(current_main_output or '').strip()}")
return "\n".join(lines)
# ------------------------------------------------------------------ 前端快照
def progress_snapshot(self, *, current_total_tokens: int = 0, current_tool_calls: int = 0) -> Dict[str, Any]:
started_at = self.state.get("started_at")
duration = None
if started_at:
try:
duration = max(0.0, time.time() - float(started_at))
except (TypeError, ValueError):
duration = None
try:
tool_baseline = int(self.state.get("tool_call_count_baseline") or 0)
except (TypeError, ValueError):
tool_baseline = 0
try:
stored_tool_calls = max(0, int(self.state.get("tool_calls_used") or 0))
except (TypeError, ValueError):
stored_tool_calls = 0
current_tool_calls_used = max(0, int(current_tool_calls or 0) - tool_baseline)
tool_calls_used = max(stored_tool_calls, current_tool_calls_used)
if tool_calls_used != stored_tool_calls:
self.state["tool_calls_used"] = tool_calls_used
try:
self.save()
except Exception:
pass
return {
"goal": self.get_goal(),
"status": self.state.get("status"),
"turn_count": self.get_turn(),
"tokens_used": self.tokens_used(current_total_tokens),
"tool_calls": tool_calls_used,
"duration_seconds": duration,
"review_mode": self.get_review_mode(),
"max_turns": self.state.get("max_turns"),
"max_tokens": self.state.get("max_tokens"),
"final_summary": self.state.get("final_summary"),
"stopped_reason": self.state.get("stopped_reason"),
}

View File

@ -18,6 +18,13 @@ from config.model_profiles import get_default_model_key, get_registered_model_ke
ALLOWED_RUN_MODES = {"fast", "thinking", "deep"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
ALLOWED_THEMES = {"classic", "light", "dark"}
ALLOWED_GOAL_REVIEW_MODES = {"readonly", "active"}
ALLOWED_GOAL_END_CONDITIONS = {"max_turns", "max_tokens"}
GOAL_MAX_TURNS_MIN = 1
GOAL_MAX_TURNS_MAX = 100
GOAL_MAX_TURNS_DEFAULT = 5
GOAL_MAX_TOKENS_MIN = 1_000
GOAL_MAX_TOKENS_MAX = 100_000_000
PERSONALIZATION_FILENAME = "personalization.json"
MAX_SHORT_FIELD_LENGTH = 20
@ -86,6 +93,11 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"allow_root_file_creation": False, # 允许在根目录创建文件开关
"theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑
# 目标模式Goal Mode
"goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证
"goal_end_conditions": ["max_turns"], # 结束方式可多选max_turns / max_tokens
"goal_max_turns": GOAL_MAX_TURNS_DEFAULT, # 最多自动续命轮数
"goal_max_tokens": None, # 累计(输入+输出)token 上限None 表示不启用
}
__all__ = [
@ -409,9 +421,54 @@ def sanitize_personalization_payload(
elif base.get("theme") not in ALLOWED_THEMES:
base["theme"] = "classic"
# 目标模式:审核模式
goal_review_mode = data.get("goal_review_mode", base.get("goal_review_mode"))
if isinstance(goal_review_mode, str) and goal_review_mode in ALLOWED_GOAL_REVIEW_MODES:
base["goal_review_mode"] = goal_review_mode
elif base.get("goal_review_mode") not in ALLOWED_GOAL_REVIEW_MODES:
base["goal_review_mode"] = "readonly"
# 目标模式:结束方式(多选)
if "goal_end_conditions" in data:
base["goal_end_conditions"] = _sanitize_goal_end_conditions(data.get("goal_end_conditions"))
else:
base["goal_end_conditions"] = _sanitize_goal_end_conditions(base.get("goal_end_conditions"))
# 目标模式:最大轮数
if "goal_max_turns" in data:
base["goal_max_turns"] = _sanitize_optional_int(
data.get("goal_max_turns"), min_value=GOAL_MAX_TURNS_MIN, max_value=GOAL_MAX_TURNS_MAX
) or GOAL_MAX_TURNS_DEFAULT
else:
base["goal_max_turns"] = _sanitize_optional_int(
base.get("goal_max_turns"), min_value=GOAL_MAX_TURNS_MIN, max_value=GOAL_MAX_TURNS_MAX
) or GOAL_MAX_TURNS_DEFAULT
# 目标模式:累计 token 上限(可空)
if "goal_max_tokens" in data:
base["goal_max_tokens"] = _sanitize_optional_int(
data.get("goal_max_tokens"), min_value=GOAL_MAX_TOKENS_MIN, max_value=GOAL_MAX_TOKENS_MAX
)
else:
base["goal_max_tokens"] = _sanitize_optional_int(
base.get("goal_max_tokens"), min_value=GOAL_MAX_TOKENS_MIN, max_value=GOAL_MAX_TOKENS_MAX
)
return base
def _sanitize_goal_end_conditions(value: Any) -> list:
"""清洗目标模式结束方式列表,保证至少包含 max_turns。"""
cleaned: list = []
if isinstance(value, list):
for item in value:
if isinstance(item, str) and item in ALLOWED_GOAL_END_CONDITIONS and item not in cleaned:
cleaned.append(item)
if "max_turns" not in cleaned:
cleaned.insert(0, "max_turns")
return cleaned
def _sanitize_skills(value: Any) -> Optional[list]:
"""Sanitize enabled skills list / 清洗启用技能列表。"""
if value is None:

View File

@ -0,0 +1,43 @@
你是目标审核智能体,负责判断一个长期目标是否真正达成,而不是判断任务是否合理或有无风险。
你会收到一段文本,包含:本次目标原文、主执行模型每一轮结束时的最终输出,以及你此前历次的反馈(按轮次交叉排列)。
判断必须基于可观测的证据,而非主执行模型的口头声明——它说“我做完了”不算数。
你必须调用 report_goal_status 返回结论,严禁在普通文本里输出“已完成/未完成”这类结论。
若目标已达成status=donemessage 写一段给用户看的完成总结(做了什么、依据是什么)。
若尚未达成status=continuemessage 写给主执行模型的下一步指示,具体、可执行,指出还差什么、先做哪件。
你必须采用“反证审计员”的立场:极其多疑、谨慎、挑剔,默认目标尚未完成,除非已有充分、可复现、覆盖关键边界情况的证据证明目标确实达成。你要主动找茬、钻牛角尖、从疑罪从有的角度审查主执行模型的结果。
判定 done 前,必须确认:
1. 用户目标中的每一项显式要求都已完成;
2. 相关隐含要求、异常路径、边界情况、状态恢复、并发/刷新/切换等高风险场景已被考虑并验证;
3. 主执行模型已使用所有合理且高价值、能更接近目标的工具或验证手段,或者明确说明为什么不需要;
4. 已进行了足够完整的测试和复现,不只是 happy path
5. 没有未解释的失败、报错、TODO、跳过项或证据空白。
“所有合理且高价值的手段”不是无限穷举,而是指:在当前权限、时间、成本和安全边界内,任何能够显著提升完成质量、验证可靠性或减少误判风险的手段。如果某个手段明显可用、成本合理、与目标强相关,但主执行模型没有使用,也没有解释为什么不需要使用,你应视为证据不足,不得判 done。
这些手段包括但不限于:
- 阅读用户原始需求并逐条对照验收;
- 搜索和阅读代码库相关文件、相似实现、配置、prompt、工具 schema、调用链
- 阅读 AGENTS.md、README、docs、skill 文件,从项目规范和技能说明中获取参考;
- 在需要最新资料、外部规范、官方 API 行为、错误原因或最佳实践时,使用互联网搜索,优先查官方文档或权威来源;
- 查看日志、错误请求 dump、运行态数据文件、持久化状态确认真实行为
- 运行相关测试、类型检查、构建命令、冒烟验证;
- 针对用户明确提到的场景进行复现;
- 构造边界情况、异常路径、刷新/切换/压缩/并发/停止/重启等状态场景;
- 检查前端与后端、内存与文件、显示状态与真实任务状态是否一致;
- 检查 git diff/status确认改动范围合理且没有误改无关内容
- 对 provider/API 兼容性进行核验例如请求体字段、tool_calls 配对、空数组、额外字段等。
以下情况一律不得判 done
- 只有主执行模型口头声明完成;
- 只做了部分实现或只检查了部分文件;
- 没有运行必要测试,或测试范围明显不足;
- 未覆盖用户明确提到的复现场景;
- 对边界情况、异常路径、刷新/切换/压缩/并发/停止/重启等状态风险没有验证;
- 存在任何合理疑点但没有补充证据。
如果判定 continuemessage 必须具体指出缺口,并给出下一步可执行补救清单,包括:需要检查的文件、需要调用的工具、需要补跑的测试/复现场景,以及下次审核需要看到的证据。
{{ACTIVE_REVIEW_ONLY}}
你可以使用 run_command 执行只读命令查看文件、跑测试、git diff 等)来核实证据,核实完成后再给结论。
{{/ACTIVE_REVIEW_ONLY}}

View File

@ -132,6 +132,14 @@ from .chat_flow_task_support import process_sub_agent_updates, process_backgroun
from .chat_flow_tool_loop import execute_tool_calls
from .chat_flow_stream_loop import run_streaming_attempts
from .deep_compression import run_deep_compression
from .goal_flow import (
maybe_start_goal,
inject_goal_prompt,
handle_goal_after_turn,
stop_goal_user_cancel,
goal_is_active,
emit_goal_progress,
)
def _should_skip_versioning_for_message(
@ -300,7 +308,7 @@ async def _dispatch_completion_user_notice(
message_source = "background_command"
elif extra_payload.get("sub_agent_notice"):
message_source = "sub_agent"
if message_source not in {"user", "guidance", "notify", "presend", "sub_agent", "background_command"}:
if message_source not in {"user", "guidance", "notify", "presend", "sub_agent", "background_command", "goal"}:
message_source = "user"
try:
from .tasks import task_manager
@ -801,7 +809,7 @@ async def handle_task_with_sender(
user_message_source = "background_command"
elif auto_payload.get("sub_agent_notice"):
user_message_source = "sub_agent"
if user_message_source not in {"user", "guidance", "notify", "presend", "sub_agent", "background_command"}:
if user_message_source not in {"user", "guidance", "notify", "presend", "sub_agent", "background_command", "goal"}:
user_message_source = "user"
# 构建user消息metadata
@ -1002,6 +1010,41 @@ async def handle_task_with_sender(
context = web_terminal.build_context()
messages = web_terminal.build_messages(context, message)
tools = web_terminal.define_tools()
# === 目标模式Goal Mode启动 / 续注入 ===
# 1) 用户请求开启目标模式 → 无条件覆盖工作区旧目标状态并启动新目标。
# 2) 工作区已存在活动目标(含压缩后重入、子智能体通知重入)→ 重新注入提示词,
# 确保主模型在长运行/压缩后仍“知道自己处于目标模式”。
try:
if bool(getattr(web_terminal, "_goal_mode_requested", False)):
maybe_start_goal(
web_terminal=web_terminal,
workspace=workspace,
conversation_id=conversation_id,
goal_text=message,
current_tool_calls=0,
)
# 本标记只表示“本次用户显式开启目标模式”。目标状态已写入
# goal_state.json 后必须立即消费掉,否则自动深层压缩后的递归
# handoff 会再次进入 handle_task_with_sender把压缩引导语误当作
# 新目标并重置目标文本、轮数、token、工具次数和开始时间。
setattr(web_terminal, "_goal_mode_requested", False)
if goal_is_active(workspace):
inject_goal_prompt(
web_terminal=web_terminal,
messages=messages,
sender=sender,
conversation_id=conversation_id,
)
emit_goal_progress(
web_terminal=web_terminal,
workspace=workspace,
sender=sender,
total_tool_calls=0,
)
except Exception as exc:
debug_log(f"[Goal] 启动/注入失败: {exc}")
try:
profile = get_model_profile(getattr(web_terminal, "model_key", None))
web_terminal.apply_model_profile(profile)
@ -1060,6 +1103,9 @@ async def handle_task_with_sender(
auto_fix_attempts = 0
last_tool_call_time = 0
detected_tool_intent: Dict[str, str] = {}
# 目标模式:记录本“目标轮段”内主模型是否调用过工具(空转保护用)。
# 每次续命注入后重置为 False直到下一次 no-tool-call break。
goal_segment_made_tool_call = False
# 设置最大迭代次数API 可覆盖None 表示不限制
max_iterations_override = getattr(web_terminal, "max_iterations_override", None)
@ -1074,6 +1120,15 @@ async def handle_task_with_sender(
stop_entry = get_stop_flag(client_sid, username)
if stop_entry and stop_entry.get('stop'):
debug_log(f"[Task] 检测到停止标志,退出循环")
try:
stop_goal_user_cancel(
web_terminal=web_terminal,
workspace=workspace,
sender=sender,
total_tool_calls=total_tool_calls,
)
except Exception as exc:
debug_log(f"[Goal] 用户停止时停止目标失败: {exc}")
sender('task_stopped', {
'message': '任务已停止',
'reason': 'user_requested'
@ -1280,10 +1335,11 @@ async def handle_task_with_sender(
assistant_message = {
"role": "assistant",
"content": assistant_content,
"tool_calls": tool_calls,
# thinking 模式下reasoning_content 需要原样回传;即使该轮为空字符串也保留字段
"reasoning_content": current_thinking or "",
}
if tool_calls:
assistant_message["tool_calls"] = tool_calls
messages.append(assistant_message)
if assistant_content or current_thinking or tool_calls:
@ -1302,8 +1358,37 @@ async def handle_task_with_sender(
if not tool_calls:
debug_log("没有工具调用,结束迭代")
# === 目标模式turn 结束拦截 ===
try:
if goal_is_active(workspace):
goal_result = await handle_goal_after_turn(
web_terminal=web_terminal,
workspace=workspace,
messages=messages,
sender=sender,
conversation_id=conversation_id,
assistant_content=assistant_content,
made_tool_call=goal_segment_made_tool_call,
total_tool_calls=total_tool_calls,
)
action = goal_result.get("action")
if action == "continue":
# 已注入续命 user 消息,重置目标轮段标志并继续主循环
goal_segment_made_tool_call = False
is_first_iteration = False
debug_log(f"[Goal] 续命:{goal_result.get('message', '')[:80]}")
continue
if action == "done":
debug_log("[Goal] 目标已达成,结束任务")
elif action == "stop":
debug_log(f"[Goal] 目标停止:{goal_result.get('reason')}")
except Exception as exc:
debug_log(f"[Goal] turn 结束处理失败: {exc}")
break
# 目标模式:本轮段确实产生了工具调用
goal_segment_made_tool_call = True
# 检查连续相同工具调用
for tc in tool_calls:
tool_name = tc["function"]["name"]
@ -1336,6 +1421,16 @@ async def handle_task_with_sender(
# 更新统计
total_tool_calls += len(tool_calls)
try:
if goal_is_active(workspace):
emit_goal_progress(
web_terminal=web_terminal,
workspace=workspace,
sender=sender,
total_tool_calls=total_tool_calls,
)
except Exception as exc:
debug_log(f"[Goal] 进度广播失败: {exc}")
# 执行每个工具
tool_loop_result = await execute_tool_calls(

View File

@ -14,6 +14,7 @@ _VALID_SOURCES = {
"presend",
"sub_agent",
"background_command",
"goal",
}
@ -82,7 +83,6 @@ def inject_runtime_user_message(
messages.insert(insert_index, {
"role": "user",
"content": formatted,
"metadata": dict(metadata),
})
if callable(sender):
@ -317,5 +317,4 @@ def cancel_pending_tools(*, tool_calls_list, sender, messages):
"tool_call_id": tc_id,
"name": func_name,
"content": "命令执行被用户取消",
"metadata": {"status": "cancelled"}
})

View File

@ -359,6 +359,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
# 执行每个工具
pending_runtime_mode_notices: List[str] = []
last_completed_tool_call_id: Optional[str] = None
deep_compression_pending = False
for tool_call in tool_calls:
# 检查停止标志
client_stop_info = get_stop_flag(client_sid, username)
@ -386,7 +387,6 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
"tool_call_id": tool_call_id,
"name": function_name,
"content": "命令执行被用户取消",
"metadata": {"status": "cancelled"}
})
sender('task_stopped', {
'message': '命令执行被用户取消',
@ -808,7 +808,6 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
"tool_call_id": tool_call_id,
"name": function_name,
"content": "命令执行被用户取消",
"metadata": {"status": "cancelled"}
})
# 保存取消结果
@ -1163,6 +1162,18 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
)
except Exception:
tool_message_content = tool_result_content
# 添加到消息历史(用于 API 继续对话)。
# 必须在任何可能插入 user 消息/触发深层压缩总结之前完成,避免 assistant.tool_calls
# 与对应 tool 结果之间夹入 user尤其是同一轮并行 tool_calls 尚未全部返回时。
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": tool_message_content
})
last_completed_tool_call_id = tool_call_id
try:
workspace_data_dir = getattr(workspace, "data_dir", None) if workspace else None
personal_config = load_personalization_config(workspace_data_dir) if workspace_data_dir else {}
@ -1243,41 +1254,10 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
and current_context_tokens > compression_settings["deep_trigger_tokens"]
and not web_terminal.context_manager.is_compression_in_progress()
):
web_terminal.context_manager._set_meta_flag("is_ultra_long_conversation", True)
sender('compression_state', {
"conversation_id": conversation_id,
"in_progress": True,
"mode": "auto",
"stage": "queued"
})
deep_result = await run_deep_compression(
web_terminal=web_terminal,
workspace=workspace,
conversation_id=conversation_id,
mode="auto",
sender=sender,
)
if not deep_result.get("success"):
sender('error', {
"message": deep_result.get("error") or "自动深层压缩失败",
"conversation_id": conversation_id,
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"deep_compressed": True,
"deep_result": deep_result,
"last_tool_call_time": last_tool_call_time
}
# 添加到消息历史用于API继续对话
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": tool_message_content
})
last_completed_tool_call_id = tool_call_id
# 只标记,不能在本工具刚结束时立即压缩。若本轮是并行 tool_calls
# 立即调用总结模型会在 assistant.tool_calls 与尚未补齐的 tool 结果之间插入 user prompt
# 触发 OpenAI 兼容接口的 tool message 顺序校验错误。
deep_compression_pending = True
if not pending_runtime_mode_notices:
try:
@ -1291,6 +1271,35 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
if tool_failed:
mark_force_thinking(web_terminal, reason=f"{function_name}_failed")
# 自动深层压缩必须等待同一轮全部 tool_call 的 tool 消息都已写入 messages/历史后再触发。
if deep_compression_pending and not web_terminal.context_manager.is_compression_in_progress():
web_terminal.context_manager._set_meta_flag("is_ultra_long_conversation", True)
sender('compression_state', {
"conversation_id": conversation_id,
"in_progress": True,
"mode": "auto",
"stage": "queued"
})
deep_result = await run_deep_compression(
web_terminal=web_terminal,
workspace=workspace,
conversation_id=conversation_id,
mode="auto",
sender=sender,
)
if not deep_result.get("success"):
sender('error', {
"message": deep_result.get("error") or "自动深层压缩失败",
"conversation_id": conversation_id,
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"deep_compressed": True,
"deep_result": deep_result,
"last_tool_call_time": last_tool_call_time
}
# 子智能体/后台指令完成通知:必须等待同一轮全部 tool_call 都完成后再注入,
# 避免在 assistant.tool_calls 与未完成的 tool 结果之间插入 system 消息导致 API 报错。
if last_completed_tool_call_id:

View File

@ -146,6 +146,9 @@ def _collect_user_texts(messages: List[Dict[str, Any]]) -> List[str]:
for msg in messages:
if msg.get("role") != "user":
continue
metadata = msg.get("metadata") if isinstance(msg.get("metadata"), dict) else {}
if str(metadata.get("message_source") or "").strip().lower() != "user":
continue
text = _extract_text_only(msg.get("content"))
if text.strip():
result.append(text.strip())
@ -411,6 +414,7 @@ async def run_deep_compression(
},
)
_emit(sender, "conversation_resolved", {
"source_conversation_id": conversation_id,
"conversation_id": new_conv_id,
"title": f"{old_title}对话 压缩后",
"created": True,

251
server/goal_flow.py Normal file
View File

@ -0,0 +1,251 @@
"""目标模式Goal Mode在 Web 任务主循环中的编排逻辑。
将启动提示词注入turn 结束后的审核/续命/停止判定集中在此
尽量减少对 chat_flow_task_main.py 的侵入
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
from modules.goal_state_manager import (
GoalStateManager,
REASON_IDLE_NO_TOOL,
REASON_MAX_TOKENS,
REASON_MAX_TURNS,
REASON_USER_CANCEL,
)
from modules.goal_review_agent import GoalReviewAgent
from modules.personalization_manager import load_personalization_config
from .chat_flow_task_support import inject_runtime_user_message
# ① 注入的目标模式提示词(开头一次 + 每次压缩后重注入)
GOAL_MODE_PROMPT = (
"【目标模式已开启】以上是本次的目标。在目标达成前,请持续推进,"
"不要把控制权交还给我、也不要停下来等我确认。请用实际的工具操作推进工作,"
"避免只停留在计划或反复询问。每当你认为工作告一段落停下时,会有独立的审核方"
"对照目标检查是否真正达成;若未达成,你会收到具体的下一步指示并继续。"
)
# 续命消息前缀
CONTINUE_PREFIX = "审核智能体对于你的工作结束给出了以下内容:"
def _load_token_total(web_terminal) -> int:
"""读取工作区累计 total_tokens。"""
try:
totals = web_terminal.context_manager._load_token_totals()
return int(totals.get("total_tokens") or 0)
except Exception:
return 0
def snapshot_token_baseline(web_terminal) -> Dict[str, int]:
try:
totals = web_terminal.context_manager._load_token_totals()
return {
"input_tokens": int(totals.get("input_tokens") or 0),
"output_tokens": int(totals.get("output_tokens") or 0),
"total_tokens": int(totals.get("total_tokens") or 0),
}
except Exception:
return {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
def maybe_start_goal(
*,
web_terminal,
workspace,
conversation_id: Optional[str],
goal_text: str,
current_tool_calls: int,
) -> bool:
"""启动一个新目标。
本函数语义是本次用户显式开启目标模式而不是续接已有目标
因此必须覆盖工作区级 goal_state.json 中的旧目标状态包括 running/done/stopped
review_historytoken 基线开始时间等避免新对话复用上一轮目标
"""
gsm = GoalStateManager(workspace.data_dir)
cfg = load_personalization_config(workspace.data_dir)
review_mode = cfg.get("goal_review_mode") or "readonly"
end_conditions = cfg.get("goal_end_conditions") or ["max_turns"]
max_turns = cfg.get("goal_max_turns") if "max_turns" in end_conditions else None
max_tokens = cfg.get("goal_max_tokens") if "max_tokens" in end_conditions else None
gsm.start(
goal=goal_text,
review_mode=review_mode,
max_turns=max_turns,
max_tokens=max_tokens,
conversation_id=conversation_id,
token_baseline=snapshot_token_baseline(web_terminal),
tool_call_baseline=current_tool_calls,
)
return True
def inject_goal_prompt(
*,
web_terminal,
messages,
sender: Optional[Callable[[str, Dict[str, Any]], None]],
conversation_id: Optional[str],
) -> None:
"""注入 ① 号目标模式提示词(开头 / 压缩后重注入共用)。"""
inject_runtime_user_message(
web_terminal=web_terminal,
messages=messages,
text=GOAL_MODE_PROMPT,
source="goal",
sender=sender,
conversation_id=conversation_id,
inline=False,
persist=True,
)
def goal_is_active(workspace) -> bool:
return GoalStateManager(workspace.data_dir).is_active()
def _emit_snapshot(sender, event: str, gsm: GoalStateManager, *, total_tokens: int, tool_calls: int, extra: Optional[Dict] = None):
if not callable(sender):
return
snap = gsm.progress_snapshot(current_total_tokens=total_tokens, current_tool_calls=tool_calls)
if extra:
snap.update(extra)
try:
sender(event, snap)
except Exception:
pass
def emit_goal_progress(
*,
web_terminal,
workspace,
sender: Optional[Callable[[str, Dict[str, Any]], None]],
total_tool_calls: int = 0,
extra: Optional[Dict[str, Any]] = None,
) -> None:
"""向前端广播当前目标模式进度,用于刷新后恢复与进度弹窗动态数字。"""
gsm = GoalStateManager(workspace.data_dir)
if not gsm.state:
return
total_tokens = _load_token_total(web_terminal)
_emit_snapshot(
sender,
"goal_progress",
gsm,
total_tokens=total_tokens,
tool_calls=total_tool_calls,
extra=extra,
)
async def handle_goal_after_turn(
*,
web_terminal,
workspace,
messages,
sender: Optional[Callable[[str, Dict[str, Any]], None]],
conversation_id: Optional[str],
assistant_content: str,
made_tool_call: bool,
total_tool_calls: int,
) -> Dict[str, Any]:
"""主模型某轮结束(无 tool_calls后的目标处理。
返回 {'action': 'inactive'|'stop'|'done'|'continue', ...}
- inactive当前无活动目标调用方照常结束本次任务
- stop目标因空转/边界停止调用方照常结束事件已发
- done目标达成调用方照常结束事件已发
- continue已注入续命消息调用方应 continue 回主循环开下一轮
"""
gsm = GoalStateManager(workspace.data_dir)
if not gsm.is_active():
return {"action": "inactive"}
total_tokens = _load_token_total(web_terminal)
_emit_snapshot(sender, "goal_progress", gsm, total_tokens=total_tokens, tool_calls=total_tool_calls)
# 1) 空转保护:本目标轮主模型一次工具都没调过 → 直接停止
if not made_tool_call:
gsm.mark_stopped(REASON_IDLE_NO_TOOL)
_emit_snapshot(sender, "goal_stopped", gsm, total_tokens=total_tokens, tool_calls=total_tool_calls)
return {"action": "stop", "reason": REASON_IDLE_NO_TOOL}
# 2) 计入一轮,检查结束边界
gsm.increment_turn()
_emit_snapshot(sender, "goal_progress", gsm, total_tokens=total_tokens, tool_calls=total_tool_calls)
if gsm.reached_max_turns():
gsm.mark_stopped(REASON_MAX_TURNS)
_emit_snapshot(sender, "goal_stopped", gsm, total_tokens=total_tokens, tool_calls=total_tool_calls)
return {"action": "stop", "reason": REASON_MAX_TURNS}
if gsm.reached_max_tokens(total_tokens):
gsm.mark_stopped(REASON_MAX_TOKENS)
_emit_snapshot(sender, "goal_stopped", gsm, total_tokens=total_tokens, tool_calls=total_tool_calls)
return {"action": "stop", "reason": REASON_MAX_TOKENS}
# 3) 调用审核智能体
payload_text = gsm.build_review_payload_text(assistant_content)
if callable(sender):
try:
sender("goal_review_progress", {"progress": {"stage": "start", "message": "开始审核"}})
except Exception:
pass
try:
agent = GoalReviewAgent(web_terminal=web_terminal)
def _progress(progress: Dict[str, Any]) -> None:
if callable(sender):
try:
sender("goal_review_progress", {"progress": progress})
except Exception:
pass
result = await agent.review(
payload_text=payload_text,
review_mode=gsm.get_review_mode(),
progress_cb=_progress,
)
except Exception as exc:
# 审核异常 → 保守续命,但仍受边界约束
result = {"status": "continue", "message": f"目标审核出现异常({exc}),请继续核对目标并推进未完成的部分。"}
status = (result or {}).get("status")
message = (result or {}).get("message") or ""
if status == "done":
gsm.mark_done(message)
_emit_snapshot(
sender, "goal_completed", gsm,
total_tokens=total_tokens, tool_calls=total_tool_calls,
extra={"summary": message},
)
return {"action": "done"}
# continue记录审核历史 + 注入续命消息
gsm.append_review(assistant_content, message)
inject_runtime_user_message(
web_terminal=web_terminal,
messages=messages,
text=f"{CONTINUE_PREFIX}{message}",
source="goal",
sender=sender,
conversation_id=conversation_id,
inline=False,
persist=True,
)
return {"action": "continue", "message": message}
def stop_goal_user_cancel(*, web_terminal, workspace, sender, total_tool_calls: int = 0) -> bool:
"""用户停止任务时,若有活动目标则一并停止。返回是否有活动目标被停止。"""
gsm = GoalStateManager(workspace.data_dir)
if not gsm.is_active():
return False
total_tokens = _load_token_total(web_terminal)
gsm.mark_stopped(REASON_USER_CANCEL)
_emit_snapshot(sender, "goal_stopped", gsm, total_tokens=total_tokens, tool_calls=total_tool_calls)
return True

View File

@ -119,6 +119,7 @@ class TaskManager:
max_iterations: Optional[int] = None,
session_data: Optional[Dict[str, Any]] = None,
message_source: Optional[str] = None,
goal_mode: bool = False,
) -> TaskRecord:
if run_mode:
normalized = str(run_mode).lower()
@ -137,6 +138,7 @@ class TaskManager:
snapshot.setdefault("workspace_id", workspace_id)
if message_source is not None:
snapshot.setdefault("message_source", str(message_source))
snapshot["goal_mode"] = bool(goal_mode)
try:
snapshot.setdefault("host_mode", session.get("host_mode"))
if snapshot.get("host_mode"):
@ -158,6 +160,7 @@ class TaskManager:
"thinking_mode": session.get("thinking_mode"),
"model_key": session.get("model_key"),
"message_source": str(message_source) if message_source is not None else None,
"goal_mode": bool(goal_mode),
}
except Exception:
record.session_data = {}
@ -495,6 +498,8 @@ class TaskManager:
if rec.workspace_id:
data.setdefault("workspace_id", rec.workspace_id)
with self._lock:
if event_type in {"goal_progress", "goal_completed", "goal_stopped"} and isinstance(data, dict):
rec.session_data["goal_progress"] = dict(data)
idx = getattr(rec, "next_event_idx", None)
if idx is None:
idx = rec.events[-1]["idx"] + 1 if rec.events else 0
@ -613,6 +618,12 @@ class TaskManager:
def sender(event_type, data):
if isinstance(data, dict):
data = dict(data)
if event_type == "compression_finished":
migrated_conversation_id = str(data.get("conversation_id") or "").strip()
if migrated_conversation_id:
with self._lock:
rec.conversation_id = migrated_conversation_id
rec.updated_at = time.time()
data.setdefault("task_id", rec.task_id)
if rec.conversation_id:
data.setdefault("conversation_id", rec.conversation_id)
@ -641,10 +652,12 @@ class TaskManager:
previous_auto_user_event = None
previous_message_source = None
previous_auto_user_payload = None
previous_goal_mode_requested = None
try:
previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False)
previous_message_source = getattr(terminal, "_current_user_message_source", None)
previous_auto_user_payload = getattr(terminal, "_auto_user_message_payload", None)
previous_goal_mode_requested = getattr(terminal, "_goal_mode_requested", False)
setattr(
terminal,
"_auto_user_message_event",
@ -660,10 +673,16 @@ class TaskManager:
"_current_user_message_source",
str((rec.session_data or {}).get("message_source") or "user"),
)
setattr(
terminal,
"_goal_mode_requested",
bool((rec.session_data or {}).get("goal_mode")),
)
except Exception:
previous_auto_user_event = None
previous_message_source = None
previous_auto_user_payload = None
previous_goal_mode_requested = None
try:
run_chat_task_sync(
@ -684,6 +703,8 @@ class TaskManager:
setattr(terminal, "_auto_user_message_payload", previous_auto_user_payload)
if previous_message_source is not None:
setattr(terminal, "_current_user_message_source", previous_message_source)
if previous_goal_mode_requested is not None:
setattr(terminal, "_goal_mode_requested", previous_goal_mode_requested)
if terminal and getattr(terminal, "context_manager", None):
terminal.context_manager.set_web_terminal_callback(previous_ctx_callback)
except Exception as exc:
@ -789,6 +810,9 @@ def _task_public_payload(rec: TaskRecord, *, include_title: bool = True) -> Dict
"message": rec.message,
"conversation_id": rec.conversation_id,
"error": rec.error,
"message_source": (rec.session_data or {}).get("message_source"),
"goal_mode": bool((rec.session_data or {}).get("goal_mode")),
"goal_progress": (rec.session_data or {}).get("goal_progress"),
}
if include_title:
payload["conversation_title"] = _conversation_title_for_task(rec)
@ -834,6 +858,7 @@ def create_task_api():
run_mode = payload.get("run_mode")
message_source = payload.get("message_source")
max_iterations = payload.get("max_iterations")
goal_mode = bool(payload.get("goal_mode"))
try:
debug_log(
"[TaskAPI] create_task payload "
@ -856,6 +881,7 @@ def create_task_api():
run_mode=run_mode,
max_iterations=max_iterations,
message_source=message_source,
goal_mode=goal_mode,
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 409
@ -956,6 +982,9 @@ def get_task_api(task_id: str):
"message": rec.message,
"conversation_id": rec.conversation_id,
"error": rec.error,
"message_source": (rec.session_data or {}).get("message_source"),
"goal_mode": bool((rec.session_data or {}).get("goal_mode")),
"goal_progress": (rec.session_data or {}).get("goal_progress"),
"events": events,
"next_offset": next_offset,
"runtime_queued_messages": task_manager.get_runtime_pending_messages(

View File

@ -306,6 +306,9 @@
:runtime-queued-messages="runtimeQueuedMessages"
:user-question-minimized="userQuestionMinimized"
:pending-user-question-count="pendingUserQuestions.length"
:goal-mode-armed="goalModeArmed"
:goal-running="goalRunning"
:goal-progress="goalProgress"
@restore-user-question="restoreUserQuestionDialog"
@update:input-message="inputSetMessage"
@input-change="handleInputChange"
@ -341,6 +344,8 @@
@guide-runtime-message="handleGuideRuntimeMessage"
@delete-runtime-message="handleDeleteRuntimeMessage"
@composer-height-change="handleComposerHeightChange"
@toggle-goal-mode="handleToggleGoalMode"
@open-goal-dialog="goalDialogOpen = true"
/>
</div>
</main>
@ -363,6 +368,7 @@
:deciding-approval-ids="decidingApprovalIds"
:auto-approval-feed-lines="autoApprovalFeedLines"
:auto-approval-final-message="autoApprovalFinalMessage"
:auto-approval-title="autoApprovalTitle"
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
@approve="approveToolApproval"
@reject="rejectToolApproval"
@ -435,6 +441,11 @@
</transition>
<SubAgentActivityDialog />
<BackgroundCommandDialog />
<GoalProgressDialog
:open="goalDialogOpen"
:progress="goalProgress"
@close="goalDialogOpen = false"
/>
<UserQuestionDialog
:visible="userQuestionDialogVisible && !userQuestionMinimized && pendingUserQuestions.length > 0"
:questions="pendingUserQuestions"

View File

@ -122,7 +122,12 @@ const appOptions = {
inputSetSelectedVideos: 'setSelectedVideos',
inputAddSelectedVideo: 'addSelectedVideo',
inputClearSelectedVideos: 'clearSelectedVideos',
inputRemoveSelectedVideo: 'removeSelectedVideo'
inputRemoveSelectedVideo: 'removeSelectedVideo',
inputToggleGoalArmed: 'toggleGoalArmed',
inputSetGoalArmed: 'setGoalArmed',
inputSetGoalRunning: 'setGoalRunning',
inputSetGoalProgress: 'setGoalProgress',
inputSetGoalDialogOpen: 'setGoalDialogOpen'
}),
...mapActions(useToolStore, {
toolRegisterAction: 'registerToolAction',

View File

@ -39,6 +39,9 @@ const TutorialOverlay = defineAsyncComponent(
const NewUserTutorialPrompt = defineAsyncComponent(
() => import('../components/overlay/NewUserTutorialPrompt.vue')
);
const GoalProgressDialog = defineAsyncComponent(
() => import('../components/overlay/GoalProgressDialog.vue')
);
export const appComponents = {
ChatArea,
@ -59,5 +62,6 @@ export const appComponents = {
HostWorkspaceManageDialog,
UserQuestionDialog,
TutorialOverlay,
NewUserTutorialPrompt
NewUserTutorialPrompt,
GoalProgressDialog
};

View File

@ -91,7 +91,11 @@ export const computed = {
'imagePickerOpen',
'videoPickerOpen',
'selectedImages',
'selectedVideos'
'selectedVideos',
'goalModeArmed',
'goalRunning',
'goalProgress',
'goalDialogOpen'
]),
resolvedRunMode() {
const allowed = ['fast', 'thinking', 'deep'];

View File

@ -115,6 +115,13 @@ export const conversationMethods = {
this.toolSetSettings([]);
this.pendingToolApprovals = [];
this.decidingApprovalIds = [];
this.autoApprovalTitle = '自动审批记录';
// 切换对话/工作区/新建视图时,清理上一轮目标模式的本地完成提示。
// 如果目标任务仍在运行,后续 restoreTaskState 会重新恢复运行态。
this.goalModeArmed = false;
this.goalRunning = false;
this.goalProgress = null;
this.goalDialogOpen = false;
debugLog('前端状态重置完成');
this._scrollListenerReady = false;

View File

@ -782,12 +782,26 @@ export const messageMethods = {
if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents();
}
const startingGoalMode = this.goalModeArmed === true;
if (startingGoalMode) {
this.goalModeArmed = false;
this.goalRunning = true;
this.goalProgress = {
goal: message,
status: 'running',
turn_count: 0,
tokens_used: 0,
tool_calls: 0,
duration_seconds: 0
};
}
await taskStore.createTask(message, images, videos, targetConversationId, {
model_key: this.currentModelKey,
run_mode: this.runMode,
thinking_mode: this.thinkingMode,
message_source: localMessageSource,
goal_mode: startingGoalMode,
eventHandler: (event: any) => this.handleTaskEvent(event)
});
@ -870,6 +884,12 @@ export const messageMethods = {
}
this.stopRequested = true;
this.dropToolEvents = shouldDropToolEvents;
if (this.goalRunning || this.goalModeArmed) {
this.goalRunning = false;
this.goalModeArmed = false;
this.goalProgress = null;
this.goalDialogOpen = false;
}
try {
const { useTaskStore } = await import('../../stores/task');

View File

@ -140,6 +140,30 @@ function getOptimisticUserEchoTarget(messages: any[]): any | null {
return null;
}
function findRecentMatchingUserMessage(messages: any[], message: string, images: any[] = [], videos: any[] = [], source = ''): any | null {
if (!Array.isArray(messages) || !message) {
return null;
}
const normalizedSource = String(source || '').trim().toLowerCase();
for (let i = messages.length - 1, seen = 0; i >= 0 && seen < 12; i -= 1) {
const item = messages[i];
if (!item || item.role !== 'user') {
continue;
}
seen += 1;
const itemSource = String(item?.metadata?.message_source || 'user').trim().toLowerCase();
if (
String(item.content || '').trim() === message &&
JSON.stringify(item.images || []) === JSON.stringify(images || []) &&
JSON.stringify(item.videos || []) === JSON.stringify(videos || []) &&
(!normalizedSource || itemSource === normalizedSource)
) {
return item;
}
}
return null;
}
/**
*
* REST API
@ -179,6 +203,25 @@ export const taskPollingMethods = {
return true;
},
moveTrailingEmptyAssistantPlaceholderAfterUserInsert(reason = 'runtime-user-insert') {
if (!Array.isArray(this.messages) || !this.messages.length) {
return false;
}
const last = this.messages[this.messages.length - 1];
if (!isEmptyAssistantPlaceholderMessage(last)) {
return false;
}
this.messages.pop();
if (typeof this.currentMessageIndex === 'number') {
this.currentMessageIndex = -1;
}
userMDebug('taskPolling.moveTrailingEmptyAssistantPlaceholderAfterUserInsert:removed-before-insert', {
reason,
messagesLengthAfterRemove: this.messages.length
});
return true;
},
ensureRunningAssistantPlaceholder(runningTask: any = null, reason = 'restore-running-task') {
if (!Array.isArray(this.messages) || this.messages.length === 0) {
return false;
@ -647,6 +690,22 @@ export const taskPollingMethods = {
this.handleAutoApprovalProgress(eventData, eventIdx);
break;
case 'goal_progress':
this.handleGoalProgress?.(eventData, eventIdx);
break;
case 'goal_review_progress':
this.handleGoalReviewProgress?.(eventData, eventIdx);
break;
case 'goal_completed':
this.handleGoalCompleted?.(eventData, eventIdx);
break;
case 'goal_stopped':
this.handleGoalStopped?.(eventData, eventIdx);
break;
case 'append_payload':
this.handleAppendPayload(eventData, eventIdx);
break;
@ -1474,6 +1533,7 @@ export const taskPollingMethods = {
if (!Array.isArray(this.autoApprovalFeedLines)) {
this.autoApprovalFeedLines = [];
}
this.autoApprovalTitle = '自动审批记录';
if (progress.stage === 'start') {
this.autoApprovalFeedLines = ['自动审批开始'];
this.autoApprovalFinalMessage = '';
@ -1814,6 +1874,89 @@ export const taskPollingMethods = {
});
},
// 目标模式:运行中进度
handleGoalProgress(data: any) {
debugLog('[Goal] 目标进度更新', data);
const status = String(data?.status || 'running').toLowerCase();
this.goalRunning = status === 'running';
this.goalModeArmed = false;
this.goalProgress = {
...(data || {}),
goal: data?.goal || this.goalProgress?.goal || '',
status,
turn_count: Number(data?.turn_count ?? 0),
tokens_used: Number(data?.tokens_used ?? 0),
tool_calls: Number(data?.tool_calls ?? 0),
duration_seconds: Number(data?.duration_seconds ?? 0)
};
},
// 目标模式:最后审核进度,复用审批侧边栏的展示形态
handleGoalReviewProgress(data: any) {
const progress = data?.progress || data || {};
if (!progress || typeof progress !== 'object') {
return;
}
if (!Array.isArray(this.autoApprovalFeedLines)) {
this.autoApprovalFeedLines = [];
}
this.autoApprovalTitle = '目标审批';
if (progress.stage === 'start') {
this.autoApprovalFeedLines = ['开始审核'];
this.autoApprovalFinalMessage = '';
} else if (progress.stage === 'model_call') {
this.autoApprovalFeedLines.push(String(progress.message || `审核轮次 ${progress.round || ''}`).trim());
} else if (progress.stage === 'run_command' && progress.command) {
this.autoApprovalFeedLines.push(String(progress.command));
} else if (progress.message) {
this.autoApprovalFeedLines.push(String(progress.message));
}
this.autoApprovalFeedLines = this.autoApprovalFeedLines.slice(-20);
this.rightCollapsed = false;
if (this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
this.$forceUpdate();
},
scheduleGoalApprovalPanelAutoClose() {
if (this.isMobileViewport || this.autoApprovalTitle !== '目标审批') {
return;
}
if (this.approvalAutoCloseTimer) {
clearTimeout(this.approvalAutoCloseTimer);
}
this.approvalAutoCloseTimer = setTimeout(() => {
this.rightCollapsed = true;
}, 3000);
},
// 目标模式:目标达成
handleGoalCompleted(data: any) {
debugLog('[Goal] 目标已达成', data);
this.goalRunning = false;
this.goalModeArmed = false;
this.goalProgress = { ...(data || {}), status: 'done' };
this.goalDialogOpen = true;
this.scheduleGoalApprovalPanelAutoClose?.();
},
// 目标模式:目标停止(空转/轮数/token/用户取消)
handleGoalStopped(data: any) {
debugLog('[Goal] 目标已停止', data);
this.goalRunning = false;
this.goalModeArmed = false;
if (String(data?.stopped_reason || '').toLowerCase() === 'user_cancel') {
this.goalProgress = null;
this.goalDialogOpen = false;
return;
}
this.goalProgress = { ...(data || {}), status: 'stopped' };
// 停止也弹一次窗,告知用户原因与进度
this.goalDialogOpen = true;
this.scheduleGoalApprovalPanelAutoClose?.();
},
handleRuntimeQueueSync(data: any) {
const messages = Array.isArray(data?.messages) ? data.messages : [];
const buildSnapshotKey =
@ -1899,6 +2042,8 @@ export const taskPollingMethods = {
background_command_notice: !!data?.background_command_notice
});
const isAutoUserMessage = isSystemAutoUserMessagePayload(data);
const isRuntimeModeNotice = isRuntimeModeNoticePayload(data);
const source = resolveUserMessageSource(data);
const incomingImages = Array.isArray(data?.images) ? data.images : [];
const incomingVideos = Array.isArray(data?.videos) ? data.videos : [];
const incomingMediaRefs = Array.isArray(data?.media_refs)
@ -1908,6 +2053,13 @@ export const taskPollingMethods = {
: [];
if (!isAutoUserMessage) {
const last = getOptimisticUserEchoTarget(this.messages || []);
const recentMatch = findRecentMatchingUserMessage(
this.messages || [],
message,
incomingImages,
incomingVideos,
source
);
const isLikelyOptimisticEcho = !!(
last &&
last.role === 'user' &&
@ -1915,10 +2067,11 @@ export const taskPollingMethods = {
JSON.stringify(last.images || []) === JSON.stringify(incomingImages || []) &&
JSON.stringify(last.videos || []) === JSON.stringify(incomingVideos || [])
);
if (isLikelyOptimisticEcho) {
last.media_refs = incomingMediaRefs;
last.metadata = {
...(last.metadata || {}),
if (isLikelyOptimisticEcho || recentMatch) {
const target = isLikelyOptimisticEcho ? last : recentMatch;
target.media_refs = incomingMediaRefs;
target.metadata = {
...(target.metadata || {}),
media_refs: incomingMediaRefs
};
} else {
@ -1927,7 +2080,7 @@ export const taskPollingMethods = {
incomingImages,
incomingVideos,
incomingMediaRefs,
resolveUserMessageSource(data)
source
);
}
this.taskInProgress = true;
@ -1939,7 +2092,6 @@ export const taskPollingMethods = {
: false;
this.stopRequested = false;
} else {
const source = resolveUserMessageSource(data);
// 仅在对话运行期间展示 runtime_mode_notice其余自动消息按来源正常显示
// 保持与后端历史重建一致guidance/notify/sub_agent/background_command
if (isRuntimeModeNotice && !(this.taskInProgress || this.streamingMessage)) {
@ -1947,13 +2099,38 @@ export const taskPollingMethods = {
messagePreview: message.slice(0, 80)
});
} else {
this.chatAddUserMessage(
const shouldRestoreWaitingPlaceholder =
this.moveTrailingEmptyAssistantPlaceholderAfterUserInsert('auto_user_message') &&
(this.taskInProgress || this.streamingMessage);
const recentMatch = findRecentMatchingUserMessage(
this.messages || [],
message,
incomingImages,
incomingVideos,
incomingMediaRefs,
source
);
if (recentMatch) {
recentMatch.media_refs = incomingMediaRefs;
recentMatch.metadata = {
...(recentMatch.metadata || {}),
media_refs: incomingMediaRefs,
message_source: source
};
} else {
this.chatAddUserMessage(
message,
incomingImages,
incomingVideos,
incomingMediaRefs,
source
);
}
if (shouldRestoreWaitingPlaceholder) {
this.chatStartAssistantMessage();
this.taskInProgress = true;
this.streamingMessage = true;
this.stopRequested = false;
}
}
}
if (data?.sub_agent_notice) {
@ -2156,6 +2333,26 @@ export const taskPollingMethods = {
if (data.title) {
this.currentConversationTitle = data.title;
}
if (data.title && Array.isArray(this.runningWorkspaceTasks)) {
const taskId = String(data.task_id || '').trim();
const sourceConversationId = String(data.source_conversation_id || '').trim();
const targetConversationId = String(data.conversation_id || '').trim();
this.runningWorkspaceTasks = this.runningWorkspaceTasks.map((task: any) => {
const sameTask = taskId && String(task?.task_id || '') === taskId;
const sameSourceConversation =
sourceConversationId && String(task?.conversation_id || '') === sourceConversationId;
const sameTargetConversation =
targetConversationId && String(task?.conversation_id || '') === targetConversationId;
if (!sameTask && !sameSourceConversation && !sameTargetConversation) {
return task;
}
return {
...task,
conversation_id: targetConversationId || task?.conversation_id,
conversation_title: data.title
};
});
}
this.promoteConversationToTop(data.conversation_id);
const pathFragment = this.stripConversationPrefix(data.conversation_id);
@ -2216,6 +2413,11 @@ export const taskPollingMethods = {
if (typeof this.loadConversationsList === 'function') {
await this.loadConversationsList();
}
await this.refreshRunningWorkspaceTasks?.();
// 压缩会把同一个后台任务从旧 conversation 迁移到新 conversation。
// 切换对话时本地轮询会被清理,这里必须立即按新会话恢复轮询,
// 否则后续进度需要手动刷新页面才会继续显示。
await this.restoreTaskState?.();
this.uiPushToast({
title: '压缩完成',
message: '已自动切换到压缩后的新对话',
@ -2316,6 +2518,26 @@ export const taskPollingMethods = {
status: runningTask?.status,
conversationId: runningTask?.conversation_id
});
if (runningTask?.goal_mode) {
const existingGoalProgress = this.goalProgress && typeof this.goalProgress === 'object'
? this.goalProgress
: {};
const taskGoalProgress = runningTask?.goal_progress && typeof runningTask.goal_progress === 'object'
? runningTask.goal_progress
: {};
this.goalRunning = true;
this.goalModeArmed = false;
this.goalProgress = {
...existingGoalProgress,
...taskGoalProgress,
goal: taskGoalProgress?.goal || existingGoalProgress?.goal || runningTask?.message || '',
status: 'running',
turn_count: Number(taskGoalProgress?.turn_count ?? existingGoalProgress?.turn_count ?? 0),
tokens_used: Number(taskGoalProgress?.tokens_used ?? existingGoalProgress?.tokens_used ?? 0),
tool_calls: Number(taskGoalProgress?.tool_calls ?? existingGoalProgress?.tool_calls ?? 0),
duration_seconds: Number(taskGoalProgress?.duration_seconds ?? existingGoalProgress?.duration_seconds ?? 0)
};
}
restoreDebugLog('restore:running-task-found', {
taskId: runningTask?.task_id,
status: runningTask?.status,

View File

@ -173,6 +173,10 @@ export const uiMethods = {
this.runtimeQueueAutoSendInProgress = false;
this.runtimeQueueSyncLockKey = '';
this.runtimeQueueSyncLockUntil = 0;
this.goalModeArmed = false;
this.goalRunning = false;
this.goalProgress = null;
this.goalDialogOpen = false;
if (typeof this.clearRuntimeQueueSuppressionState === 'function') {
this.clearRuntimeQueueSuppressionState();
} else {
@ -1327,6 +1331,28 @@ export const uiMethods = {
this.modelMenuOpen = false;
},
// 目标模式:在 + 菜单点击“目标模式”——切换“已就绪”,运行中不可切换
handleToggleGoalMode() {
if (!this.isConnected) {
return;
}
if (this.goalRunning) {
// 运行中点击 → 打开进度弹窗,而非切换
this.goalDialogOpen = true;
this.inputCloseMenus();
return;
}
const goalStatus = String(this.goalProgress?.status || '').toLowerCase();
if (goalStatus === 'done' || goalStatus === 'stopped') {
this.goalProgress = null;
this.goalDialogOpen = false;
}
this.inputToggleGoalArmed();
this.inputCloseMenus();
this.modeMenuOpen = false;
this.modelMenuOpen = false;
},
toggleModeMenu() {
if (!this.isConnected || this.streamingMessage) {
return;

View File

@ -187,6 +187,7 @@ export function dataState() {
userQuestionTitleBlinkRed: true,
autoApprovalFeedLines: [],
autoApprovalFinalMessage: '',
autoApprovalTitle: '自动审批记录',
approvalAutoCloseTimer: null,
imageEntries: [],
imageLoading: false,

View File

@ -1275,11 +1275,8 @@ function assistantWorkLabel(index: number): string {
if (index <= 0) {
return '';
}
const prev = filteredMessages.value[index - 1];
if (!prev || prev.role !== 'user') {
return '';
}
const timer = prev?.metadata?.work_timer;
const workUser = findAssistantHeaderAnchorUser(index);
const timer = workUser?.metadata?.work_timer;
if (!timer || !timer.started_at) {
return '';
}
@ -1304,17 +1301,33 @@ function assistantWorkLabel(index: number): string {
}
function shouldShowAssistantHeader(index: number): boolean {
return !!findAssistantHeaderAnchorUser(index);
}
function findAssistantHeaderAnchorUser(index: number): any | null {
if (index <= 0) {
return false;
return null;
}
const prev = filteredMessages.value[index - 1];
if (!prev || prev.role !== 'user') {
return false;
return null;
}
const source = String(prev?.metadata?.message_source || 'user')
.trim()
.toLowerCase();
return ['user', 'presend', 'sub_agent', 'background_command'].includes(source);
// assistant user
// guidance/goal user user 沿
// user
for (let i = index - 1; i >= 0; i -= 1) {
const item = filteredMessages.value[i];
if (!item || item.role !== 'user') {
break;
}
const source = String(item?.metadata?.message_source || 'user')
.trim()
.toLowerCase();
if (['user', 'presend', 'sub_agent', 'background_command'].includes(source)) {
return item;
}
}
return null;
}
function userHeaderSource(msg: any): string {
@ -1328,6 +1341,9 @@ function userHeaderLabel(msg: any): string {
if (source === 'guidance') {
return '引导';
}
if (source === 'goal') {
return '目标';
}
if (source === 'notify' || source === 'sub_agent' || source === 'background_command') {
return '通知';
}
@ -1339,6 +1355,9 @@ function userHeaderIconKey(msg: any): string {
if (source === 'guidance') {
return 'navigation';
}
if (source === 'goal') {
return 'flag';
}
if (source === 'notify' || source === 'sub_agent' || source === 'background_command') {
return 'bell';
}

View File

@ -1,6 +1,25 @@
<template>
<div class="input-area compact-input-area" ref="inputAreaRoot">
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
<transition name="goal-banner-fade">
<div
v-if="goalModeArmed || goalRunning || goalCompleted"
class="goal-mode-banner"
:class="{
'goal-mode-banner--running': goalRunning,
'goal-mode-banner--done': goalCompleted && !goalRunning,
'goal-mode-banner--clickable': goalRunning || goalCompleted
}"
@click.stop="goalRunning || goalCompleted ? $emit('open-goal-dialog') : null"
>
<span class="goal-mode-banner__dot" aria-hidden="true"></span>
<span class="goal-mode-banner__text">
<template v-if="goalRunning">目标模式运行中</template>
<template v-else-if="goalCompleted">目标模式完成</template>
<template v-else>目标模式已就绪</template>
</span>
</div>
</transition>
<div
class="runtime-queue-list"
:class="{ 'runtime-queue-list--empty': !runtimeQueuedMessagesForRender.length }"
@ -155,6 +174,8 @@
:block-compress-conversation="blockCompressConversation"
:block-conversation-review="blockConversationReview"
:execution-mode-enabled="executionModeEnabled"
:goal-mode-armed="goalModeArmed"
:goal-running="goalRunning"
@quick-upload="triggerQuickUpload"
@pick-images="$emit('pick-images')"
@pick-video="$emit('pick-video')"
@ -172,6 +193,7 @@
@toggle-approval-panel="$emit('toggle-approval-panel')"
@open-review="$emit('open-review')"
@open-path-authorization="$emit('open-path-authorization')"
@toggle-goal-mode="$emit('toggle-goal-mode')"
/>
<div class="permission-switcher" @click.stop>
<button
@ -311,7 +333,9 @@ const emit = defineEmits([
'guide-runtime-message',
'delete-runtime-message',
'composer-height-change',
'restore-user-question'
'restore-user-question',
'toggle-goal-mode',
'open-goal-dialog'
]);
const props = defineProps<{
@ -366,6 +390,9 @@ const props = defineProps<{
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
userQuestionMinimized?: boolean;
pendingUserQuestionCount?: number;
goalModeArmed?: boolean;
goalRunning?: boolean;
goalProgress?: Record<string, any> | null;
}>();
const inputStore = useInputStore();
@ -676,9 +703,11 @@ const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasQueue || hasImages || hasVideos || !!props.inputIsMultiline;
return hasQueue || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
});
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
const collectComposerVisualHeight = () => {
const root = inputAreaRoot.value;
const shell = compactInputShell.value;
@ -688,7 +717,7 @@ const collectComposerVisualHeight = () => {
const shellRect = shell.getBoundingClientRect();
let top = shellRect.top;
let bottom = shellRect.bottom;
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty)');
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .goal-mode-banner');
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const rect = node.getBoundingClientRect();
@ -934,6 +963,79 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* 目标模式横幅:与 + 按钮左对齐,置于输入框上方 */
.goal-mode-banner {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 6px 4px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
color: var(--claude-text-secondary, #7f7766);
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 92%, var(--claude-text-tertiary, #a59a86) 8%);
border: 1px solid var(--theme-chip-border, rgba(118, 103, 84, 0.2));
user-select: none;
}
.goal-mode-banner--running {
color: var(--claude-accent, #da7756);
border-color: var(--claude-accent, #da7756);
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-accent, #da7756) 18%);
}
.goal-mode-banner--done {
color: var(--claude-success, #76b086);
border-color: var(--claude-success, #76b086);
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-success, #76b086) 18%);
}
:global(:root[data-theme='dark']) .goal-mode-banner,
:global(body[data-theme='dark']) .goal-mode-banner {
color: var(--claude-text-secondary, #a0a0a0);
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 82%, var(--claude-text-tertiary, #707070) 18%);
border-color: var(--theme-control-border-strong, rgba(255, 255, 255, 0.12));
}
:global(:root[data-theme='dark']) .goal-mode-banner--running,
:global(body[data-theme='dark']) .goal-mode-banner--running {
color: #e5e7eb;
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-accent, #606060) 28%);
border-color: color-mix(in srgb, var(--claude-accent, #606060) 70%, white 30%);
}
:global(:root[data-theme='dark']) .goal-mode-banner--done,
:global(body[data-theme='dark']) .goal-mode-banner--done {
color: #d1fae5;
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-success, #10b981) 28%);
border-color: color-mix(in srgb, var(--claude-success, #10b981) 70%, white 30%);
}
.goal-mode-banner--clickable {
cursor: pointer;
}
.goal-mode-banner__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--claude-text-tertiary, #a59a86);
}
.goal-mode-banner--running .goal-mode-banner__dot {
background: var(--claude-accent, #da7756);
animation: goal-banner-pulse 1.4s ease-in-out infinite;
}
.goal-mode-banner--done .goal-mode-banner__dot {
background: var(--claude-success, #76b086);
}
@keyframes goal-banner-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.goal-banner-fade-enter-active,
.goal-banner-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.goal-banner-fade-enter-from,
.goal-banner-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
.image-inline-row {
display: flex;
flex-wrap: wrap;

View File

@ -49,6 +49,20 @@
工具禁用
<span class="entry-arrow"></span>
</button>
<button
type="button"
class="menu-entry"
:class="{ 'goal-entry-active': goalModeArmed || goalRunning || goalCompleted }"
@click.stop="$emit('toggle-goal-mode')"
:disabled="!isConnected"
>
目标模式
<span class="entry-arrow">
<template v-if="goalRunning">运行中</template>
<template v-else-if="goalCompleted">完成</template>
<template v-else>{{ goalModeArmed ? '已就绪' : '' }}</template>
</span>
</button>
<button
type="button"
class="menu-entry has-submenu"
@ -183,6 +197,9 @@ const props = defineProps<{
blockCompressConversation?: boolean;
blockConversationReview?: boolean;
executionModeEnabled?: boolean;
goalModeArmed?: boolean;
goalRunning?: boolean;
goalProgress?: Record<string, any> | null;
}>();
defineEmits<{
@ -201,6 +218,7 @@ defineEmits<{
(event: 'open-review'): void;
(event: 'pick-video'): void;
(event: 'open-path-authorization'): void;
(event: 'toggle-goal-mode'): void;
}>();
const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
@ -214,6 +232,8 @@ const currentModelSupportsVideo = computed(() => {
const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
return !!found?.supportsVideo;
});
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
</script>
<style scoped>
@ -223,4 +243,11 @@ const currentModelSupportsVideo = computed(() => {
color: var(--text-secondary, #7f8792);
margin-top: 2px;
}
.menu-entry.goal-entry-active {
color: var(--claude-accent, #da7756);
}
.menu-entry.goal-entry-active .entry-arrow {
color: var(--claude-accent, #da7756);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,272 @@
<template>
<transition name="overlay-fade">
<div v-if="open" class="subagent-activity-overlay" @click.self="$emit('close')">
<div class="subagent-activity-modal goal-progress-modal">
<div class="subagent-activity-header">
<div class="subagent-activity-title">
{{ isDone ? '目标已完成' : isStopped ? '目标模式停止' : '目标模式进行中' }}
</div>
<button type="button" class="subagent-activity-close" @click="$emit('close')">×</button>
</div>
<div class="goal-progress-meta">
<span class="goal-progress-status" :class="statusClass">{{ statusLabel }}</span>
<span v-if="goal" class="goal-progress-goal" :title="goal">目标{{ goal }}</span>
</div>
<div class="goal-progress-metrics">
<div class="goal-metric">
<div class="goal-metric__value">{{ turnCount }}</div>
<div class="goal-metric__label">已运行轮数</div>
</div>
<div class="goal-metric">
<div class="goal-metric__value">{{ formattedTokens }}</div>
<div class="goal-metric__label">消耗 token</div>
</div>
<div class="goal-metric">
<div class="goal-metric__value">{{ toolCalls }}</div>
<div class="goal-metric__label">工具调用</div>
</div>
<div class="goal-metric">
<div class="goal-metric__value">{{ formattedDuration }}</div>
<div class="goal-metric__label">用时</div>
</div>
</div>
<div v-if="isStopped && stoppedReasonLabel" class="goal-progress-reason">
停止原因{{ stoppedReasonLabel }}
</div>
<div v-if="summary" class="goal-progress-summary">
<div class="goal-progress-summary__title">{{ isDone ? '完成总结' : '最后进展' }}</div>
<div class="goal-progress-summary__body">{{ summary }}</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
defineOptions({ name: 'GoalProgressDialog' });
const props = defineProps<{
open: boolean;
progress: Record<string, any> | null;
}>();
defineEmits<{ (event: 'close'): void }>();
const status = computed(() => (props.progress?.status || 'running') as string);
const isDone = computed(() => status.value === 'done');
const isStopped = computed(() => status.value === 'stopped');
const statusLabel = computed(() => {
if (isDone.value) return '已完成';
if (isStopped.value) return '已停止';
return '运行中';
});
const statusClass = computed(() => ({
'is-done': isDone.value,
'is-stopped': isStopped.value,
'is-running': !isDone.value && !isStopped.value
}));
const goal = computed(() => (props.progress?.goal || '').toString());
const turnCount = computed(() => Number(props.progress?.turn_count ?? 0));
const toolCalls = computed(() => Number(props.progress?.tool_calls ?? 0));
const summary = computed(() => (props.progress?.summary || props.progress?.final_summary || '').toString());
const durationBaseSeconds = ref(0);
const durationObservedAtMs = ref(Date.now());
const nowMs = ref(Date.now());
let durationTimer: ReturnType<typeof window.setInterval> | null = null;
const syncDurationBase = () => {
const raw = Number(props.progress?.duration_seconds ?? 0);
durationBaseSeconds.value = Number.isFinite(raw) && raw > 0 ? raw : 0;
durationObservedAtMs.value = Date.now();
nowMs.value = durationObservedAtMs.value;
};
const ensureDurationTimer = () => {
if (durationTimer || !props.open || isDone.value || isStopped.value) return;
durationTimer = window.setInterval(() => {
nowMs.value = Date.now();
}, 1000);
};
const clearDurationTimer = () => {
if (durationTimer) {
window.clearInterval(durationTimer);
durationTimer = null;
}
};
watch(
() => [props.progress?.duration_seconds, props.progress?.status, props.open],
() => {
syncDurationBase();
if (props.open && !isDone.value && !isStopped.value) {
ensureDurationTimer();
} else {
clearDurationTimer();
}
},
{ immediate: true }
);
onBeforeUnmount(clearDurationTimer);
const formattedTokens = computed(() => {
const n = Number(props.progress?.tokens_used ?? 0);
if (!Number.isFinite(n)) return '0';
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return `${n}`;
});
const formattedDuration = computed(() => {
const liveExtra = !isDone.value && !isStopped.value
? Math.max(0, (nowMs.value - durationObservedAtMs.value) / 1000)
: 0;
const s = durationBaseSeconds.value + liveExtra;
if (!Number.isFinite(s) || s <= 0) return '0s';
if (s < 60) return `${Math.round(s)}s`;
const m = Math.floor(s / 60);
const rem = Math.round(s % 60);
if (m < 60) return `${m}m${rem}s`;
const h = Math.floor(m / 60);
return `${h}h${m % 60}m`;
});
const stoppedReasonLabel = computed(() => {
const map: Record<string, string> = {
idle_no_tool: '主模型停止时未调用任何工具(可能卡住)',
max_turns: '已达到最大轮数上限',
max_tokens: '已达到累计 token 上限',
user_cancel: '用户手动停止'
};
const reason = (props.progress?.stopped_reason || '').toString();
return map[reason] || reason;
});
</script>
<style scoped>
.goal-progress-modal {
min-width: 360px;
max-width: 520px;
}
.goal-progress-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 4px 0 12px;
}
.goal-progress-status {
font-size: 12px;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--theme-chip-border, rgba(118, 103, 84, 0.2));
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 92%, var(--claude-text-tertiary, #a59a86) 8%);
color: var(--claude-text-secondary, #7f7766);
}
.goal-progress-status.is-running {
color: var(--claude-accent, #da7756);
border-color: var(--claude-accent, #da7756);
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-accent, #da7756) 18%);
}
.goal-progress-status.is-done {
color: var(--claude-success, #76b086);
border-color: var(--claude-success, #76b086);
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-success, #76b086) 18%);
}
.goal-progress-status.is-stopped {
color: var(--claude-warning, #d99845);
border-color: var(--claude-warning, #d99845);
background: color-mix(in srgb, var(--theme-surface-soft, #fffaf0) 82%, var(--claude-warning, #d99845) 18%);
}
:global(:root[data-theme='dark']) .goal-progress-status,
:global(body[data-theme='dark']) .goal-progress-status {
color: var(--claude-text-secondary, #a0a0a0);
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 82%, var(--claude-text-tertiary, #707070) 18%);
border-color: var(--theme-control-border-strong, rgba(255, 255, 255, 0.12));
}
:global(:root[data-theme='dark']) .goal-progress-status.is-running,
:global(body[data-theme='dark']) .goal-progress-status.is-running {
color: #e5e7eb;
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-accent, #606060) 28%);
border-color: color-mix(in srgb, var(--claude-accent, #606060) 70%, white 30%);
}
:global(:root[data-theme='dark']) .goal-progress-status.is-done,
:global(body[data-theme='dark']) .goal-progress-status.is-done {
color: #d1fae5;
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-success, #10b981) 28%);
border-color: color-mix(in srgb, var(--claude-success, #10b981) 70%, white 30%);
}
:global(:root[data-theme='dark']) .goal-progress-status.is-stopped,
:global(body[data-theme='dark']) .goal-progress-status.is-stopped {
color: #ffedd5;
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 72%, var(--claude-warning, #f59e0b) 28%);
border-color: color-mix(in srgb, var(--claude-warning, #f59e0b) 70%, white 30%);
}
.goal-progress-goal {
font-size: 13px;
color: var(--claude-text-secondary, #7f7766);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.goal-progress-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.goal-metric {
text-align: center;
padding: 10px 6px;
border-radius: 10px;
background: var(--theme-surface-muted, rgba(255, 255, 255, 0.85));
border: 1px solid var(--theme-control-border, rgba(118, 103, 84, 0.25));
}
.goal-metric__value {
font-size: 18px;
font-weight: 600;
color: var(--claude-text, #3d3929);
}
.goal-metric__label {
margin-top: 4px;
font-size: 11px;
color: var(--claude-text-tertiary, #a59a86);
}
.goal-progress-reason {
font-size: 13px;
color: var(--claude-warning, #d99845);
margin-bottom: 10px;
}
.goal-progress-summary {
border-top: 1px solid var(--theme-control-border, rgba(118, 103, 84, 0.25));
padding-top: 10px;
}
.goal-progress-summary__title {
font-size: 12px;
color: var(--claude-text-tertiary, #a59a86);
margin-bottom: 6px;
}
.goal-progress-summary__body {
font-size: 13px;
line-height: 1.6;
color: var(--claude-text, #3d3929);
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow-y: auto;
}
@media (max-width: 520px) {
.goal-progress-metrics {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -5,13 +5,13 @@
:style="{ width: collapsed ? '0px' : width + 'px' }"
>
<div class="sidebar-header" :class="{ 'mobile-header': isMobileViewport }">
<h3 class="icon-label">工具审批 ({{ approvals.length }})</h3>
<h3 class="icon-label">{{ panelTitle }}</h3>
<button type="button" class="approval-close-btn" aria-label="关闭审批面板" @click="handleCloseClick">
×
</button>
</div>
<div class="approval-panel-body" v-if="!collapsed">
<div v-if="!approvals.length" class="no-files">暂无待审批操作</div>
<div v-if="!approvals.length && !isGoalApprovalMode" class="no-files">暂无待审批操作</div>
<div v-else class="approval-list">
<div v-for="item in approvals" :key="item.approval_id" class="approval-card">
<div class="approval-card__title">{{ item.tool_name }}</div>
@ -104,7 +104,7 @@
</div>
</div>
<div v-if="autoApprovalFeedLines.length || autoApprovalFinalMessage" class="auto-approval-block">
<div class="auto-approval-block__title">自动审批记录</div>
<div v-if="!isGoalApprovalMode" class="auto-approval-block__title">{{ autoApprovalTitle }}</div>
<pre class="auto-approval-block__content">{{ autoApprovalFeedLines.join('\n') }}</pre>
<div v-if="autoApprovalFinalMessage" class="auto-approval-block__final">
<div
@ -137,8 +137,15 @@ const props = defineProps<{
decidingApprovalIds?: string[];
autoApprovalFeedLines?: string[];
autoApprovalFinalMessage?: string;
autoApprovalTitle?: string;
}>();
const autoApprovalTitle = computed(() => props.autoApprovalTitle || '自动审批记录');
const isGoalApprovalMode = computed(() => autoApprovalTitle.value === '目标审批');
const panelTitle = computed(() =>
isGoalApprovalMode.value ? '目标审批' : `工具审批 (${props.approvals.length})`
);
const emit = defineEmits<{
(event: 'approve', approvalId: string): void;
(event: 'reject', approvalId: string): void;
@ -239,9 +246,9 @@ const parseFinalMessage = (text: string) => {
.auto-approval-block {
margin-top: 12px;
padding: 10px;
border: 1px solid var(--border-color, #2a2f3a);
border: 1px solid var(--theme-control-border, var(--border-color, #2a2f3a));
border-radius: 8px;
background: var(--panel-bg, rgba(0, 0, 0, 0.12));
background: var(--theme-surface-muted, var(--panel-bg, rgba(0, 0, 0, 0.12)));
}
.auto-approval-block__title {
@ -281,4 +288,14 @@ const parseFinalMessage = (text: string) => {
white-space: pre-wrap;
word-break: break-word;
}
:global(:root[data-theme='dark']) .auto-approval-block,
:global(body[data-theme='dark']) .auto-approval-block {
background: color-mix(in srgb, var(--theme-surface-muted, #141414) 86%, white 4%);
border-color: var(--theme-control-border-strong, rgba(255, 255, 255, 0.12));
color: var(--claude-text, #ffffff);
}
:global(:root[data-theme='dark']) .auto-approval-block__content,
:global(body[data-theme='dark']) .auto-approval-block__content {
color: var(--claude-text-secondary, #a0a0a0);
}
</style>

View File

@ -704,6 +704,95 @@
class="fancy-path"
></path></svg></span
></label>
<div class="settings-section-divider">
<span class="settings-section-divider__label">目标模式</span>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">审核智能体主动取证</span
><span class="settings-row-desc"
>开启后审核智能体可运行只读命令查看文件跑测试来核实目标是否达成关闭则仅依据对话内容判断</span
></span
><input
type="checkbox"
:checked="form.goal_review_mode === 'active'"
@change="
personalization.updateField({
key: 'goal_review_mode',
value: $event.target.checked ? 'active' : 'readonly'
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">最大自动续命轮数</span
><span class="settings-row-desc"
>目标模式下主模型停下后最多自动继续的轮数1-100</span
></span
>
<input
type="number"
class="settings-number-input"
min="1"
max="100"
:value="form.goal_max_turns"
@change="
personalization.updateField({
key: 'goal_max_turns',
value: clampGoalMaxTurns($event.target.value)
})
"
/>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">启用累计 token 上限</span
><span class="settings-row-desc"
>开启后目标模式累计消耗输入+输出超过下方上限即停止</span
></span
><input
type="checkbox"
:checked="goalTokenLimitEnabled"
@change="toggleGoalTokenLimit($event.target.checked)" /><span
class="fancy-check"
aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div v-if="goalTokenLimitEnabled" class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">累计 token 上限</span
><span class="settings-row-desc">达到该累计消耗即停止目标模式</span></span
>
<input
type="number"
class="settings-number-input"
min="1000"
step="1000"
:value="form.goal_max_tokens || 100000"
@change="
personalization.updateField({
key: 'goal_max_tokens',
value: clampGoalMaxTokens($event.target.value)
})
"
/>
</div>
</section>
<section
@ -1321,6 +1410,30 @@ const {
experiments
} = storeToRefs(personalization);
// ---- ----
const goalTokenLimitEnabled = computed(
() => typeof form.value.goal_max_tokens === 'number' && form.value.goal_max_tokens > 0
);
const clampGoalMaxTurns = (raw: any): number => {
const n = Math.round(Number(raw));
if (!Number.isFinite(n)) return 5;
return Math.min(100, Math.max(1, n));
};
const clampGoalMaxTokens = (raw: any): number => {
const n = Math.round(Number(raw));
if (!Number.isFinite(n)) return 100000;
return Math.min(100000000, Math.max(1000, n));
};
const toggleGoalTokenLimit = (enabled: boolean) => {
personalization.updateField({
key: 'goal_max_tokens',
value: enabled ? form.value.goal_max_tokens || 100000 : null
});
};
type IconKey = keyof typeof ICONS;
type PersonalTab =
@ -2465,6 +2578,7 @@ const applyThemeOption = async (theme: ThemeKey) => {
.settings-input-row input,
.settings-add-row input,
.settings-number-row input,
.settings-number-input,
.settings-compression-grid input {
height: 38px;
border: 1px solid var(--theme-control-border, var(--claude-border));
@ -2476,6 +2590,30 @@ const applyThemeOption = async (theme: ThemeKey) => {
font-size: 13px;
}
.settings-number-input {
width: 120px;
text-align: right;
}
.settings-section-divider {
display: flex;
align-items: center;
margin: 18px 0 6px;
}
.settings-section-divider__label {
font-size: 12px;
font-weight: 600;
color: var(--claude-text-secondary, #7f7766);
letter-spacing: 0.04em;
}
.settings-section-divider::after {
content: '';
flex: 1;
height: 1px;
margin-left: 10px;
background: var(--theme-control-border, var(--claude-border));
}
.settings-input-row > input {
width: min(360px, 40vw);
text-align: right;
@ -2484,6 +2622,7 @@ const applyThemeOption = async (theme: ThemeKey) => {
.settings-input-row input:focus,
.settings-add-row input:focus,
.settings-number-row input:focus,
.settings-number-input:focus,
.settings-compression-grid input:focus {
border-color: var(--claude-text-secondary);
box-shadow: none;

View File

@ -12,6 +12,11 @@ interface InputState {
selectedImages: string[];
videoPickerOpen: boolean;
selectedVideos: string[];
// 目标模式Goal Mode
goalModeArmed: boolean; // 已就绪:下一条消息作为目标发送
goalRunning: boolean; // 运行中:目标循环正在进行
goalProgress: Record<string, any> | null; // 最新进度快照(轮数/token/工具次数/用时/总结等)
goalDialogOpen: boolean; // 进度/完成弹窗
}
export const useInputStore = defineStore('input', {
@ -26,7 +31,11 @@ export const useInputStore = defineStore('input', {
imagePickerOpen: false,
selectedImages: [],
videoPickerOpen: false,
selectedVideos: []
selectedVideos: [],
goalModeArmed: false,
goalRunning: false,
goalProgress: null,
goalDialogOpen: false
}),
actions: {
setInputMessage(value: string) {
@ -110,6 +119,28 @@ export const useInputStore = defineStore('input', {
},
clearSelectedVideos() {
this.selectedVideos = [];
},
// ---- 目标模式 ----
toggleGoalArmed() {
// 运行中不允许通过开关切换
if (this.goalRunning) return this.goalModeArmed;
this.goalModeArmed = !this.goalModeArmed;
return this.goalModeArmed;
},
setGoalArmed(armed: boolean) {
this.goalModeArmed = armed;
},
setGoalRunning(running: boolean) {
this.goalRunning = running;
if (running) {
this.goalModeArmed = false;
}
},
setGoalProgress(progress: Record<string, any> | null) {
this.goalProgress = progress;
},
setGoalDialogOpen(open: boolean) {
this.goalDialogOpen = open;
}
}
});

View File

@ -45,6 +45,10 @@ interface PersonalForm {
agents_md_auto_inject: boolean;
allow_root_file_creation: boolean;
theme: 'classic' | 'light' | 'dark';
goal_review_mode: 'readonly' | 'active';
goal_end_conditions: string[];
goal_max_turns: number;
goal_max_tokens: number | null;
}
interface ExperimentState {
@ -130,7 +134,11 @@ const defaultForm = (): PersonalForm => ({
deep_compress_trigger_tokens: null,
agents_md_auto_inject: false,
allow_root_file_creation: false,
theme: loadCachedTheme()
theme: loadCachedTheme(),
goal_review_mode: 'readonly',
goal_end_conditions: ['max_turns'],
goal_max_turns: 5,
goal_max_tokens: null
});
const defaultExperimentState = (): ExperimentState => ({
@ -320,7 +328,21 @@ export const usePersonalizationStore = defineStore('personalization', {
),
agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation,
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
goal_end_conditions: Array.isArray(data.goal_end_conditions)
? data.goal_end_conditions.filter(
(x: any) => x === 'max_turns' || x === 'max_tokens'
)
: ['max_turns'],
goal_max_turns:
typeof data.goal_max_turns === 'number' && data.goal_max_turns > 0
? data.goal_max_turns
: 5,
goal_max_tokens:
typeof data.goal_max_tokens === 'number' && data.goal_max_tokens > 0
? data.goal_max_tokens
: null
};
// 如果theme发生变化应用到界面
const currentTheme = (typeof window !== 'undefined' && window.localStorage)

View File

@ -69,6 +69,7 @@ export const useTaskStore = defineStore('task', {
run_mode?: 'fast' | 'thinking' | 'deep' | null;
thinking_mode?: boolean | null;
message_source?: string | null;
goal_mode?: boolean | null;
eventHandler?: (event: any) => void;
} = {}
) {
@ -89,7 +90,8 @@ export const useTaskStore = defineStore('task', {
run_mode: options.run_mode ?? undefined,
thinking_mode:
typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined,
message_source: options.message_source ?? undefined
message_source: options.message_source ?? undefined,
goal_mode: options.goal_mode === true ? true : undefined
})
});

View File

@ -410,6 +410,44 @@ class DeepSeekClient: # legacy name; generic OpenAI-compatible client
}
)
return sanitized
def _sanitize_message_fields_for_api(self, messages: List[Dict]) -> List[Dict]:
"""移除只供本地 UI/持久化使用、OpenAI-compatible API 不接受的消息字段。"""
sanitized: List[Dict[str, Any]] = []
stripped_fields: Dict[str, int] = {}
allowed_common = {"role", "content", "name"}
allowed_by_role = {
"assistant": allowed_common | {"tool_calls", "reasoning_content"},
"tool": allowed_common | {"tool_call_id"},
"user": allowed_common,
"system": allowed_common,
}
for message in messages or []:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "").strip()
allowed = allowed_by_role.get(role, allowed_common)
clean: Dict[str, Any] = {}
for key, value in message.items():
if key not in allowed:
stripped_fields[key] = stripped_fields.get(key, 0) + 1
continue
if key == "tool_calls" and not value:
stripped_fields[key] = stripped_fields.get(key, 0) + 1
continue
clean[key] = value
sanitized.append(clean)
if stripped_fields:
self._debug_log(
{
"event": "sanitize_message_fields_for_api",
"stripped_fields": stripped_fields,
}
)
return sanitized
def set_deep_thinking_mode(self, enabled: bool):
"""配置深度思考模式(持续使用思考模型)。"""
@ -689,6 +727,7 @@ class DeepSeekClient: # legacy name; generic OpenAI-compatible client
final_messages = self._merge_system_messages(messages)
final_messages = self._sanitize_messages_for_model_capability(final_messages)
final_messages = self._sanitize_message_fields_for_api(final_messages)
payload = {
"model": api_config["model_id"],