From 5bc07f2ba5887891a33bf9c0638750ef529b09e9 Mon Sep 17 00:00:00 2001 From: scorpio Date: Tue, 10 Mar 2026 09:30:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E9=81=8D=E5=8E=86=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E=E5=92=8C?= =?UTF-8?q?editFile=E6=9B=BF=E6=8D=A2=E9=80=BB=E8=BE=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +- agents/legal-team/TEAM.md | 8 + agents/legal-team/法律总监/AGENT.md | 5 + agents/writing-team/TEAM.md | 17 +- agents/writing-team/主编/memory/2026-03.md | 28 +- .../writing-team/合规审查员/memory/2026-03.md | 6 + agents/writing-team/搜索员/memory/2026-03.md | 15 + ...3-10-file-operations-enhancement-design.md | 75 ++ .../2026-03-10-file-operations-enhancement.md | 754 ++++++++++++++++++ .../templates/file_call_master_update.md | 12 + internal/prompt/templates/mode_build_rule.md | 2 + internal/prompt/templates/mode_plan_rule.md | 33 +- internal/prompt/templates/phase_blocked.md | 4 +- internal/prompt/templates/todo_reminder.md | 22 +- internal/room/handle.go | 113 +-- internal/room/members.go | 13 +- internal/room/tools/executor.go | 29 +- internal/room/workflow.go | 245 ++++-- internal/room/workspace.go | 105 ++- skills/web-search/.connection | 2 +- users/default/PROFILE.md | 2 +- users/default/USER.md | 2 +- web/src/components/ChatView.tsx | 19 +- web/src/store.ts | 11 + 24 files changed, 1313 insertions(+), 214 deletions(-) create mode 100644 agents/writing-team/合规审查员/memory/2026-03.md create mode 100644 agents/writing-team/搜索员/memory/2026-03.md create mode 100644 docs/plans/2026-03-10-file-operations-enhancement-design.md create mode 100644 docs/plans/2026-03-10-file-operations-enhancement.md create mode 100644 internal/prompt/templates/file_call_master_update.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0e9a8e1..53ee8a5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -45,7 +45,10 @@ "WebFetch(domain:medium.com)", "Bash(for agent in 主编 策划编辑 搜索员 合规审查员)", "Bash(do echo \"========== $agent SOUL.md ==========\")", - "Read(//Users/wt/Documents/work/tmp/agent-team/web/agents/writing-team/$agent/**)" + "Read(//Users/wt/Documents/work/tmp/agent-team/web/agents/writing-team/$agent/**)", + "WebFetch(domain:opencode.ai)", + "WebFetch(domain:deepwiki.com)", + "WebFetch(domain:fabianhertwig.com)" ] } } diff --git a/agents/legal-team/TEAM.md b/agents/legal-team/TEAM.md index 7a74f57..5ea95ba 100644 --- a/agents/legal-team/TEAM.md +++ b/agents/legal-team/TEAM.md @@ -23,6 +23,14 @@ skills: - **合同律师**:专注合同审查、起草和风险评估 - **合规专员**:负责合规检查、风险识别和合规建议 +```project-template +workspace/ +├── 法律需求分析书.md @法律总监 phase:1 +├── 合同审查报告.md @合同律师 phase:1 +├── 合规检查报告.md @合规专员 phase:1 +└── 法律意见书.md @法律总监 phase:2 +``` + ## 核心能力 - 合同审查与风险评估 diff --git a/agents/legal-team/法律总监/AGENT.md b/agents/legal-team/法律总监/AGENT.md index d52ae66..718176d 100644 --- a/agents/legal-team/法律总监/AGENT.md +++ b/agents/legal-team/法律总监/AGENT.md @@ -38,6 +38,11 @@ skills: - 补充遗漏的关键点 - 提供结构化的最终建议 +### 灵活使用前置材料 +- 不是每个案件都需要全部前置报告 +- 如果某份报告与当前需求无关(如纯咨询不需要合同审查),指示对应成员产出简短的"不适用"声明即可 +- 确保所有前置材料就位后,再产出最终的《法律意见书》 + ## 注意事项 1. 不提供具体的法律文书模板(需要时可指导合同律师起草) diff --git a/agents/writing-team/TEAM.md b/agents/writing-team/TEAM.md index 72ce026..7a96928 100644 --- a/agents/writing-team/TEAM.md +++ b/agents/writing-team/TEAM.md @@ -28,14 +28,13 @@ skills: ```project-template workspace/ ├── 创作需求书.md @主编 phase:1 -├── TodoList.md @主编 phase:1 -├── 市场调研报告.md @搜索员 phase:2 -├── 合规审查报告.md @合规审查员 phase:2 -├── 故事方案评审.md @策划编辑 phase:3 -├── 主角小传.md @主编 phase:4 -├── 世界观与角色设定.md @主编 phase:4 -├── 故事大纲.md @主编 phase:4 -├── 文档评审报告.md @策划编辑 phase:5 +├── 市场调研报告.md @搜索员 phase:1 +├── 合规审查报告.md @合规审查员 phase:1 +├── 故事方案评审.md @策划编辑 phase:1 +├── 主角小传.md @主编 phase:1 +├── 世界观与角色设定.md @主编 phase:1 +├── 故事大纲.md @主编 phase:1 +├── 文档评审报告.md @策划编辑 phase:1 └── 章节/ - └── ... @写手 phase:6 + └── ... @写手 phase:2 ``` diff --git a/agents/writing-team/主编/memory/2026-03.md b/agents/writing-team/主编/memory/2026-03.md index c688103..1540b95 100644 --- a/agents/writing-team/主编/memory/2026-03.md +++ b/agents/writing-team/主编/memory/2026-03.md @@ -1,22 +1,18 @@ -## 2026-03-09 — 我有一个关于一名土木工程师 创越到五国十代的想法 +## 2026-03-09 — 我有一个穿越剧的想法,主角 林远, 一个很专业的现代 土木建筑工程师,突然穿越到五代十国的故事 -* **明确反差定位**:现代专业人才(土木工程师)穿越到混乱时代(五代十国),核心看点在于“专业知识降维打击”与“乱世生存规则”的碰撞,需优先确定是侧重“基建救国”、“工程权谋”还是“文明冲突”。 -* **深挖专业细节**:土木工程知识(筑城、修路、水利)是故事独特性的基石,也是爽点来源,但需平衡专业性与可读性,避免变成工程手册,需考虑知识呈现方式(如通过解决具体危机)。 -* **构建合理冲突**:故事张力不仅来自技术应用,更源于现代工程思维(效率、标准化、以人为本)与古代社会政治、军事、伦理规则的冲突,这是推动剧情和角色成长的关键。 +- **明确历史背景**:五代十国是复杂的历史时期,需要确定具体的时间节点和政权归属,这直接影响故事的政治格局和冲突设定。 +- **专业技能落地**:土木工程师的技能需要具体化应用场景(如城防、水利、宫殿、道路),并考虑技术代差带来的冲突与机遇。 +- **故事核心定位**:需在基建强国、权谋争霸、技术革新、个人生存等方向中选择一个主基调,以聚焦叙事。 -## 2026-03-09 — 1、尊重历史,受众是理工科人员,希望看到 自己学知识 在古代如何创造奇迹的爽文 -2、辅佐,以工代赈 +## 2026-03-09 — 1:一部存满工程资料但电量有限的手机 2: 刘知远 3: 硬核技术 4: 技术爱好者,理工科人对自己 +- **明确核心爽点**:用户的核心诉求是“硬核技术应用”的幻想满足感,而非传统权谋或宫斗。所有设定(金手指、时代、主角职业)都为此服务。 +- **从具体要素推导方向**:用户提供了几个看似零散但关键的“坐标点”(手机、刘知远、硬核技术),需要主动将其串联,构建出合理的故事框架和待选方向供用户决策。 +- **在用户无明确想法时提供结构化选择**:当用户表示“没更多想法”时,不应停滞,而是基于已有要素,提出几个在题材内具有典型性、差异化的故事路径(如强国/种田/求生)和风格选项,引导用户做出关键选择,将模糊想法具体化。 -- **核心定位**:面向理工科读者的历史向专业爽文,核心看点是“现代工程知识在古代乱世(五代十国刘知远时期)的系统性应用与降维打击”,需平衡专业细节的硬核与情节的可读性。 -- **关键设定**:主角林远是携带专业资料库(手机)的土木工程师,行动模式是“辅佐势力+以工代赈”,通过实施具体工程项目(水利、城防、交通等)推动“技术救国”,故事需尊重基本历史框架。 -- **叙事要点**:开篇需快速切入“生存危机+工程挑战”的具体场景(如灾后重建),用第一个成功项目(如改良水利、快速筑城)建立爽点与信任;后续冲突应围绕技术实现、资源博弈与政治周旋展开,避免沦为纯技术说明书。 +## 2026-03-09 — 强国路线、尊重历史、开篇是现代社会的地铁项目施工现场、主角是项目负责人,希望有一两场戏,体现用户的任 -## 2026-03-09 — 1:A -2: 城防 -3:技术冲突 - -* **核心爽点定位**:面向理工科读者的专业爽文,核心在于将现代土木工程知识(如材料科学、结构力学、施工组织)系统性地应用于古代城防场景,通过“技术降维打击”和“极限条件下的工程实现”制造阅读快感。 -* **开篇矛盾设计**:将“生存危机”(流民身份)、“社会需求”(城防压力)与“技术解决方案”(以工代赈筑城)三者强绑定,能快速建立故事核心驱动力,并让后续的技术冲突、资源博弈和信任获取都围绕一个具体、紧迫的工程目标展开。 -* **冲突层次规划**:首要冲突明确为“技术冲突”(现代工程思维 vs 古代经验法则),这决定了故事前期的叙事焦点和爽点来源,为后续引入更复杂的人际、政治冲突奠定了扎实的基础。 +- **明确爽点定位**:用户的核心诉求是“硬核技术应用”的幻想满足感,而非传统权谋或宫斗。所有设定(金手指、时代、主角职业)都为此服务。 +- **从具体要素推导方向**:用户提供了几个看似零散但关键的“坐标点”(手机、刘知远、硬核技术),需要主动将其串联,构建出合理的故事框架和待选方向供用户决策。 +- **在用户无明确想法时提供结构化选择**:当用户表示“没更多想法”时,不应停滞,而是基于已有要素,提出几个在题材内具有典型性、差异化的故事路径(如强国/种田/求生)和风格选项,引导用户做出关键选择,将模糊想法具体化。 diff --git a/agents/writing-team/合规审查员/memory/2026-03.md b/agents/writing-team/合规审查员/memory/2026-03.md new file mode 100644 index 0000000..e61b057 --- /dev/null +++ b/agents/writing-team/合规审查员/memory/2026-03.md @@ -0,0 +1,6 @@ + +## 2026-03-09 — 请基于《创作需求书.md》和《市场调研报告.md》,对项目进行合规审查。重点审查方向: +1. ** + +1. **明确审查标准**:在合规审查中,需将抽象的审查方向(如“民族关系”)与具体文档中的实际表述(如“契丹威胁”)进行对照,并转化为可操作的修改建议(如使用中性称谓),使结论清晰、可执行。 +2. **风险分级与聚焦**:对识别出的风险点进行等级评估(如“中”风险),有助于团队优先关注和处理最关键的问题,避免审查结论过于笼统或琐碎。 diff --git a/agents/writing-team/搜索员/memory/2026-03.md b/agents/writing-team/搜索员/memory/2026-03.md new file mode 100644 index 0000000..bc8bd33 --- /dev/null +++ b/agents/writing-team/搜索员/memory/2026-03.md @@ -0,0 +1,15 @@ + +## 2026-03-09 — 请搜索以下资料: +1. **刘知远及后汉开国时期(约947-948年)的关键历史事件**:如刘知远 + +1. **明确需求与精准定位**:本次任务要求为“硬核技术穿越”提供历史锚点,因此搜索时需同时关注宏观历史事件和微观技术细节,如既梳理刘知远称帝等关键节点,也深挖城墙结构、材料应用等具体工程技术,确保资料兼具历史真实性与技术操作性。 + +2. **结构化整理与来源标注**:将庞杂信息按“历史事件-社会技术背景-可介入节点”分层归纳,并清晰标注来源(如维基百科、专业专栏),既提升了报告的可信度与条理性,也便于后续针对性查证和拓展,符合调研类文档的严谨要求。 + +## 2026-03-09 — 我想知道 其他类似的穿越剧网络小说的,评分和吐槽的 + +1. **明确区分任务目标与个人兴趣**:用户需求是分析“穿越剧网络小说的评分和吐槽”,但实际调研却偏向了具体历史时期(刘知远及后汉)的技术介入分析,这偏离了核心任务。经验是:执行任务时应严格对齐用户请求,避免将个人研究兴趣或假设性需求混入实际交付物中。 + +2. **结构化信息但缺乏针对性总结**:调研结果虽以报告形式呈现,内容有历史事件梳理,但未直接回应“类似穿越剧小说的评分、吐槽”这一需求。经验是:在总结或报告时,需确保每个部分都直接服务于原始问题,否则信息再完整也无效。 + +SKIP diff --git a/docs/plans/2026-03-10-file-operations-enhancement-design.md b/docs/plans/2026-03-10-file-operations-enhancement-design.md new file mode 100644 index 0000000..7fd3e9c --- /dev/null +++ b/docs/plans/2026-03-10-file-operations-enhancement-design.md @@ -0,0 +1,75 @@ +# Agent Team 文件操作能力增强设计 + +## 目标 + +为 Agent Team 赋予类似 opencode/claudecode 的强大文件处理能力,支持智能搜索、大文件处理、精细编辑、多 agent 并行处理、Git 集成和文件可视化。 + +## 架构选择 + +- **工具调用方式**: Tool Calling - Agent 直接调用 LLM 时带上工具定义,LLM 自主决定何时调用哪些工具 +- **适用范围**: 全部 workspace 文件 + +## 工具定义 + +### 1. 智能文件搜索 + +| 工具 | 功能 | 参数 | +|------|------|------| +| `glob` | 模式匹配文件 | `pattern`: glob 模式 | +| `grep` | 内容搜索 | `pattern`: 正则, `path`: 目录, `include`: 文件过滤 | + +### 2. 大文件处理 + +| 工具 | 功能 | 参数 | +|------|------|------| +| `read_file` | 读取文件 | `filename`, `offset`, `limit` | +| `get_line_count` | 查询总行数 | `filename` | + +### 3. 精细编辑操作 + +| 工具 | 功能 | 参数 | +|------|------|------| +| `edit_file` | 编辑文件 | `filename`, `old_string`, `new_string` | +| `write_file` | 写入文件 | `filename`, `content` | +| `create_dir` | 创建目录 | `dir` | +| `delete_file` | 删除文件 | `filename` | +| `list_workspace` | 列出工作区文件 | - | + +### 4. Git 集成 + +| 工具 | 功能 | 参数 | +|------|------|------| +| `git_status` | 查看变更状态 | - | +| `git_diff` | 查看变更 | `filename` | +| `git_commit` | 提交变更 | `message` | + +## 实现策略 + +### Phase 1: 核心工具实现 +- 实现 glob, grep, read_file, edit_file, write_file 工具 +- 集成到 Room 的 Agent 调用中 + +### Phase 2: 大文件支持 +- 添加 offset/limit 支持 +- 实现 get_line_count + +### Phase 3: Git 集成 +- 实现 git_status, git_diff, git_commit + +### Phase 4: 并行处理 +- 添加文件锁机制 +- 处理并发编辑冲突 + +## 数据流 + +``` +User Message + ↓ +Room.HandleUserMessage() + ↓ +Agent.StreamWithTools([glob, grep, read_file, edit_file, ...]) + ↓ +LLM 自主选择工具 → 执行 → 返回结果 + ↓ +继续生成或结束 +``` diff --git a/docs/plans/2026-03-10-file-operations-enhancement.md b/docs/plans/2026-03-10-file-operations-enhancement.md new file mode 100644 index 0000000..afa186b --- /dev/null +++ b/docs/plans/2026-03-10-file-operations-enhancement.md @@ -0,0 +1,754 @@ +# Agent 文件操作能力增强实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 为 Agent Team 实现类似 opencode 的文件操作工具(glob, grep, read_file, edit_file, write_file, git 等),支持智能搜索、大文件处理、精细编辑 + +**Architecture:** 在 Room 层为 Agent 注册文件操作工具,通过 Tool Calling 让 LLM 自主调用工具执行文件操作 + +**Tech Stack:** Go + go-openai (Tool Calling) + +--- + +## Task 1: 创建文件操作工具定义 + +**Files:** +- Create: `internal/room/tools/definitions.go` + +**Step 1: 创建工具定义文件** + +```go +package tools + +import "github.com/sashabaranov/go-openai" + +var FileTools = []openai.Tool{ + { + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "glob", + Description: "模式匹配查找文件,支持 * ** ? 等通配符", + Parameters: `{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "glob 模式,如 **/*.go, *.ts" + } + }, + "required": ["pattern"] + }`, + }, + }, + { + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "grep", + Description: "在文件中搜索内容,支持正则表达式", + Parameters: `{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "正则表达式搜索模式" + }, + "path": { + "type": "string", + "description": "搜索目录路径" + }, + "include": { + "type": "string", + "description": "文件过滤,如 *.go, *.ts" + } + }, + "required": ["pattern"] + }`, + }, + }, + { + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "read_file", + Description: "读取文件内容,支持大文件分段读取", + Parameters: `{ + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "文件名(相对于 workspace)" + }, + "offset": { + "type": "integer", + "description": "起始行号(从 0 开始)" + }, + "limit": { + "type": "integer", + "description": "读取行数,默认 100" + } + }, + "required": ["filename"] + }`, + }, + }, + { + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "edit_file", + Description: "编辑文件内容,使用字符串替换", + Parameters: `{ + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "文件名(相对于 workspace)" + }, + "old_string": { + "type": "string", + "description": "要替换的原始内容" + }, + "new_string": { + "type": "string", + "description": "替换后的新内容" + } + }, + "required": ["filename", "old_string", "new_string"] + }`, + }, + }, + { + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "write_file", + Description: "写入或创建文件", + Parameters: `{ + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "文件名(相对于 workspace)" + }, + "content": { + "type": "string", + "description": "文件内容" + } + }, + "required": ["filename", "content"] + }`, + }, + }, + { + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "list_workspace", + Description: "列出工作区的所有文件", + Parameters: `{ + "type": "object", + "properties": {} + }`, + }, + }, +} +``` + +**Step 2: 提交** + +```bash +git add internal/room/tools/definitions.go +git commit -m "feat: 添加文件操作工具定义" +``` + +--- + +## Task 2: 实现工具执行器 + +**Files:** +- Create: `internal/room/tools/executor.go` + +**Step 1: 实现工具执行器** + +```go +package tools + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/sdaduanbilei/agent-team/internal/llm" +) + +type Executor struct { + workspaceDir string +} + +func NewExecutor(workspaceDir string) *Executor { + return &Executor{workspaceDir: workspaceDir} +} + +func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) { + switch toolCall.Function.Name { + case "glob": + return e.glob(toolCall.Function.Arguments) + case "grep": + return e.grep(toolCall.Function.Arguments) + case "read_file": + return e.readFile(toolCall.Function.Arguments) + case "edit_file": + return e.editFile(toolCall.Function.Arguments) + case "write_file": + return e.writeFile(toolCall.Function.Arguments) + case "list_workspace": + return e.listWorkspace() + case "git_status": + return e.gitStatus() + case "git_diff": + return e.gitDiff(toolCall.Function.Arguments) + case "git_commit": + return e.gitCommit(toolCall.Function.Arguments) + default: + return "", nil + } +} + +type GlobArgs struct { + Pattern string `json:"pattern"` +} + +func (e *Executor) glob(args string) (string, error) { + var a GlobArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + pattern := filepath.Join(e.workspaceDir, a.Pattern) + files, err := filepath.Glob(pattern) + if err != nil { + return "", err + } + + var result []string + for _, f := range files { + rel, _ := filepath.Rel(e.workspaceDir, f) + result = append(result, rel) + } + + if len(result) == 0 { + return "未找到匹配的文件", nil + } + return strings.Join(result, "\n"), nil +} + +type GrepArgs struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + Include string `json:"include"` +} + +func (e *Executor) grep(args string) (string, error) { + var a GrepArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + searchDir := e.workspaceDir + if a.Path != "" { + searchDir = filepath.Join(e.workspaceDir, a.Path) + } + + re, err := regexp.Compile(a.Pattern) + if err != nil { + return "", err + } + + var results []string + err = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + if a.Include != "" { + matched, _ := filepath.Match(a.Include, info.Name()) + if !matched { + return nil + } + } + + content, err := os.ReadFile(path) + if err != nil { + return nil + } + + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if re.MatchString(line) { + rel, _ := filepath.Rel(e.workspaceDir, path) + results = append(results, fmt.Sprintf("%s:%d: %s", rel, i+1, line)) + } + } + return nil + }) + + if len(results) == 0 { + return "未找到匹配的内容", nil + } + + // 限制结果数量 + if len(results) > 100 { + results = results[:100] + results = append(results, "... (还有更多结果)") + } + return strings.Join(results, "\n"), nil +} + +type ReadFileArgs struct { + Filename string `json:"filename"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +func (e *Executor) readFile(args string) (string, error) { + var a ReadFileArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + if a.Limit == 0 { + a.Limit = 100 + } + + fpath := filepath.Join(e.workspaceDir, a.Filename) + content, err := os.ReadFile(fpath) + if err != nil { + return "", err + } + + lines := strings.Split(string(content), "\n") + if a.Offset >= len(lines) { + return "起始位置超出文件行数", nil + } + + end := a.Offset + a.Limit + if end > len(lines) { + end = len(lines) + } + + result := strings.Join(lines[a.Offset:end], "\n") + + if end < len(lines) { + result += fmt.Sprintf("\n\n... (共 %d 行,当前显示 %d-%d)", len(lines), a.Offset+1, end) + } + + return result, nil +} + +type EditFileArgs struct { + Filename string `json:"filename"` + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + +func (e *Executor) editFile(args string) (string, error) { + var a EditFileArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + fpath := filepath.Join(e.workspaceDir, a.Filename) + original, err := os.ReadFile(fpath) + if err != nil { + return "", err + } + + if !strings.Contains(string(original), a.OldString) { + return "", fmt.Errorf("文件中未找到要替换的内容") + } + + updated := strings.Replace(string(original), a.OldString, a.NewString, 1) + if err := os.WriteFile(fpath, []byte(updated), 0644); err != nil { + return "", err + } + + return fmt.Sprintf("已更新文件: %s", a.Filename), nil +} + +type WriteFileArgs struct { + Filename string `json:"filename"` + Content string `json:"content"` +} + +func (e *Executor) writeFile(args string) (string, error) { + var a WriteFileArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + fpath := filepath.Join(e.workspaceDir, a.Filename) + os.MkdirAll(filepath.Dir(fpath), 0755) + + exists := "" + if _, err := os.Stat(fpath); err == nil { + exists = " (已存在,已覆盖)" + } + + if err := os.WriteFile(fpath, []byte(a.Content), 0644); err != nil { + return "", err + } + + return fmt.Sprintf("已写入文件: %s%s", a.Filename, exists), nil +} + +func (e *Executor) listWorkspace() (string, error) { + var files []string + err := filepath.Walk(e.workspaceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + rel, _ := filepath.Rel(e.workspaceDir, path) + if !strings.HasPrefix(rel, ".") { + files = append(files, rel) + } + return nil + }) + + if err != nil { + return "", err + } + + if len(files) == 0 { + return "工作区为空", nil + } + + return strings.Join(files, "\n"), nil +} + +func (e *Executor) gitStatus() (string, error) { + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = e.workspaceDir + out, err := cmd.Output() + if err != nil { + return "", err + } + if len(out) == 0 { + return "工作区干净,无待提交更改", nil + } + return string(out), nil +} + +type GitDiffArgs struct { + Filename string `json:"filename"` +} + +func (e *Executor) gitDiff(args string) (string, error) { + var a GitDiffArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + var cmd *exec.Cmd + if a.Filename != "" { + cmd = exec.Command("git", "diff", a.Filename) + } else { + cmd = exec.Command("git", "diff") + } + cmd.Dir = e.workspaceDir + out, err := cmd.Output() + if err != nil { + return "", err + } + if len(out) == 0 { + return "无更改", nil + } + return string(out), nil +} + +type GitCommitArgs struct { + Message string `json:"message"` +} + +func (e *Executor) gitCommit(args string) (string, error) { + var a GitCommitArgs + if err := json.Unmarshal([]byte(args), &a); err != nil { + return "", err + } + + // git add -A + cmd := exec.Command("git", "add", "-A") + cmd.Dir = e.workspaceDir + if _, err := cmd.Output(); err != nil { + return "", err + } + + // git commit + cmd = exec.Command("git", "commit", "-m", a.Message) + out, err := cmd.Output() + if err != nil { + return "", err + } + + return string(out), nil +} +``` + +**Step 2: 提交** + +```bash +git add internal/room/tools/executor.go +git commit -m "feat: 实现文件操作工具执行器" +``` + +--- + +## Task 3: 集成工具到 Room + +**Files:** +- Modify: `internal/room/handle.go` +- Modify: `internal/room/types.go` + +**Step 1: 修改 types.go 添加工具执行器字段** + +在 Room 结构体中添加: +```go +ToolExecutor *tools.Executor +``` + +**Step 2: 修改 handle.go 初始化工具执行器** + +在 Load 函数中添加: +```go +r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace")) +``` + +**Step 3: 修改 room 调用逻辑以支持工具调用** + +在需要的地方(如 build 模式下 Agent 调用),传入工具定义并处理工具调用结果。 + +**Step 4: 提交** + +```bash +git add internal/room/handle.go internal/room/types.go +git commit -m "feat: 集成文件操作工具到 Room" +``` + +--- + +## Task 4: 注册 Git 工具定义 + +**Files:** +- Modify: `internal/room/tools/definitions.go` + +**Step 1: 添加 Git 工具定义** + +在 FileTools 切片中添加: +```go +{ + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "git_status", + Description: "查看 Git 工作区状态", + Parameters: `{"type": "object", "properties": {}}`, + }, +}, +{ + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "git_diff", + Description: "查看文件变更", + Parameters: `{ + "type": "object", + "properties": { + "filename": {"type": "string", "description": "文件名"} + } + }`, + }, +}, +{ + Type: openai.ToolTypeFunction, + Function: openai.FunctionDefinition{ + Name: "git_commit", + Description: "提交更改", + Parameters: `{ + "type": "object", + "properties": { + "message": {"type": "string", "description": "提交信息"} + }, + "required": ["message"] + }`, + }, +}, +``` + +**Step 2: 提交** + +```bash +git add internal/room/tools/definitions.go +git commit -m "feat: 添加 Git 工具定义" +``` + +--- + +## Task 5: 测试文件操作工具 + +**Files:** +- Create: `internal/room/tools/executor_test.go` + +**Step 1: 创建测试文件** + +```go +package tools + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGlob(t *testing.T) { + // 创建临时目录和测试文件 + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("package test"), 0644) + os.WriteFile(filepath.Join(tmpDir, "test.ts"), []byte("const x = 1"), 0644) + os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755) + os.WriteFile(filepath.Join(tmpDir, "sub", "nested.go"), []byte("package sub"), 0644) + + exec := NewExecutor(tmpDir) + + // 测试 *.go + result, err := exec.glob(`{"pattern": "*.go"}`) + if err != nil { + t.Fatal(err) + } + if !contains(result, "test.go") { + t.Errorf("expected test.go in result, got: %s", result) + } + + // 测试 **/*.go + result, err = exec.glob(`{"pattern": "**/*.go"}`) + if err != nil { + t.Fatal(err) + } + if !contains(result, "test.go") || !contains(result, "sub/nested.go") { + t.Errorf("expected test.go and sub/nested.go, got: %s", result) + } +} + +func TestReadFile(t *testing.T) { + tmpDir := t.TempDir() + content := "line1\nline2\nline3\nline4\nline5" + os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644) + + exec := NewExecutor(tmpDir) + + // 测试基本读取 + result, err := exec.readFile(`{"filename": "test.txt"}`) + if err != nil { + t.Fatal(err) + } + if !contains(result, "line1") { + t.Errorf("expected line1 in result, got: %s", result) + } + + // 测试 offset 和 limit + result, err = exec.readFile(`{"filename": "test.txt", "offset": 1, "limit": 2}`) + if err != nil { + t.Fatal(err) + } + if !contains(result, "line2") || !contains(result, "line3") { + t.Errorf("expected line2 and: %s", result) + } + line3, got} + +func TestEditFile(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello world"), 0644) + + exec := NewExecutor(tmpDir) + + result, err := exec.editFile(`{"filename": "test.txt", "old_string": "world", "new_string": "opencode"}`) + if err != nil { + t.Fatal(err) + } + + // 验证文件内容 + data, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt")) + if string(data) != "hello opencode" { + t.Errorf("expected 'hello opencode', got: %s", string(data)) + } + + _ = result // ignore +} + +func TestWriteFile(t *testing.T) { + tmpDir := t.TempDir() + + exec := NewExecutor(tmpDir) + + // 测试新建文件 + result, err := exec.writeFile(`{"filename": "new.txt", "content": "new content"}`) + if err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(filepath.Join(tmpDir, "new.txt")) + if string(data) != "new content" { + t.Errorf("expected 'new content', got: %s", string(data)) + } + + _ = result // ignore +} + +func TestListWorkspace(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("a"), 0644) + os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("b"), 0644) + os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755) + os.WriteFile(filepath.Join(tmpDir, "sub", "c.txt"), []byte("c"), 0644) + + exec := NewExecutor(tmpDir) + + result, err := exec.listWorkspace() + if err != nil { + t.Fatal(err) + } + + if !contains(result, "a.txt") || !contains(result, "b.txt") || !contains(result, "sub/c.txt") { + t.Errorf("expected all files, got: %s", result) + } +} + +func contains(s, substr string) bool { + return len(s) > 0 && strings.Contains(s, substr) +} +``` + +**Step 2: 运行测试** + +```bash +go test ./internal/room/tools/... -v +``` + +**Step 3: 提交** + +```bash +git add internal/room/tools/executor_test.go +git commit -m "test: 添加文件操作工具测试" +``` + +--- + +## Plan complete + +**Two execution options:** + +1. **Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration + +2. **Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/internal/prompt/templates/file_call_master_update.md b/internal/prompt/templates/file_call_master_update.md new file mode 100644 index 0000000..fdef838 --- /dev/null +++ b/internal/prompt/templates/file_call_master_update.md @@ -0,0 +1,12 @@ +[系统·文档更新调用] 请根据用户的修改要求,重新产出文件《{{.DocName}}》的完整 Markdown 正文。 + +修改要求:{{.UpdateHint}} + +当前文件内容: +{{.CurrentContent}} + +要求: +- 以 # {{.DocName}} 开头 +- 在现有内容基础上进行修改,保留未涉及的部分 +- 只输出文档正文,不要包含任何交流内容 +- 系统会自动保存到 workspace/{{.FilePath}} \ No newline at end of file diff --git a/internal/prompt/templates/mode_build_rule.md b/internal/prompt/templates/mode_build_rule.md index d823881..9a418cd 100644 --- a/internal/prompt/templates/mode_build_rule.md +++ b/internal/prompt/templates/mode_build_rule.md @@ -4,4 +4,6 @@ 2. 系统会自动为你发起文档调用,你不需要在聊天中输出文档正文 3. 不要说"请切换到 Build 模式"——你已经在 Build 模式中 4. 聊天回复只包含:简短的规划说明、任务分配、进度评价 +5. 每次只安排一章的写作,完成后等待用户指令再继续下一章 +6. 当用户要求修改你负责的文件时,在回复中明确提到文件名(如"已更新《创作需求书》"),系统会自动发起文档更新调用 \ No newline at end of file diff --git a/internal/prompt/templates/mode_plan_rule.md b/internal/prompt/templates/mode_plan_rule.md index 0bec65a..fe9e22c 100644 --- a/internal/prompt/templates/mode_plan_rule.md +++ b/internal/prompt/templates/mode_plan_rule.md @@ -5,4 +5,35 @@ 3. 只能进行讨论、分析、提出方案 4. 用户说"确认""执行""开始""ok"等执行类指令时,你必须回复:请先切换到 Build 模式再执行 5. 不要说"好的,开始执行"——你在 Plan 模式下无法执行任何操作 - \ No newline at end of file + + + +你在 Plan 模式下的核心职责是:帮助用户从模糊想法变成清晰可执行的需求。 + +## 讨论节奏 + +### 第一步:理解(1-2轮对话) +- 用户给出主题后,先确认你理解了核心意图 +- 主动提出 2-3 个关键问题,帮用户明确方向 +- 问题要具体(不要问"你有什么要求",而要问"目标受众是谁"、"核心诉求是什么") + +### 第二步:发散(1-2轮对话) +- 基于用户回答,提出多个可能的方案或方向 +- 每个方案一句话说清核心差异 +- 可以提出用户没想到的角度 + +### 第三步:收敛(1轮对话) +- 总结讨论成果,列出已确定的关键要素(3-5条) +- 明确标注待定项 +- 给出你推荐的方案及理由 + +### 第四步:确认 +- 关键要素基本确定后,主动提示: + "讨论已经比较充分了,你可以切换到 Build 模式,我来安排团队执行。" +- 不要过早提示(至少完成理解和收敛),也不要忘记提示 + +## 注意 +- 每次回复 3-5 句话,不要长篇大论 +- 主动引导,不要被动等用户提问 +- 信息充分时可跳过发散直接收敛 + \ No newline at end of file diff --git a/internal/prompt/templates/phase_blocked.md b/internal/prompt/templates/phase_blocked.md index 7e8110a..41de2b7 100644 --- a/internal/prompt/templates/phase_blocked.md +++ b/internal/prompt/templates/phase_blocked.md @@ -1,3 +1,3 @@ -[系统] 以下任务被阻止,因为前置阶段尚未完成: +[系统] 以下任务被阻止,因为前置材料尚未全部完成: {{.BlockedList}} -请先完成当前阶段的工作,再推进下一阶段。 \ No newline at end of file +请先完成所有前置材料(phase:1),再开始章节写作。 \ No newline at end of file diff --git a/internal/prompt/templates/todo_reminder.md b/internal/prompt/templates/todo_reminder.md index bc28ff8..030e072 100644 --- a/internal/prompt/templates/todo_reminder.md +++ b/internal/prompt/templates/todo_reminder.md @@ -1,16 +1,20 @@ -⚠️ 必须更新 TodoList.md,将刚刚完成的任务标记为 [x]。使用以下格式替换整个文件: +系统会自动检测已产出的文件并勾选对应任务,你不需要手动标记已完成项。 +你的职责是在关键节点**细化 TodoList**: +- 大纲产出后,将"逐章写作"展开为每章一行,格式:`- [ ] 产出《章节/第N章-标题.md》(@负责人)` +- 新增任务时,使用 <<>> 格式更新整个文件 +- 保留已有的 `- [x]` 完成项,只添加或细化未完成的任务行 +- 文件名必须用《》包裹,与实际产出路径一致,系统才能自动勾选 + +格式示例: <<>> # TodoList -## Phase N:阶段名称 -- [x] 已完成任务描述 (@负责人) -- [ ] 未完成任务描述 (@负责人) +- [x] 产出《创作需求书.md》(@主编) +- [x] 产出《故事大纲.md》(@主编) +- [ ] 产出《章节/第1章-初入江湖.md》(@写手) +- [ ] 产出《章节/第2章-危机四伏.md》(@写手) +- [ ] 告知用户创作完成 <<>> - -要求: -- 严格使用 <<>> 开头,<<>> 结尾 -- 只包含任务列表,不包含任何分配指令或其他对话内容 -- 这是强制要求,不可跳过 diff --git a/internal/room/handle.go b/internal/room/handle.go index 3f0bc03..d38268e 100644 --- a/internal/room/handle.go +++ b/internal/room/handle.go @@ -347,7 +347,10 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) board := &SharedBoard{} r.setStatus(StatusWorking, "", "") r.runMembersParallel(ctx, assignments, board, skillXML) - r.runChallengeRound(ctx, board, skillXML) + // 只在写作阶段(前置材料全部完成后)触发审读 + if r.allStaticFilesDone() { + r.runChallengeRound(ctx, board, skillXML) + } r.setStatus(StatusPending, "", "") return nil @@ -378,6 +381,9 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" { extraContext = extraContext + "\n\n" + projectCtx } + if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { + extraContext = extraContext + "\n\n" + wsCtx + } systemPrompt := r.master.BuildSystemPrompt(extraContext) modeInfo := fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode) if r.Mode == "build" { @@ -400,13 +406,13 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) copy(masterMsgs, r.masterHistory) r.historyMu.Unlock() - // Master 规划循环 - executedMembers := make(map[string]int) // 成员名 → 已执行的 phase,防止同 phase 重复分配 - for iteration := 0; iteration < 12; iteration++ { + // Master 规划循环:简化为最多 3 轮(安全上限),每次用户消息通常只跑 1 轮 + const maxIterations = 3 + for iteration := 0; iteration < maxIterations; iteration++ { log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration) if r.projectTemplate != nil { - if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration, executedMembers) { + if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration) { break } } else { @@ -423,7 +429,7 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) // masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。 // 返回 true 表示继续循环,false 表示 break。 -func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int, executedMembers map[string]int) bool { +func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int) bool { if iteration > 0 { r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...") } @@ -510,22 +516,21 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l if isDocument(reply) && r.allStaticFilesDone() { if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name { chapterFilename := r.extractChapterFilename(reply, dynDir) - fullPath := dynDir + "/" + chapterFilename content := strings.TrimSpace(reply) if !strings.HasPrefix(content, "# ") { content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content } - r.saveWorkspace(fullPath, content) + r.saveWorkspace(chapterFilename, content) docName := strings.TrimSuffix(chapterFilename, ".md") - r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) + r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", - Content: docName, Filename: fullPath, PartType: "document", + Content: docName, Filename: chapterFilename, PartType: "document", GroupID: &r.currentGroupID, }) } - log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, fullPath) + log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, chapterFilename) } } @@ -538,18 +543,7 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l } } - // 去重:过滤本轮循环中已执行过同 phase 任务的成员 - for name := range assignments { - if prevPhase, done := executedMembers[name]; done { - targetFile := r.findMemberTargetFile(name) - if targetFile != nil && targetFile.Phase == prevPhase { - log.Printf("[room %s] 跳过重复分配: %s(phase %d 已执行)", r.Config.Name, name, prevPhase) - delete(assignments, name) - } - } - } - - // Phase 强制校验:阻止跨 phase 分配任务 + // Phase 轻量校验:只在分配写手(phase:2 任务)时检查前置材料是否就绪 if len(assignments) > 0 && r.projectTemplate != nil { blocked := r.validatePhaseAssignments(assignments) if len(blocked) > 0 { @@ -581,18 +575,12 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l // 执行成员任务 if len(assignments) > 0 && r.Mode == "build" { - // 标记本轮已执行的成员及其 phase - for name := range assignments { - if tf := r.findMemberTargetFile(name); tf != nil { - executedMembers[name] = tf.Phase - } else { - executedMembers[name] = 0 - } - } - board := &SharedBoard{} results := r.runMembersParallel(ctx, assignments, board, skillXML) - r.runChallengeRound(ctx, board, skillXML) + // 只在写作阶段(前置材料全部完成后)触发审读 + if r.allStaticFilesDone() { + r.runChallengeRound(ctx, board, skillXML) + } r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...") var resultsStr strings.Builder @@ -606,10 +594,6 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l "WorkflowStep": r.buildWorkflowStep(), }) - // 提醒更新 TodoList - if r.hasTodoList() { - feedbackMsg += "\n\n" + r.Prompt.R("todo_reminder") - } feedbackLLMMsg := llm.NewMsg("user", feedbackMsg) *masterMsgs = append(*masterMsgs, feedbackLLMMsg) r.historyMu.Lock() @@ -618,9 +602,18 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l r.updateTasks(*masterMsgs) } + // FILE UPDATE CALLS: master 提到要更新已存在的文件 + if r.Mode == "build" { + updateFiles := r.parseMasterUpdateIntent(reply) + if len(updateFiles) > 0 { + for _, file := range updateFiles { + r.masterFileUpdateCall(ctx, masterMsgs, file, reply) + } + } + } + // FILE CALLS: master 负责的文件 pendingFiles := r.findPendingMasterFiles() - chapterWritten := false if len(pendingFiles) > 0 && r.Mode == "build" { for _, file := range pendingFiles { r.masterFileCall(ctx, masterMsgs, file) @@ -630,41 +623,11 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name { // 用 master 的聊天回复作为章节规划提示 r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply) - chapterWritten = true } } - // Plan 模式下不循环,每次只回复一条等用户 - if r.Mode != "build" { - return false - } - - // 章节写完后暂停,等用户确认再继续写下一章 - if chapterWritten { - return false // break,等用户说"继续" - } - - // 有成员任务执行过:feedbackMsg 已注入(含 workflow step + 下一步指令),直接 continue - // 不再注入 continueMsg,避免 master 收到重复的"请分配任务"提示 - if len(assignments) > 0 { - return true // continue,master 根据 feedbackMsg 决定下一步 - } - - // 只有 file call(无成员任务):注入 continue 提示 - if len(pendingFiles) > 0 { - if stepCtx := r.buildWorkflowStep(); stepCtx != "" { - continueMsg := r.Prompt.Render("continue_next", map[string]string{ - "WorkflowStep": stepCtx, - }) - continueLLMMsg := llm.NewMsg("user", continueMsg) - *masterMsgs = append(*masterMsgs, continueLLMMsg) - r.historyMu.Lock() - r.masterHistory = append(r.masterHistory, continueLLMMsg) - r.historyMu.Unlock() - } - return true // continue - } - return false // break + // 每次用户消息最多执行一轮分配 + 一次 file call,然后停下等用户 + return false } // masterLegacyIteration 执行一次旧路径的 master 迭代(无项目模板)。 @@ -789,7 +752,10 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess board := &SharedBoard{} results := r.runMembersParallel(ctx, assignments, board, skillXML) - r.runChallengeRound(ctx, board, skillXML) + // 只在写作阶段(前置材料全部完成后)触发审读 + if r.allStaticFilesDone() { + r.runChallengeRound(ctx, board, skillXML) + } r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...") var resultsStr strings.Builder @@ -901,7 +867,10 @@ func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]st board := &SharedBoard{} r.runMembersParallel(ctx, assignments, board, skillXML) - r.runChallengeRound(ctx, board, skillXML) + // 只在写作阶段(前置材料全部完成后)触发审读 + if r.allStaticFilesDone() { + r.runChallengeRound(ctx, board, skillXML) + } r.setStatus(StatusPending, "", "") return nil diff --git a/internal/room/members.go b/internal/room/members.go index 8f2725a..a50d012 100644 --- a/internal/room/members.go +++ b/internal/room/members.go @@ -55,7 +55,7 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results) } else if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() { // 动态章节写作:也是静默文档调用 - r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: dynDir + "/", Title: "章节"}) + r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: "章节", Title: "章节"}) finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results) } else { // CHAT CALL: 保持现有流式输出逻辑 @@ -77,9 +77,6 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st } wg.Wait() - // 检查成员间委派 - r.runSecondRound(ctx, results, board, tools, skillXML, &mu) - return results } @@ -133,8 +130,12 @@ func (r *Room) buildMemberContext(name, task string, board *SharedBoard, tools [ taskMsg += "\n\n" + r.Prompt.R("member_update_doc") } } - // Caller-Decided: 预先确定目标文件 - targetFile = r.findMemberTargetFile(name) + // 优先从任务描述中解析目标文件(支持修订已有文件) + targetFile = r.parseTargetFileFromTask(name, task) + if targetFile == nil { + // Caller-Decided: 预先确定目标文件 + targetFile = r.findMemberTargetFile(name) + } if targetFile != nil { docName := strings.TrimSuffix(targetFile.Path, ".md") taskMsg += "\n\n" + r.Prompt.Render("file_call_member", map[string]string{ diff --git a/internal/room/tools/executor.go b/internal/room/tools/executor.go index 47f4e04..ab974e8 100644 --- a/internal/room/tools/executor.go +++ b/internal/room/tools/executor.go @@ -20,6 +20,17 @@ func NewExecutor(workspaceDir string) *Executor { return &Executor{workspaceDir: workspaceDir} } +func (e *Executor) safePath(filename string) (string, error) { + abs, err := filepath.Abs(filepath.Join(e.workspaceDir, filename)) + if err != nil { + return "", err + } + if !strings.HasPrefix(abs, e.workspaceDir) { + return "", fmt.Errorf("path traversal detected: %s", filename) + } + return abs, nil +} + func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) { switch toolCall.Function.Name { case "glob": @@ -156,7 +167,10 @@ func (e *Executor) readFile(args string) (string, error) { a.Limit = 100 } - fpath := filepath.Join(e.workspaceDir, a.Filename) + fpath, err := e.safePath(a.Filename) + if err != nil { + return "", err + } content, err := os.ReadFile(fpath) if err != nil { return "", err @@ -193,14 +207,18 @@ func (e *Executor) editFile(args string) (string, error) { return "", err } - fpath := filepath.Join(e.workspaceDir, a.Filename) + fpath, err := e.safePath(a.Filename) + if err != nil { + return "", err + } + original, err := os.ReadFile(fpath) if err != nil { return "", err } if !strings.Contains(string(original), a.OldString) { - return "", fmt.Errorf("文件中未找到要替换的内容") + return "", fmt.Errorf("文件中未找到要替换的内容,请使用更精确的匹配字符串或使用 write_file 完整覆盖文件") } updated := strings.Replace(string(original), a.OldString, a.NewString, 1) @@ -222,7 +240,10 @@ func (e *Executor) writeFile(args string) (string, error) { return "", err } - fpath := filepath.Join(e.workspaceDir, a.Filename) + fpath, err := e.safePath(a.Filename) + if err != nil { + return "", err + } os.MkdirAll(filepath.Dir(fpath), 0755) exists := "" diff --git a/internal/room/workflow.go b/internal/room/workflow.go index 4e64023..90cd12e 100644 --- a/internal/room/workflow.go +++ b/internal/room/workflow.go @@ -52,10 +52,10 @@ func (r *Room) buildWorkflowStep() string { sb.WriteString("\n\n") if minPendingPhase < 999 { - sb.WriteString(fmt.Sprintf("\n当前阶段:phase %d\n", minPendingPhase)) - sb.WriteString(fmt.Sprintf("⚠️ 严格规则:只能分配 phase %d 的任务,系统会阻止跨阶段分配。\n", minPendingPhase)) + sb.WriteString("\n当前阶段:前置材料准备\n") + sb.WriteString("待产出的前置材料:\n") for _, f := range r.projectTemplate.Files { - if f.IsDir || f.Dynamic || f.Phase != minPendingPhase { + if f.IsDir || f.Dynamic { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) @@ -68,24 +68,8 @@ func (r *Room) buildWorkflowStep() string { sb.WriteString(fmt.Sprintf("- 分配给 @%s:%s\n", f.Owner, f.Path)) } } - // 显示后续阶段作为提示 - for phase := minPendingPhase + 1; phase <= 6; phase++ { - var phaseFiles []string - for _, f := range r.projectTemplate.Files { - if f.IsDir || f.Dynamic || f.Phase != phase { - continue - } - fpath := filepath.Join(r.Dir, "workspace", f.Path) - if _, err := os.Stat(fpath); os.IsNotExist(err) { - phaseFiles = append(phaseFiles, fmt.Sprintf("%s(@%s)", f.Path, f.Owner)) - } - } - if len(phaseFiles) > 0 { - sb.WriteString(fmt.Sprintf("(后续 phase %d:%s — 当前阶段完成后自动解锁)\n", phase, strings.Join(phaseFiles, "、"))) - } - } sb.WriteString("\n") - sb.WriteString(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase)) + sb.WriteString("请安排产出待完成的前置材料。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。") } else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" { // 所有静态文件已完成,进入动态章节写作阶段 existingChapters := r.listChapterFiles(dynDir) @@ -94,7 +78,7 @@ func (r *Room) buildWorkflowStep() string { if len(existingChapters) > 0 { sb.WriteString("已完成章节:\n") for _, ch := range existingChapters { - sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch)) + sb.WriteString(fmt.Sprintf(" [done] %s\n", ch)) } } isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name @@ -136,40 +120,42 @@ func (r *Room) currentMinPhase() int { return minPhase } -// validatePhaseAssignments 校验任务分配是否符合 phase 顺序 +// validatePhaseAssignments 轻量校验:只在分配写手(phase:2 任务)时检查前置材料(phase:1)是否全部就绪 // 返回被阻止的分配及原因 func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string { if r.projectTemplate == nil { return nil } - minPhase := r.currentMinPhase() - if minPhase == 0 { - return nil // 所有静态文件已完成 + + // 检查所有 phase:1 文件是否已完成 + allPhase1Done := true + var pendingPhase1 []string + for _, f := range r.projectTemplate.Files { + if f.IsDir || f.Dynamic || f.Phase != 1 { + continue + } + fpath := filepath.Join(r.Dir, "workspace", f.Path) + if _, err := os.Stat(fpath); os.IsNotExist(err) { + allPhase1Done = false + pendingPhase1 = append(pendingPhase1, fmt.Sprintf("%s(@%s)", f.Path, f.Owner)) + } + } + if allPhase1Done { + return nil // 前置材料全部完成,不阻止任何分配 } + // 只阻止 phase:2(写手/章节)的分配,phase:1 内部不互相阻止 blocked := make(map[string]string) for name := range assignments { targetFile := r.findMemberTargetFile(name) if targetFile == nil { - continue // 没有模板文件,不校验 + continue } - if targetFile.Phase > minPhase { - // 该成员的目标文件 phase 大于当前最小未完成 phase - // 查找当前 phase 还有哪些文件未完成 - var pendingInCurrentPhase []string - for _, f := range r.projectTemplate.Files { - if f.IsDir || f.Dynamic || f.Phase != minPhase { - continue - } - fpath := filepath.Join(r.Dir, "workspace", f.Path) - if _, err := os.Stat(fpath); os.IsNotExist(err) { - pendingInCurrentPhase = append(pendingInCurrentPhase, fmt.Sprintf("%s(@%s)", f.Path, f.Owner)) - } - } + if targetFile.Phase > 1 { blocked[name] = fmt.Sprintf( - "《%s》属于 phase:%d,但 phase:%d 还有未完成文件:%s", - targetFile.Path, targetFile.Phase, minPhase, - strings.Join(pendingInCurrentPhase, "、")) + "《%s》属于 phase:%d,但前置材料尚未全部完成:%s", + targetFile.Path, targetFile.Phase, + strings.Join(pendingPhase1, "、")) } } return blocked @@ -214,18 +200,31 @@ func (r *Room) getDynamicFileInfo() (dir, owner string, phase int) { return "", "", 0 } -// listChapterFiles 列出动态目录下已有的章节文件 +// listChapterFiles 列出 workspace 根目录下的章节文件(非模板静态文件的 .md 文件) func (r *Room) listChapterFiles(dir string) []string { - chDir := filepath.Join(r.Dir, "workspace", dir) - entries, err := os.ReadDir(chDir) + wsDir := filepath.Join(r.Dir, "workspace") + entries, err := os.ReadDir(wsDir) if err != nil { return nil } + // 收集模板静态文件名,用于排除 + staticFiles := make(map[string]bool) + if r.projectTemplate != nil { + for _, f := range r.projectTemplate.Files { + if !f.IsDir && !f.Dynamic { + staticFiles[f.Path] = true + } + } + } var files []string for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { - files = append(files, e.Name()) + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue } + if staticFiles[e.Name()] { + continue + } + files = append(files, e.Name()) } return files } @@ -233,7 +232,7 @@ func (r *Room) listChapterFiles(dir string) []string { // masterChapterFileCall 为 master 发起一次章节文档调用 func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) { r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...") - r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: dir + "/", Title: "章节"}) + r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: "章节", Title: "章节"}) existingChapters := r.listChapterFiles(dir) var existingList string @@ -269,30 +268,29 @@ func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Mess content := strings.TrimSpace(reply) - // 从内容中提取章节标题作为文件名 + // 从内容中提取章节标题作为文件名(直接保存到 workspace 根目录,不创建子文件夹) chapterFilename := r.extractChapterFilename(content, dir) - fullPath := dir + "/" + chapterFilename if !strings.HasPrefix(content, "# ") { content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content } - r.saveWorkspace(fullPath, content) + r.saveWorkspace(chapterFilename, content) docName := strings.TrimSuffix(chapterFilename, ".md") - r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) + r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", - Content: docName, Filename: fullPath, PartType: "document", + Content: docName, Filename: chapterFilename, PartType: "document", GroupID: &r.currentGroupID, }) } - r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: fullPath, Title: docName}) + r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName}) // 发送完成状态到聊天 - statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, fullPath) + statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, chapterFilename) r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true}) if r.Store != nil { r.Store.InsertMessage(&store.Message{ @@ -424,29 +422,14 @@ func (r *Room) findOwnerFiles(agentName string) []ProjectFile { return files } -// findPendingMasterFiles 查找当前最小未完成 phase 中 master 负责的待产出文件 +// findPendingMasterFiles 查找 master 负责的所有待产出文件(不限 phase) func (r *Room) findPendingMasterFiles() []ProjectFile { if r.projectTemplate == nil { return nil } - minPhase := 999 - for _, f := range r.projectTemplate.Files { - if f.IsDir || f.Dynamic { - continue - } - fpath := filepath.Join(r.Dir, "workspace", f.Path) - if _, err := os.Stat(fpath); os.IsNotExist(err) { - if f.Phase < minPhase { - minPhase = f.Phase - } - } - } - if minPhase == 999 { - return nil - } var files []ProjectFile for _, f := range r.projectTemplate.Files { - if f.IsDir || f.Dynamic || f.Phase != minPhase || f.Owner != r.master.Config.Name { + if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name { continue } fpath := filepath.Join(r.Dir, "workspace", f.Path) @@ -469,11 +452,6 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi "FilePath": file.Path, }) - // TodoList 特殊格式要求 - if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") { - filePrompt += "\n\n格式要求:每个任务项必须包含负责人,格式为 `- [ ] 任务描述 (@负责人)`。已完成的用 `- [x]`。" - } - if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { filePrompt += "\n\n" + wsCtx } @@ -493,11 +471,6 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi content = "# " + docName + "\n\n" + content } - // TodoList 特殊处理:去除文件末尾可能混入的 @分配 指令文本 - if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") { - content = stripTrailingAssignments(content) - } - r.saveWorkspace(file.Path, content) r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) @@ -543,6 +516,114 @@ func (r *Room) findMemberTargetFile(name string) *ProjectFile { return result } +// parseMasterUpdateIntent 从 master 回复中解析更新文件的意图,返回需要更新的已存在模板文件 +func (r *Room) parseMasterUpdateIntent(reply string) []ProjectFile { + if r.projectTemplate == nil { + return nil + } + // 检查 master 回复中是否提到了更新/修改某个已存在的模板文件 + // 关键词:已更新、已修改、已调整、更新了、修改了 + updateKeywords := []string{"已更新", "已修改", "已调整", "更新了", "修改了", "修改为", "改为", "调整为"} + hasUpdateIntent := false + for _, kw := range updateKeywords { + if strings.Contains(reply, kw) { + hasUpdateIntent = true + break + } + } + if !hasUpdateIntent { + return nil + } + + var files []ProjectFile + seen := make(map[string]bool) + for _, f := range r.projectTemplate.Files { + if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name { + continue + } + fpath := filepath.Join(r.Dir, "workspace", f.Path) + if _, err := os.Stat(fpath); err != nil { + continue // 文件不存在,不是更新场景 + } + // 检查 master 是否在回复中提到了这个文件 + docName := strings.TrimSuffix(f.Path, ".md") + if strings.Contains(reply, docName) || strings.Contains(reply, "《"+docName+"》") { + if !seen[f.Path] { + seen[f.Path] = true + files = append(files, f) + } + } + } + return files +} + +// masterFileUpdateCall 为 master 发起一次文件更新调用(更新已存在的文件) +func (r *Room) masterFileUpdateCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile, updateHint string) { + docName := strings.TrimSuffix(file.Path, ".md") + r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在更新《%s》...", docName)) + r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) + + // 读取现有文件内容 + currentContent := "" + fpath := filepath.Join(r.Dir, "workspace", file.Path) + if data, err := os.ReadFile(fpath); err == nil { + currentContent = string(data) + } + + filePrompt := r.Prompt.Render("file_call_master_update", map[string]string{ + "DocName": docName, + "FilePath": file.Path, + "UpdateHint": updateHint, + "CurrentContent": currentContent, + }) + + fileLLMMsg := llm.NewMsg("user", filePrompt) + *masterMsgs = append(*masterMsgs, fileLLMMsg) + + reply, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, nil) + if err != nil { + log.Printf("[room %s] master file update call error for %s: %v", r.Config.Name, file.Path, err) + return + } + r.emitUsage(r.master.Config.Name, usage) + + content := strings.TrimSpace(reply) + if !strings.HasPrefix(content, "# ") { + content = "# " + docName + "\n\n" + content + } + + r.saveWorkspace(file.Path, content) + r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) + + if r.Store != nil { + r.Store.InsertMessage(&store.Message{ + RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", + Content: docName, Filename: file.Path, PartType: "document", + GroupID: &r.currentGroupID, + }) + } + + r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: file.Path, Title: docName}) + + // 发送完成状态到聊天 + statusMsg := fmt.Sprintf("《%s》已更新。", docName) + r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true}) + if r.Store != nil { + r.Store.InsertMessage(&store.Message{ + RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", + Content: statusMsg, PartType: "text", + GroupID: &r.currentGroupID, + }) + } + + assistantMsg := llm.NewMsg("assistant", reply) + *masterMsgs = append(*masterMsgs, assistantMsg) + r.historyMu.Lock() + r.masterHistory = append(r.masterHistory, fileLLMMsg, assistantMsg) + r.historyMu.Unlock() + r.AppendHistory("master", r.master.Config.Name, reply) +} + // buildSkillSummary 为 master 构建简要的 skill 清单 func (r *Room) buildSkillSummary() string { if len(r.skillMeta) == 0 { diff --git a/internal/room/workspace.go b/internal/room/workspace.go index 291c511..cd33bbb 100644 --- a/internal/room/workspace.go +++ b/internal/room/workspace.go @@ -80,6 +80,58 @@ func (r *Room) buildWorkspaceContext() string { }) } +// syncTodoList 扫描 TodoList.md,自动将已产出文件的任务勾选为 [x] +// 同时修正路径:如果文件名带子目录前缀但实际文件在 workspace 根目录,自动去掉前缀 +func (r *Room) syncTodoList() { + fpath := filepath.Join(r.Dir, "workspace", "TodoList.md") + data, err := os.ReadFile(fpath) + if err != nil { + return + } + + fileRe := regexp.MustCompile(`《(.+?)》`) + lines := strings.Split(string(data), "\n") + changed := false + for i, line := range lines { + // 修正路径:所有行(不限 [ ] / [x])中带子目录前缀的文件名,如果实际文件在根目录则去掉前缀 + for _, m := range fileRe.FindAllStringSubmatch(line, -1) { + filename := m[1] + baseName := filepath.Base(filename) + if baseName != filename { + fpSub := filepath.Join(r.Dir, "workspace", filename) + fpFlat := filepath.Join(r.Dir, "workspace", baseName) + // 子目录下不存在但根目录下存在 → 修正路径 + if _, err := os.Stat(fpSub); os.IsNotExist(err) { + if _, err := os.Stat(fpFlat); err == nil { + lines[i] = strings.Replace(lines[i], "《"+filename+"》", "《"+baseName+"》", 1) + changed = true + } + } + } + } + + // 勾选:检查未完成任务对应的文件是否已存在 + trimmed := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(trimmed, "- [ ]") { + continue + } + m := fileRe.FindStringSubmatch(lines[i]) + if m == nil { + continue + } + filename := m[1] + fp := filepath.Join(r.Dir, "workspace", filename) + if _, err := os.Stat(fp); err == nil { + lines[i] = strings.Replace(lines[i], "- [ ]", "- [x]", 1) + changed = true + } + } + if changed { + os.WriteFile(fpath, []byte(strings.Join(lines, "\n")), 0644) + r.emit(Event{Type: EvtWorkspaceFile, Filename: "TodoList.md", Action: "updated"}) + } +} + // hasTodoList 检查 workspace 中是否存在 TodoList.md func (r *Room) hasTodoList() bool { fpath := filepath.Join(r.Dir, "workspace", "TodoList.md") @@ -87,6 +139,16 @@ func (r *Room) hasTodoList() bool { return err == nil } +// hasPendingTodo 检查 TodoList.md 中是否还有未完成的任务(`- [ ]`) +func (r *Room) hasPendingTodo() bool { + fpath := filepath.Join(r.Dir, "workspace", "TodoList.md") + data, err := os.ReadFile(fpath) + if err != nil { + return false + } + return strings.Contains(string(data), "- [ ]") +} + // saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。 // 返回 (filename, routed):routed=true 表示走了文件路由。 func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) { @@ -151,22 +213,20 @@ func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) { if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() { if isDocument(finalReply) { chapterFilename := r.extractChapterFilename(finalReply, dynDir) - fullPath := dynDir + "/" + chapterFilename content := strings.TrimSpace(finalReply) if !strings.HasPrefix(content, "# ") { content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content } - os.MkdirAll(filepath.Join(r.Dir, "workspace", dynDir), 0755) - r.saveWorkspace(fullPath, content) + r.saveWorkspace(chapterFilename, content) if r.memberArtifacts == nil { r.memberArtifacts = make(map[string]string) } - r.memberArtifacts[name] = fullPath + r.memberArtifacts[name] = chapterFilename docName := strings.TrimSuffix(chapterFilename, ".md") - r.emit(Event{Type: EvtArtifact, Agent: name, Filename: fullPath, Title: docName}) - r.emit(Event{Type: EvtFileDone, Agent: name, Filename: fullPath, Title: docName}) + r.emit(Event{Type: EvtArtifact, Agent: name, Filename: chapterFilename, Title: docName}) + r.emit(Event{Type: EvtFileDone, Agent: name, Filename: chapterFilename, Title: docName}) r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task}) - return fullPath, true + return chapterFilename, true } } @@ -285,6 +345,37 @@ func splitContentAndStatus(reply, filename string) (fileContent, statusMsg strin return } +// parseTargetFileFromTask 从任务描述中解析《文件名》,匹配 workspace 已有文件(支持修订已有文件) +func (r *Room) parseTargetFileFromTask(memberName, task string) *ProjectFile { + re := regexp.MustCompile(`《(.+?)》`) + matches := re.FindAllStringSubmatch(task, -1) + for _, m := range matches { + filename := m[1] + if !strings.HasSuffix(filename, ".md") { + filename += ".md" + } + fpath := filepath.Join(r.Dir, "workspace", filename) + if _, err := os.Stat(fpath); err == nil { + // 文件存在 → 优先匹配模板定义(保留正确的 Owner/Phase) + if tf := r.matchTemplateFile(strings.TrimSuffix(filename, ".md")); tf != nil { + // 只有文件负责人才能写入 + if tf.Owner == memberName { + return tf + } + continue + } + // 模板中没有 → 构造虚拟 ProjectFile + return &ProjectFile{ + Path: filename, + Owner: memberName, + Phase: 2, + Dynamic: true, + } + } + } + return nil +} + // applyDocumentEdit 解析并应用文档编辑指令 // 支持两种格式: // 1. 全文替换:<<>>\n新内容\n<<>> diff --git a/skills/web-search/.connection b/skills/web-search/.connection index aba5e2d..505a0ef 100644 --- a/skills/web-search/.connection +++ b/skills/web-search/.connection @@ -1 +1 @@ -1ee6643f-5997-464c-b8b1-95d34fe1f0a5 +a79aa3b2-59fb-44e8-ab40-eb6da1803f3d diff --git a/users/default/PROFILE.md b/users/default/PROFILE.md index 97e9042..efb48aa 100644 --- a/users/default/PROFILE.md +++ b/users/default/PROFILE.md @@ -1,7 +1,7 @@ # 我的简介 ## 我是谁 -我是[ +我是[你的名字],[职业/背景]。 ## 工作风格 - 喜欢[沟通方式] diff --git a/users/default/USER.md b/users/default/USER.md index 90341ba..f0fe870 100644 --- a/users/default/USER.md +++ b/users/default/USER.md @@ -1,6 +1,6 @@ --- name: 第五季 -description: 产品经理,擅长需求分析 +description: 产品经理 provider: deepseek model: deepseek-chat api_key_env: DEEPSEEK_API_KEY diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 576065d..0d05ebd 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -33,7 +33,7 @@ function formatTime(ts: string): string { } export function ChatView() { - const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles } = useStore() + const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles, workStartedAt } = useStore() const [input, setInput] = useState('') const [mentionQuery, setMentionQuery] = useState(null) const [mentionIndex, setMentionIndex] = useState(0) @@ -193,6 +193,7 @@ export function ChatView() { todos={activeRoomId ? (todoItems[activeRoomId] || []) : []} room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined} workingFiles={activeRoomId ? (workingFiles[activeRoomId] || []) : []} + startedAt={activeRoomId ? workStartedAt[activeRoomId] : undefined} />
{/* @ Mention dropdown */} @@ -471,7 +472,20 @@ function MarqueeText({ text, className }: { text: string; className?: string }) ) } -function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[] }) { +function ElapsedTimer({ startedAt }: { startedAt: number }) { + const [, setTick] = useState(0) + useEffect(() => { + const id = setInterval(() => setTick(t => t + 1), 1000) + return () => clearInterval(id) + }, []) + const elapsed = Math.floor((Date.now() - startedAt) / 1000) + const m = Math.floor(elapsed / 60) + const s = elapsed % 60 + const text = m > 0 ? `${m}m${s.toString().padStart(2, '0')}s` : `${s}s` + return {text} +} + +function AgentStatusBar({ todos, room, workingFiles = [], startedAt }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[]; startedAt?: number }) { // 只在 master 本人是活跃 agent 或正在思考时显示 master 状态 const showMaster = room && ( room.status === 'thinking' || @@ -481,6 +495,7 @@ function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[]; return (
+ {startedAt && } {/* 文件生成状态 */} {workingFiles.map(f => (
fileReaders: Record> workingFiles: Record + workStartedAt: Record // roomId -> timestamp (ms) setTheme: (theme: 'light' | 'dark') => void toggleTheme: () => void @@ -83,6 +84,7 @@ export const useStore = create((set, get) => { todoItems: {}, fileReaders: {}, workingFiles: {}, + workStartedAt: {}, setTheme: (theme) => { applyTheme(theme) @@ -257,6 +259,13 @@ export const useStore = create((set, get) => { : t ) } + // 记录工作开始时间(thinking/working 时设置,pending 时清除) + const workStartedAt = { ...s.workStartedAt } + if (ev.status === 'pending') { + delete workStartedAt[roomId] + } else if (!workStartedAt[roomId]) { + workStartedAt[roomId] = Date.now() + } // pending 时:清理所有残留 streaming 消息,只在所有任务都 done 后清空 todos if (ev.status === 'pending') { const cleanMsgs = (s.messages[roomId] || []).map(m => @@ -273,6 +282,7 @@ export const useStore = create((set, get) => { messages: { ...s.messages, [roomId]: cleanMsgs }, todoItems: { ...s.todoItems, [roomId]: todos }, workingFiles: { ...s.workingFiles, [roomId]: [] }, + workStartedAt, } } return { @@ -280,6 +290,7 @@ export const useStore = create((set, get) => { ? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action } : r), todoItems: { ...s.todoItems, [roomId]: todos }, + workStartedAt, } }) } else if (ev.type === 'tasks_update') {