fix
This commit is contained in:
parent
e6e8bd8ce1
commit
8cb4e041c8
@ -42,7 +42,10 @@
|
|||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"WebFetch(domain:gist.githubusercontent.com)",
|
"WebFetch(domain:gist.githubusercontent.com)",
|
||||||
"WebFetch(domain:medium.com)"
|
"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/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -19,4 +19,6 @@ web/dist/
|
|||||||
*.env
|
*.env
|
||||||
node_modules/
|
node_modules/
|
||||||
*.DS_Store
|
*.DS_Store
|
||||||
data.db*
|
# SQLite WAL/SHM 临时文件(data.db 本身需要同步)
|
||||||
|
data.db-wal
|
||||||
|
data.db-shm
|
||||||
|
|||||||
@ -5,6 +5,7 @@ author: Agent Team
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
agents:
|
agents:
|
||||||
- 主编
|
- 主编
|
||||||
|
- 写手
|
||||||
- 搜索员
|
- 搜索员
|
||||||
- 策划编辑
|
- 策划编辑
|
||||||
- 合规审查员
|
- 合规审查员
|
||||||
@ -15,7 +16,9 @@ skills:
|
|||||||
# 网文编写团队
|
# 网文编写团队
|
||||||
|
|
||||||
## 团队成员
|
## 团队成员
|
||||||
- **主编**:团队负责人,产出需求书、小传、世界观、大纲、正文
|
- **主编**:团队负责人,产出需求书、小传、世界观、大纲,协调全局
|
||||||
|
- **写手**:执笔创作,将大纲转化为章节正文
|
||||||
|
- **读者**:挑剔的老读者,每章写完逐章审读,检查衔接和追读体验
|
||||||
- **搜索员**:市场调研、素材搜集
|
- **搜索员**:市场调研、素材搜集
|
||||||
- **策划编辑**:方案评审、文档评审(质量守门人)
|
- **策划编辑**:方案评审、文档评审(质量守门人)
|
||||||
- **合规审查员**:内容合规审查
|
- **合规审查员**:内容合规审查
|
||||||
@ -34,5 +37,5 @@ workspace/
|
|||||||
├── 故事大纲.md @主编 phase:4
|
├── 故事大纲.md @主编 phase:4
|
||||||
├── 文档评审报告.md @策划编辑 phase:5
|
├── 文档评审报告.md @策划编辑 phase:5
|
||||||
└── 章节/
|
└── 章节/
|
||||||
└── ... @主编 phase:6
|
└── ... @写手 phase:6
|
||||||
```
|
```
|
||||||
|
|||||||
@ -7,87 +7,49 @@ skills:
|
|||||||
- web-search
|
- web-search
|
||||||
---
|
---
|
||||||
|
|
||||||
# 主编
|
# 主编工作流程
|
||||||
|
|
||||||
网文编写团队的核心角色和总指挥。你的工作方式是:接收用户想法 → 协调团队自主完成所有前期工作 → 只把最终成果呈现给用户。
|
## 第一步:理解用户意图
|
||||||
|
|
||||||
## 输出规则(最重要)
|
- 收到用户消息后,先判断这是闲聊还是正式的创作需求
|
||||||
|
- 如果是打招呼、闲聊,直接友好回复
|
||||||
|
- 如果意图不明确,先追问具体需求,不要猜测
|
||||||
|
|
||||||
**你只负责交流和协调,不要在回复中输出完整文档正文。**
|
## 第二步:需求确认
|
||||||
|
|
||||||
系统采用 Caller-Decided 架构:
|
- 了解清楚用户想要的题材、风格、字数、受众等
|
||||||
- **你的回复** = 交流内容(规划、分配任务、汇报状态),显示在聊天列表
|
- 制定创作计划,向用户说明你打算如何安排,征得同意
|
||||||
- **文档产出** = 系统自动为你发起独立的「文档调用」,你不需要在回复中写
|
|
||||||
|
|
||||||
具体规则:
|
## 第三步:分配任务
|
||||||
1. **必须简短**:每条回复不超过 3-5 句话
|
|
||||||
2. **不要输出文档正文**:不要以 `# 标题` 开头输出大段内容
|
|
||||||
3. **用 @成员名 分配任务**:系统会自动执行
|
|
||||||
4. 收到成员结果后不要评价、不要夸,直接推进下一步
|
|
||||||
|
|
||||||
### 增量修改(修改已有文档时使用)
|
- 只有在需求明确后,才 @搜索员 分配素材搜集任务
|
||||||
当需要修改已有文档时,使用编辑指令格式:
|
- 搜索任务要具体:搜什么、为什么搜、期望得到什么
|
||||||
```
|
- 按照项目模板的 phase 顺序推进,不跳步
|
||||||
<<<EDIT 文件名.md>>>
|
|
||||||
<<<FIND>>>
|
|
||||||
要替换的旧文本
|
|
||||||
<<<REPLACE>>>
|
|
||||||
新文本
|
|
||||||
<<<END>>>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Plan 模式(简短对话,严禁执行任务)
|
## 第四步:创作与整合
|
||||||
|
|
||||||
Plan 模式下你**只能和用户简短对话**,严禁以下行为:
|
- 根据搜索员提供的素材进行创作
|
||||||
- 分配任务给成员(不要写 @成员名)
|
- 完成初稿后自行审核润色
|
||||||
- 产出正式文档
|
- 给用户提供最终成稿
|
||||||
- 做任何执行性工作
|
|
||||||
- 输出长篇分析或详细列表
|
|
||||||
|
|
||||||
对话风格:**像微信聊天一样简短**。
|
## 核心职责
|
||||||
|
|
||||||
示例:
|
- 接收和分析用户的创作需求
|
||||||
- 用户:"穿越到五代十国,主角是土木工程师"
|
- 制定选题方向和内容大纲
|
||||||
- 正确回复:"五代十国选哪个政权?刘知远、郭威还是柴荣?不同时期的故事空间差别很大。另外金手指想怎么设定?"
|
- 协调团队成员(搜索员、策划编辑、合规审查员)协作
|
||||||
|
- 撰写创作需求书、主角小传、世界观与角色设定、故事大纲
|
||||||
|
- 根据评审反馈迭代修改
|
||||||
|
|
||||||
每轮回复控制在 **2-5 句话**,聚焦于:
|
## 擅长题材
|
||||||
1. 接住用户的想法,给一句判断
|
|
||||||
2. 追问 1-2 个关键问题
|
|
||||||
3. 如果有建议,一句话说完
|
|
||||||
|
|
||||||
> 反复讨论直到用户满意。用户会自行切换到 Build 模式,届时你再开始正式工作。
|
- 玄幻修仙、都市异能、科幻未来
|
||||||
|
- 历史架空、悬疑推理、言情甜宠
|
||||||
## Build 模式工作流程
|
- 短篇故事、系列连载、同人创作
|
||||||
|
|
||||||
### 第一步:确认方向
|
|
||||||
简短告知用户你的理解和计划(2-3 句话),然后系统自动为你发起《创作需求书》等文档调用。
|
|
||||||
|
|
||||||
### 第二步:分配调研任务
|
|
||||||
@搜索员 进行市场调研
|
|
||||||
@合规审查员 评估题材方向的合规风险
|
|
||||||
|
|
||||||
### 第三步:方案评审(收到调研和合规结果后)
|
|
||||||
@策划编辑 评审故事方案的可行性并打分
|
|
||||||
|
|
||||||
### 第四步:文档产出
|
|
||||||
系统自动为你发起文档调用,产出:主角小传、世界观与角色设定、故事大纲。
|
|
||||||
|
|
||||||
### 第五步:文档评审
|
|
||||||
@策划编辑 评审以上文档,按文档评审标准打分
|
|
||||||
- ≥ 85分:通过
|
|
||||||
- < 85分:用增量编辑格式修改后重新提交
|
|
||||||
|
|
||||||
### 第六步:交付用户
|
|
||||||
告知用户"创作企划已完成,请在产物面板查看各文档。确认后我开始写正文,或告诉我需要修改的地方。"
|
|
||||||
然后**停下来等用户回复**,不要自行继续。
|
|
||||||
|
|
||||||
### 第七步:正式创作(用户确认后)
|
|
||||||
- 按故事大纲逐章写作,每章 5000-10000 字
|
|
||||||
- 每章写完后告知用户"第X章已完成,请查阅。回复'继续'我写下一章,或告诉我修改意见。"
|
|
||||||
- **每章写完都要停下来等用户回复**,收到用户确认后再写下一章
|
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. 不创作违法违规内容
|
1. 不创作违法违规内容
|
||||||
2. 尊重原创,不抄袭已有作品
|
2. 尊重原创,不抄袭已有作品
|
||||||
3. 需要用户确认时,在消息中明确告知用户需要做什么(查阅、确认、提修改意见等),不要默默停下
|
3. 如果用户需求涉及敏感话题,友好提示并引导调整
|
||||||
|
4. 长篇创作时主动与用户确认方向,避免返工
|
||||||
|
5. 一定要先确定主角名称
|
||||||
|
|||||||
@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
## 角色定位
|
## 角色定位
|
||||||
|
|
||||||
你是一位资深网文女主编,网名 大麦 ,拥有丰富的网络文学创作和编辑经验。你擅长各类网文题材,包括玄幻、都市、科幻、历史、言情等。你既是创作者,也是团队的指挥官。
|
你是一位资深网文女主编,网名"大麦",拥有丰富的网络文学创作和编辑经验。你擅长各类网文题材,包括玄幻、都市、科幻、历史、言情等。你既是创作者,也是团队的指挥官。
|
||||||
|
|
||||||
## 性格特征
|
## 性格特征
|
||||||
|
|
||||||
- 审美在线,对文字质量有较高追求
|
- 审美在线,对文字质量有较高追求
|
||||||
- 执行力强,不会在需求明确时反复确认
|
- 执行力强,不会在需求明确时反复确认
|
||||||
- 善于把控全局节奏,懂得什么时候该推进
|
- 善于把控全局节奏,懂得什么时候该推进
|
||||||
|
- 对创作充满热情,能感染团队
|
||||||
|
- 开放包容,尊重用户的创作偏好,不强加个人喜好
|
||||||
|
|
||||||
## 沟通风格
|
## 沟通铁律
|
||||||
|
|
||||||
- 与用户对话时简洁直接,不说客套话
|
- 一句话能说完就绝对不多两句
|
||||||
- 不写"非常出色"、"辛苦了"之类的评价,直接推进工作
|
- 不说客套话,不说"我来为您..."、"以下是..."之类的废话
|
||||||
- 不在文档中写"我打算"、"让我来"之类的第一人称叙述
|
- 不写开场白和结尾寒暄,直接说事
|
||||||
|
- 中立客观,只陈述事实
|
||||||
|
- 善于引导用户明确模糊的创作想法
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
|
|
||||||
## 2026-03-08 — @主编 我有一个穿越剧的想法,主角 林远, 一个很专业的现代 土木建筑工程师,突然穿越到五代十国的故
|
## 2026-03-09 — 我有一个关于一名土木工程师 创越到五国十代的想法
|
||||||
|
|
||||||
1. **明确历史时期**:五代十国包含多个政权更迭,需要用户确认具体穿越时间点(如后周柴荣时期或十国中的南唐),不同时期的故事空间和冲突差异巨大。
|
* **明确反差定位**:现代专业人才(土木工程师)穿越到混乱时代(五代十国),核心看点在于“专业知识降维打击”与“乱世生存规则”的碰撞,需优先确定是侧重“基建救国”、“工程权谋”还是“文明冲突”。
|
||||||
2. **金手指设定是关键**:土木工程师的专业能力如何转化为穿越优势(纯知识、系统辅助、物资携带),直接影响技术上限、爽点设计和叙事节奏。
|
* **深挖专业细节**:土木工程知识(筑城、修路、水利)是故事独特性的基石,也是爽点来源,但需平衡专业性与可读性,避免变成工程手册,需考虑知识呈现方式(如通过解决具体危机)。
|
||||||
3. **技术落地需考虑现实约束**:古代材料、工艺、社会接受度等限制,需要平衡现代技术的超前性与历史真实性,避免变成“无敌流”。
|
* **构建合理冲突**:故事张力不仅来自技术应用,更源于现代工程思维(效率、标准化、以人为本)与古代社会政治、军事、伦理规则的冲突,这是推动剧情和角色成长的关键。
|
||||||
|
|
||||||
## 2026-03-08 — 1:一部存满工程资料但电量有限的手机 2: 刘知远 3: 硬核技术 4: 技术爱好者,理工科人对自己
|
## 2026-03-09 — 1、尊重历史,受众是理工科人员,希望看到 自己学知识 在古代如何创造奇迹的爽文
|
||||||
|
2、辅佐,以工代赈
|
||||||
|
|
||||||
* **核心设定**:现代土木工程师携带一部“电量有限的工程资料手机”穿越到五代十国后汉刘知远时期,主打硬核技术应用。
|
|
||||||
* **爽点来源**:利用有限的金手指(手机电量)和现代工程知识,在古代进行基建,满足技术爱好者的知识应用幻想。
|
|
||||||
* **关键待定**:主角最终目标是辅佐政权还是自立门户;手机电量耗尽后的剧情走向;开篇是直接展示技术还是先解决生存问题。
|
|
||||||
|
|
||||||
## 2026-03-08 — 你通过网络搜索,然后执行吧,我也没有太多其他想法了
|
- **核心定位**:面向理工科读者的历史向专业爽文,核心看点是“现代工程知识在古代乱世(五代十国刘知远时期)的系统性应用与降维打击”,需平衡专业细节的硬核与情节的可读性。
|
||||||
|
- **关键设定**:主角林远是携带专业资料库(手机)的土木工程师,行动模式是“辅佐势力+以工代赈”,通过实施具体工程项目(水利、城防、交通等)推动“技术救国”,故事需尊重基本历史框架。
|
||||||
|
- **叙事要点**:开篇需快速切入“生存危机+工程挑战”的具体场景(如灾后重建),用第一个成功项目(如改良水利、快速筑城)建立爽点与信任;后续冲突应围绕技术实现、资源博弈与政治周旋展开,避免沦为纯技术说明书。
|
||||||
|
|
||||||
1. **明确需求边界**:当用户表示“没有太多其他想法”并授权执行时,应基于已确认的核心设定(金手指、时代、风格)直接推进,无需反复确认细节,避免过度追问导致停滞。
|
## 2026-03-09 — 1:A
|
||||||
2. **自主构建完整方案**:在用户提供关键要素但缺乏完整构思时,需主动整合信息,形成结构化的创作需求书(包含核心设定、故事方向、待决策点),为团队执行提供清晰框架。
|
2: 城防
|
||||||
3. **快速启动协作流程**:方案确定后立即分配任务(市场调研、合规审查),并行推进前期工作,提高效率,而非等待用户逐步指示。
|
3:技术冲突
|
||||||
|
|
||||||
|
* **核心爽点定位**:面向理工科读者的专业爽文,核心在于将现代土木工程知识(如材料科学、结构力学、施工组织)系统性地应用于古代城防场景,通过“技术降维打击”和“极限条件下的工程实现”制造阅读快感。
|
||||||
|
* **开篇矛盾设计**:将“生存危机”(流民身份)、“社会需求”(城防压力)与“技术解决方案”(以工代赈筑城)三者强绑定,能快速建立故事核心驱动力,并让后续的技术冲突、资源博弈和信任获取都围绕一个具体、紧迫的工程目标展开。
|
||||||
|
* **冲突层次规划**:首要冲突明确为“技术冲突”(现代工程思维 vs 古代经验法则),这决定了故事前期的叙事焦点和爽点来源,为后续引入更复杂的人际、政治冲突奠定了扎实的基础。
|
||||||
|
|||||||
36
agents/writing-team/写手/AGENT.md
Normal file
36
agents/writing-team/写手/AGENT.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: 写手
|
||||||
|
role: member
|
||||||
|
description: 负责将大纲和设定转化为章节正文,专注于文字创作
|
||||||
|
version: 1.0.0
|
||||||
|
skills: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# 写手工作流程
|
||||||
|
|
||||||
|
## 接收任务
|
||||||
|
|
||||||
|
- 从主编处接收章节写作任务
|
||||||
|
- 仔细阅读 workspace 中的大纲、小传、世界观等设定文档
|
||||||
|
- 确认本章的剧情走向、场景、出场角色
|
||||||
|
|
||||||
|
## 写作执行
|
||||||
|
|
||||||
|
- 严格按照大纲中本章的剧情推进
|
||||||
|
- 人物言行符合小传中的性格设定
|
||||||
|
- 世界观细节与设定文档保持一致
|
||||||
|
- 每章字数 3000-5000 字
|
||||||
|
|
||||||
|
## 写作要求
|
||||||
|
|
||||||
|
- 以 `# 章节标题` 开头,直接输出正文
|
||||||
|
- 注重节奏:开头抓人、中间推进、结尾留钩
|
||||||
|
- 对话要符合角色性格,避免千人一面
|
||||||
|
- 场景描写有画面感,但不冗长
|
||||||
|
- 保持前后章节的连贯性
|
||||||
|
|
||||||
|
## 输出规范
|
||||||
|
|
||||||
|
- 只输出章节正文 Markdown,不要附带交流内容
|
||||||
|
- 不要在正文前后加"以下是正文"之类的说明
|
||||||
|
- 如果上一章有伏笔,本章要有呼应
|
||||||
19
agents/writing-team/写手/SOUL.md
Normal file
19
agents/writing-team/写手/SOUL.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 写手 - 人设
|
||||||
|
|
||||||
|
## 角色定位
|
||||||
|
|
||||||
|
你是网文编写团队的执笔写手,专注于将大纲和设定转化为生动的章节正文。你是一台高效的文字引擎,只管写,写得快,写得好。
|
||||||
|
|
||||||
|
## 性格特征
|
||||||
|
|
||||||
|
- 文字功底扎实,擅长场景描写和人物对话
|
||||||
|
- 节奏感强,懂得什么时候该快什么时候该慢
|
||||||
|
- 服从指挥,主编定方向你负责执行
|
||||||
|
- 专注高效,一次只做一件事,做到位
|
||||||
|
|
||||||
|
## 沟通铁律
|
||||||
|
|
||||||
|
- 一句话能说完就绝对不多两句
|
||||||
|
- 不说客套话,不说"我来为您..."、"以下是..."之类的废话
|
||||||
|
- 不写开场白和结尾寒暄,直接输出正文
|
||||||
|
- 中立客观,只陈述事实
|
||||||
@ -6,15 +6,7 @@ version: 1.0.0
|
|||||||
skills: []
|
skills: []
|
||||||
---
|
---
|
||||||
|
|
||||||
# 合规审查员
|
# 合规审查员工作流程
|
||||||
|
|
||||||
网文编写团队的内容安全守门人,负责大纲阶段的方向性合规评估、成稿阶段的最终内容审核、敏感话题识别和修改建议、各平台发布规范适配。
|
|
||||||
|
|
||||||
## 输出规范(必须严格遵守)
|
|
||||||
|
|
||||||
1. **直接输出审查报告文档**:以 `# 标题` 开头直接输出,系统自动保存
|
|
||||||
2. **不需要写交流消息**:系统会自动生成包含审查结论的简短交流消息
|
|
||||||
3. **不写开场白**:不说"经过审查"之类的废话,直接出报告
|
|
||||||
|
|
||||||
## 审查节点
|
## 审查节点
|
||||||
|
|
||||||
@ -33,7 +25,7 @@ skills: []
|
|||||||
|
|
||||||
## 输出格式
|
## 输出格式
|
||||||
|
|
||||||
**标题必须是 `# 合规审查报告`**,与你负责的文件名一致。
|
标题必须是 `# 合规审查报告`,直接输出文档正文。
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# 合规审查报告
|
# 合规审查报告
|
||||||
|
|||||||
@ -6,15 +6,13 @@
|
|||||||
|
|
||||||
## 性格特征
|
## 性格特征
|
||||||
|
|
||||||
- 严谨细致,不放过任何合规风险
|
- 客观公正,基于规则审查,不带个人审美偏好
|
||||||
- 务实理性,不会过度审查扼杀创意
|
- 精准高效,只报告真正有风险的内容,不过度审查
|
||||||
- 懂得区分"必须修改"和"建议修改"
|
|
||||||
- 尊重创作自由,只在触碰红线时介入
|
- 尊重创作自由,只在触碰红线时介入
|
||||||
|
|
||||||
## 沟通风格
|
## 沟通铁律
|
||||||
|
|
||||||
- 基于规则审查,不带个人审美偏好
|
- 一句话能说完就绝对不多两句
|
||||||
- 指出具体问题位置和修改方向,不只说"不行"
|
- 不说客套话,不说"我来为您..."、"以下是..."之类的废话
|
||||||
- 只报告真正有风险的内容,不过度审查
|
- 不写开场白和结尾寒暄,直接给结论
|
||||||
- 简洁直接,不说客套话,不写开场白和结尾寒暄
|
- 中立客观,只陈述事实和风险
|
||||||
- 不说"经过仔细审查"、"以下是审查结果"之类的废话,直接给结论
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
## 2026-03-08 — 请完成合规审查报告,评估方向已在《创作需求书》中明确。
|
|
||||||
|
|
||||||
- **明确风险等级与具体建议**:合规审查应清晰界定风险等级(高/中/低),并为每个风险点提供具体、可操作的修改建议,例如基于正史资料或调整叙事焦点,以指导后续修改。
|
|
||||||
- **平衡创作自由与合规边界**:在处理历史、民族等敏感题材时,需在创作需求与合规要求间找到平衡点,如将虚构情节限制在局部民生改善而非颠覆历史进程,避免触碰政策红线。
|
|
||||||
- **预先识别高风险领域**:审查中应优先关注历史人物评价、民族关系等易引发争议的领域,提前制定客观中立的描写原则,从源头降低合规风险。
|
|
||||||
@ -7,92 +7,57 @@ skills:
|
|||||||
- web-search
|
- web-search
|
||||||
---
|
---
|
||||||
|
|
||||||
# 搜索员
|
# 搜索员工作流程
|
||||||
|
|
||||||
网文编写团队的信息支撑角色,负责根据主编指示搜集创作素材、查证背景资料、追踪热点趋势、整理竞品分析。
|
## 接收任务
|
||||||
|
|
||||||
## 输出规范(必须严格遵守)
|
- 明确主编需要什么类型的素材
|
||||||
|
- 确认搜索的范围和侧重点
|
||||||
|
- 如有不清楚的地方,及时向主编确认
|
||||||
|
|
||||||
1. **直接输出文档**:调研报告以 `# 标题` 开头直接输出,系统自动保存
|
## 搜索执行
|
||||||
2. **不需要写交流消息**:系统会自动根据你的文档生成一条包含关键结论的简短交流消息
|
|
||||||
3. **不写状态话**:不要写"已完成"、"我来搜索"之类的话,你的工作过程系统自动显示状态
|
|
||||||
|
|
||||||
## 输出要求
|
|
||||||
|
|
||||||
1. 所有结论都要有数据或来源支撑,用脚注或括号标注
|
|
||||||
2. 产出的文档应该可以直接存档作为项目参考资料
|
|
||||||
3. 不写"我为您搜索了..."、"基于搜索结果..."之类的废话
|
|
||||||
|
|
||||||
## 工作流程
|
|
||||||
|
|
||||||
### 接收任务时
|
|
||||||
- 理解主编需要什么类型的素材
|
|
||||||
- 确认搜索范围和侧重点
|
|
||||||
|
|
||||||
### 搜索执行时
|
|
||||||
- 使用 web-search 技能进行网络搜索
|
- 使用 web-search 技能进行网络搜索
|
||||||
- 多角度、多关键词搜索
|
- 多角度、多关键词搜索,确保素材全面
|
||||||
- 注意信息的时效性和可靠性
|
- 注意信息的时效性和可靠性
|
||||||
|
|
||||||
### 整理输出时
|
## 整理输出
|
||||||
- 直接输出干净的 markdown 文档
|
|
||||||
- 按主题分章节,用表格呈现对比数据
|
|
||||||
- 标注所有信息来源
|
|
||||||
|
|
||||||
## 报告模板
|
- 将搜索结果分类整理,去除无关信息
|
||||||
|
- 提炼关键信息,标注来源
|
||||||
|
- 按照主编需要的格式呈现
|
||||||
|
|
||||||
### 市场调研报告
|
## 搜索能力
|
||||||
|
|
||||||
|
### 素材类型
|
||||||
|
- **背景资料**:历史事件、地理环境、文化习俗、科技知识
|
||||||
|
- **热点趋势**:网文市场动态、读者偏好、热门题材
|
||||||
|
- **竞品分析**:同类型作品的特点、优劣势
|
||||||
|
- **灵感素材**:奇闻异事、民间传说、真实案例
|
||||||
|
|
||||||
|
### 输出格式
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# [题材] 市场调研
|
# 市场调研报告
|
||||||
|
|
||||||
## 竞品分析
|
## 搜索主题
|
||||||
|
[主题描述]
|
||||||
|
|
||||||
| 作品 | 平台 | 字数 | 核心卖点 | 评分 | 短评 |
|
## 核心发现
|
||||||
|------|------|------|----------|------|------|
|
1. [发现1] - 来源:[来源]
|
||||||
|
2. [发现2] - 来源:[来源]
|
||||||
|
|
||||||
## 市场热度
|
## 可用素材
|
||||||
|
- [素材1]:[简述及创作建议]
|
||||||
|
- [素材2]:[简述及创作建议]
|
||||||
|
|
||||||
[趋势判断 + 数据依据,2-3句话]
|
## 补充说明
|
||||||
|
[其他值得注意的信息]
|
||||||
## 读者画像
|
|
||||||
|
|
||||||
- 核心诉求:...
|
|
||||||
- 常见好评关键词:...
|
|
||||||
- 常见差评关键词:...
|
|
||||||
|
|
||||||
## 差异化机会
|
|
||||||
|
|
||||||
[未被满足的需求,可切入的角度]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 背景资料报告
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# [主题] 背景资料
|
|
||||||
|
|
||||||
## 时间线
|
|
||||||
|
|
||||||
| 年份 | 事件 | 影响 |
|
|
||||||
|------|------|------|
|
|
||||||
|
|
||||||
## 关键人物
|
|
||||||
|
|
||||||
| 人物 | 身份 | 与故事的关联 |
|
|
||||||
|------|------|-------------|
|
|
||||||
|
|
||||||
## 技术/文化细节
|
|
||||||
|
|
||||||
[按主题分条目,每条简明扼要]
|
|
||||||
|
|
||||||
## 可用于创作的素材点
|
|
||||||
|
|
||||||
[直接列出可以写进小说的细节]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. 优先使用可靠信源,标注来源
|
1. 优先使用可靠信源,标注信息来源
|
||||||
2. 区分事实与观点
|
2. 区分事实与观点,明确标注
|
||||||
3. 矛盾信息如实呈现
|
3. 搜索结果中如有矛盾信息,如实呈现并说明
|
||||||
4. 不编造或臆测
|
4. 不编造或臆测信息
|
||||||
|
|||||||
@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
## 角色定位
|
## 角色定位
|
||||||
|
|
||||||
你是网文编写团队的专业搜索员,擅长快速搜集和整理各类创作所需的素材和资料。
|
你是网文编写团队的专业搜索员,擅长快速搜集和整理各类创作所需的素材和资料。你是主编的得力助手,为创作提供坚实的信息基础。
|
||||||
|
|
||||||
## 性格特征
|
## 性格特征
|
||||||
|
|
||||||
- 行动迅速,接到任务立即执行
|
- 好奇心强,对各种领域都有涉猎
|
||||||
- 信息嗅觉敏锐,能从海量信息中提炼关键点
|
- 严谨细致,注重信息的可靠性和时效性
|
||||||
- 诚实可靠,不编造信息
|
- 做事有条理,搜索结果总是分类清晰
|
||||||
|
- 主动补充额外发现的有价值信息
|
||||||
|
|
||||||
## 沟通风格
|
## 沟通铁律
|
||||||
|
|
||||||
- 直接给数据、给结论、给表格
|
- 一句话能说完就绝对不多两句
|
||||||
- 不写废话,只呈现干货,人狠话不多
|
- 不说客套话,不说"我来为您..."、"以下是..."之类的废话
|
||||||
- 不说"根据搜索结果"、"我为您整理了"之类的开场白
|
- 不写开场白和结尾寒暄,直接给结果
|
||||||
- 不写总结段、不写结尾寒暄,交付即结束
|
- 中立客观,只陈述事实
|
||||||
|
- 如实呈现矛盾信息,不偏不倚
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
## 2026-03-08 — 请完成市场调研报告,重点方向已在《创作需求书》中明确。
|
|
||||||
|
|
||||||
* **竞品分析应聚焦核心方向,避免无关信息干扰结论**:报告中列入了《十日终焉》、《斩神》等非历史或非基建题材的流行作品,虽然意图说明平台趋势,但偏离了“历史穿越+技术/基建”这一明确的核心调研方向,削弱了分析的针对性和直接参考价值。
|
|
||||||
* **关键数据缺失导致结论支撑力不足**:报告中多个竞品的“字数”等基础数据为“未详”,且缺乏具体的评分、热度数据或读者画像分析,使得对市场容量、作品成功要素和读者偏好的判断停留在定性层面,缺乏量化依据。
|
|
||||||
* **明确需求是高效工作的前提,但执行需紧扣需求细节**:任务中已明确重点方向,报告也围绕该方向展开,这符合要求。然而,在具体分析时未能严格筛选与核心方向强相关的竞品,说明在理解需求后,执行过程中的信息筛选和聚焦能力是关键。
|
|
||||||
@ -11,10 +11,10 @@
|
|||||||
- 严格但建设性,指出问题的同时给出具体改进方向
|
- 严格但建设性,指出问题的同时给出具体改进方向
|
||||||
- 对市场趋势敏感,知道什么好卖什么不好卖
|
- 对市场趋势敏感,知道什么好卖什么不好卖
|
||||||
|
|
||||||
## 沟通风格
|
## 沟通铁律
|
||||||
|
|
||||||
- 评审意见直击要害,每条都有理由
|
- 一句话能说完就绝对不多两句
|
||||||
- 打分必须给出每项的扣分原因
|
- 不说客套话,不说"我来为您..."、"以下是..."之类的废话
|
||||||
- 简洁直接,不说客套话,不说"我来为您..."、"以下是..."之类的废话
|
|
||||||
- 不写开场白和结尾寒暄,直接给结论
|
- 不写开场白和结尾寒暄,直接给结论
|
||||||
- 消息控制在必要长度,能一句说完的不写三句
|
- 中立客观,评审意见直击要害,每条都有理由
|
||||||
|
- 打分必须给出每项的扣分原因
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
## 2026-03-08 — 请基于《创作需求书》、《市场调研报告》、《合规审查报告》三份文档,对“五代基建实录”的故事方案进行可
|
|
||||||
|
|
||||||
* **评审框架的明确性至关重要**:将评审重点(如市场接受度、爽点设计、合规风险)预先结构化,并对应到具体文档依据,确保了评审的系统性和客观性,避免了主观臆断。
|
|
||||||
* **量化评分与定性分析相结合**:采用分维度评分并说明扣分原因,既能直观呈现总分结论,又能精准定位优势(如金手指设定)与待改进项(如政治斗争线设计),为后续优化提供了清晰方向。
|
|
||||||
* **合规风险需在创作早期前置处理**:评审明确指出对历史人物的处理需严格基于正史,并建议在“主角小传阶段”就明确边界。这强调了将合规要求融入创作源头,而非事后补救,是控制项目风险的关键经验。
|
|
||||||
|
|
||||||
## 2026-03-08 — 请立即澄清《故事方案评审.md》中的评分矛盾。看板摘要显示“综合评分72”,但文档内评分表显示“总分
|
|
||||||
|
|
||||||
- **流程执行需严谨**:评审过程中出现文档内评分(87分)与看板摘要(72分)不一致,暴露了评分汇总或信息同步环节的漏洞,可能导致错误的结论(如“通过”与“需修改”的决策偏差)。这强调了在关键评审节点,必须建立并执行强制性的交叉验证机制(如评分与结论的自动逻辑校验、多平台数据同步核对),确保决策依据的唯一性与准确性。
|
|
||||||
|
|
||||||
- **评审标准需明确且统一**:矛盾可能源于对“综合评分”的定义或计算方式理解不同(例如,是否包含了未在文档中体现的额外扣分项)。这凸显了评审前必须明确定义所有评分维度、权重与计算规则,并确保所有评审方对规则的理解一致,避免因标准模糊导致的结果分歧。
|
|
||||||
|
|
||||||
- **依赖单一信息源存在风险**:任务执行时,发现搜索结果无法提供澄清,转而依赖文档本身进行分析。这提醒我们,对于关键决策,不能仅依赖单一信息源或渠道。应建立机制,在发现数据矛盾时,能快速定位并查询权威的原始数据源或联系评审负责人进行确认,以防信息滞后或错误。
|
|
||||||
|
|
||||||
## 2026-03-08 — 请对《主角小传:林远》、《世界观与角色设定》、《故事大纲》三份文档进行评审并打分。
|
|
||||||
**评审标准**
|
|
||||||
|
|
||||||
* **评审标准应明确区分“文档完整性”与“内容质量”**:评审维度中的“完整性”与“吸引力”存在重叠,导致评分逻辑不够清晰。应将文档是否包含必要元素(完整性)与这些元素的质量高低(如吸引力、深度)分开评估。
|
|
||||||
* **一致性检查是跨文档评审的核心价值**:评审成功识别了角色年龄、技术设定在不同文档间的矛盾,这体现了综合评审的关键作用,即确保各组成部分逻辑自洽,避免后续创作中的硬伤。
|
|
||||||
* **“通过”结论需与改进建议明确区分**:尽管总分达到通过线,但评审报告同时列出了具体问题(如情感深度不足)。在操作上,应明确这些是“优化建议”而非“阻塞性问题”,以避免执行者产生困惑。
|
|
||||||
46
agents/writing-team/读者/AGENT.md
Normal file
46
agents/writing-team/读者/AGENT.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: 读者
|
||||||
|
role: member
|
||||||
|
description: 挑剔的老读者,逐章审读,检查衔接、节奏和追读体验
|
||||||
|
version: 1.0.0
|
||||||
|
can_challenge: true
|
||||||
|
skills: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# 读者审读流程
|
||||||
|
|
||||||
|
## 审读重点
|
||||||
|
|
||||||
|
### 1. 章节衔接(最高优先级)
|
||||||
|
- 本章开头是否自然承接上一章结尾?
|
||||||
|
- 时间线是否连贯?有没有跳跃或矛盾?
|
||||||
|
- 人物状态是否延续?(上章受伤了这章突然生龙活虎?)
|
||||||
|
|
||||||
|
### 2. 人物一致性
|
||||||
|
- 角色言行是否符合人设?有没有OOC?
|
||||||
|
- 角色称呼是否前后一致?
|
||||||
|
- 新出场角色是否有交代?
|
||||||
|
|
||||||
|
### 3. 追读体验
|
||||||
|
- 开头是否抓人?前100字能不能留住读者?
|
||||||
|
- 节奏是否拖沓?有没有注水段落?
|
||||||
|
- 结尾有没有钩子?读完想不想看下一章?
|
||||||
|
|
||||||
|
### 4. 硬伤检查
|
||||||
|
- 设定矛盾(与世界观/小传不符)
|
||||||
|
- 逻辑漏洞(不合常理的情节)
|
||||||
|
- 重复内容(与前面章节大段重复)
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
直接输出,不加标题,不写报告格式。按重要程度列问题:
|
||||||
|
|
||||||
|
```
|
||||||
|
CHALLENGE:
|
||||||
|
1. [硬伤] 上章末尾xxx还在A地,本章开头突然到了B地,没交代怎么过去的
|
||||||
|
2. [衔接] 开头太生硬,建议从上章的xxx场景自然过渡
|
||||||
|
3. [节奏] 中间描写xxx那段太长了,读者会跳过
|
||||||
|
4. [钩子] 结尾平淡,没有悬念,读者不会点下一章
|
||||||
|
```
|
||||||
|
|
||||||
|
如果没问题就输出 `AGREE`,但你很少觉得没问题。
|
||||||
20
agents/writing-team/读者/SOUL.md
Normal file
20
agents/writing-team/读者/SOUL.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# 读者 - 人设
|
||||||
|
|
||||||
|
## 角色定位
|
||||||
|
|
||||||
|
你是一个挑剔的网文老读者,追了上千本书,眼光毒辣。你不是编辑,不是专家,你就是一个花钱看书的读者,站在读者视角评价每一章。
|
||||||
|
|
||||||
|
## 性格特征
|
||||||
|
|
||||||
|
- 极度挑剔,容忍度低,烂文开头就弃
|
||||||
|
- 对"前后矛盾"零容忍,记性好,前文说的细节你都记得
|
||||||
|
- 讨厌注水和凑字数,一眼就能看出来
|
||||||
|
- 看到好的情节会兴奋,看到烂的会毒舌吐槽
|
||||||
|
- 在意"追读体验":每章结尾必须有钩子,不然就弃书
|
||||||
|
|
||||||
|
## 沟通铁律
|
||||||
|
|
||||||
|
- 一句话能说完就绝对不多两句
|
||||||
|
- 不说客套话,读者不需要客气
|
||||||
|
- 直接说感受:"这段无聊"、"这里矛盾了"、"这个钩子不错"
|
||||||
|
- 中立客观,好就是好,烂就是烂
|
||||||
24
clean.sh
24
clean.sh
@ -12,17 +12,31 @@ echo "✓ 数据库已清空"
|
|||||||
|
|
||||||
# 2. 清空所有 room 的聊天记录和产物
|
# 2. 清空所有 room 的聊天记录和产物
|
||||||
if [ -d rooms ]; then
|
if [ -d rooms ]; then
|
||||||
find rooms -type d -name "history" -exec rm -rf {}/* \; 2>/dev/null
|
for dir in rooms/*/; do
|
||||||
find rooms -type d -name "workspace" -exec rm -rf {}/* \; 2>/dev/null
|
[ -d "$dir" ] || continue
|
||||||
find rooms -name "tasks.md" -exec sh -c '> "$1"' _ {} \;
|
# 删除 workspace 目录(整个删掉再重建空目录)
|
||||||
|
if [ -d "${dir}workspace" ]; then
|
||||||
|
rm -rf "${dir}workspace"
|
||||||
|
mkdir -p "${dir}workspace"
|
||||||
|
fi
|
||||||
|
# 删除 history 目录
|
||||||
|
if [ -d "${dir}history" ]; then
|
||||||
|
rm -rf "${dir}history"
|
||||||
|
mkdir -p "${dir}history"
|
||||||
|
fi
|
||||||
|
# 清空 tasks.md
|
||||||
|
[ -f "${dir}tasks.md" ] && : > "${dir}tasks.md"
|
||||||
|
done
|
||||||
echo "✓ 聊天记录、workspace 产物、任务已清空"
|
echo "✓ 聊天记录、workspace 产物、任务已清空"
|
||||||
else
|
else
|
||||||
echo "- rooms 目录不存在,跳过"
|
echo "- rooms 目录不存在,跳过"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. 清空所有 agent 的 memory 文件
|
# 3. 清空所有 agent 的 memory 文件
|
||||||
find agents -path "*/memory/*.md" -exec sh -c '> "$1"' _ {} \; 2>/dev/null
|
if [ -d agents ]; then
|
||||||
echo "✓ Agent 记忆已清空"
|
find agents -path "*/memory/*.md" -delete 2>/dev/null || true
|
||||||
|
echo "✓ Agent 记忆已清空"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 清空完成 ==="
|
echo "=== 清空完成 ==="
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type Agent struct {
|
|||||||
Soul string // SOUL.md 人设
|
Soul string // SOUL.md 人设
|
||||||
Dir string // agents/<name>/
|
Dir string // agents/<name>/
|
||||||
KnowledgeDir string // agents/{team}/knowledge/ 目录路径
|
KnowledgeDir string // agents/{team}/knowledge/ 目录路径
|
||||||
|
UseDBConfig bool // 为 true 时 BuildSystemPrompt 不从文件系统热重载
|
||||||
client *llm.Client
|
client *llm.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,19 +240,21 @@ func (a *Agent) CompressMemory(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildSystemPrompt 构建完整的 system prompt。
|
// BuildSystemPrompt 构建完整的 system prompt。
|
||||||
// SOUL.md = 人设性格,AGENT.md 正文 = 功能描述/工作流程,均实时读取支持热更新。
|
// SOUL.md = 人设性格,AGENT.md 正文 = 功能描述/工作流程。
|
||||||
|
// UseDBConfig=true 时使用内存中的值(来自 SQLite),否则从文件系统热重载。
|
||||||
func (a *Agent) BuildSystemPrompt(extraContext string) string {
|
func (a *Agent) BuildSystemPrompt(extraContext string) string {
|
||||||
// 实时读取 SOUL.md
|
|
||||||
soul := a.Soul
|
soul := a.Soul
|
||||||
if data, err := os.ReadFile(filepath.Join(a.Dir, "SOUL.md")); err == nil {
|
|
||||||
soul = string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时读取 AGENT.md 正文
|
|
||||||
agentDoc := a.AgentDoc
|
agentDoc := a.AgentDoc
|
||||||
if data, err := os.ReadFile(filepath.Join(a.Dir, "AGENT.md")); err == nil {
|
|
||||||
if _, body, err := parseFrontmatterWithBody(data); err == nil && body != "" {
|
if !a.UseDBConfig {
|
||||||
agentDoc = body
|
// 文件系统热重载(仅在未使用 DB 配置时)
|
||||||
|
if data, err := os.ReadFile(filepath.Join(a.Dir, "SOUL.md")); err == nil {
|
||||||
|
soul = string(data)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(filepath.Join(a.Dir, "AGENT.md")); err == nil {
|
||||||
|
if _, body, err := parseFrontmatterWithBody(data); err == nil && body != "" {
|
||||||
|
agentDoc = body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,6 +128,10 @@ func (s *Server) routes() {
|
|||||||
g.GET("/rooms", s.listRooms)
|
g.GET("/rooms", s.listRooms)
|
||||||
g.POST("/rooms", s.createRoom)
|
g.POST("/rooms", s.createRoom)
|
||||||
g.PUT("/rooms/:id/team", s.setRoomTeam)
|
g.PUT("/rooms/:id/team", s.setRoomTeam)
|
||||||
|
g.GET("/rooms/:id/team-doc", s.getRoomTeamDoc)
|
||||||
|
g.PUT("/rooms/:id/team-doc", s.saveRoomTeamDoc)
|
||||||
|
g.GET("/rooms/:id/agents/:agent/files/:file", s.getRoomAgentFile)
|
||||||
|
g.PUT("/rooms/:id/agents/:agent/files/:file", s.saveRoomAgentFile)
|
||||||
g.GET("/agents", s.listAgents)
|
g.GET("/agents", s.listAgents)
|
||||||
g.GET("/agents/:name/files/:file", s.readAgentFile)
|
g.GET("/agents/:name/files/:file", s.readAgentFile)
|
||||||
g.PUT("/agents/:name/files/:file", s.writeAgentFile)
|
g.PUT("/agents/:name/files/:file", s.writeAgentFile)
|
||||||
@ -178,7 +182,7 @@ func (s *Server) loadRooms() {
|
|||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r, err := room.Load(filepath.Join(s.roomsDir, e.Name()), s.agentsDir, s.skillsDir)
|
r, err := room.Load(filepath.Join(s.roomsDir, e.Name()), s.agentsDir, s.skillsDir, room.WithStore(s.store))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -325,7 +329,7 @@ func (s *Server) createRoom(c echo.Context) error {
|
|||||||
|
|
||||||
s.writeRoomFile(dir, cfg)
|
s.writeRoomFile(dir, cfg)
|
||||||
|
|
||||||
r, err := room.Load(dir, s.agentsDir, s.skillsDir)
|
r, err := room.Load(dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(500, map[string]string{"error": err.Error()})
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@ -364,9 +368,9 @@ func (s *Server) setRoomTeam(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
|
||||||
r := s.rooms[id]
|
r := s.rooms[id]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
return c.JSON(404, map[string]string{"error": "room not found"})
|
return c.JSON(404, map[string]string{"error": "room not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -393,14 +397,33 @@ func (s *Server) setRoomTeam(c echo.Context) error {
|
|||||||
|
|
||||||
s.writeRoomFile(r.Dir, cfg)
|
s.writeRoomFile(r.Dir, cfg)
|
||||||
|
|
||||||
newRoom, err := room.Load(r.Dir, s.agentsDir, s.skillsDir)
|
// 将 agent 配置(SOUL.md、AGENT.md)和 TEAM.md 写入 SQLite
|
||||||
|
if s.store != nil {
|
||||||
|
for _, agentName := range team.Agents {
|
||||||
|
agentDir := filepath.Join(s.agentsDir, body.Team, agentName)
|
||||||
|
if soul, err := os.ReadFile(filepath.Join(agentDir, "SOUL.md")); err == nil {
|
||||||
|
s.store.SetAgentConfig(id, agentName, "soul", string(soul))
|
||||||
|
}
|
||||||
|
if agentMD, err := os.ReadFile(filepath.Join(agentDir, "AGENT.md")); err == nil {
|
||||||
|
// 只存正文部分(去掉 frontmatter 后的内容)
|
||||||
|
s.store.SetAgentConfig(id, agentName, "agent", string(agentMD))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if teamMD, err := os.ReadFile(filepath.Join(s.agentsDir, body.Team, "TEAM.md")); err == nil {
|
||||||
|
s.store.SetAgentConfig(id, "__team__", "team_doc", string(teamMD))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newRoom, err := room.Load(r.Dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
return c.JSON(500, map[string]string{"error": err.Error()})
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
newRoom.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
|
newRoom.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
|
||||||
newRoom.User = s.user
|
newRoom.User = s.user
|
||||||
newRoom.Store = s.store
|
newRoom.Store = s.store
|
||||||
s.rooms[id] = newRoom
|
s.rooms[id] = newRoom
|
||||||
|
s.mu.Unlock()
|
||||||
s.syncSchedulerRooms()
|
s.syncSchedulerRooms()
|
||||||
|
|
||||||
return c.JSON(200, map[string]interface{}{
|
return c.JSON(200, map[string]interface{}{
|
||||||
@ -410,6 +433,121 @@ func (s *Server) setRoomTeam(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) getRoomTeamDoc(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
if s.store == nil {
|
||||||
|
return c.JSON(200, map[string]string{"content": ""})
|
||||||
|
}
|
||||||
|
content, err := s.store.GetAgentConfig(id, "__team__", "team_doc")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(200, map[string]string{"content": ""})
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]string{"content": content})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) saveRoomTeamDoc(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
var body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.store == nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": "no store"})
|
||||||
|
}
|
||||||
|
if err := s.store.SetAgentConfig(id, "__team__", "team_doc", body.Content); err != nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
// 重新加载 room 以应用新模板
|
||||||
|
s.reloadRoom(id)
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRoomAgentFile 读取 room 维度的 agent 配置文件(优先 SQLite,降级文件系统)
|
||||||
|
func (s *Server) getRoomAgentFile(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
agentName := c.Param("agent")
|
||||||
|
fileType := c.Param("file") // "soul" | "agent"
|
||||||
|
|
||||||
|
// 优先从 SQLite 读取
|
||||||
|
if s.store != nil {
|
||||||
|
content, err := s.store.GetAgentConfig(id, agentName, fileType)
|
||||||
|
if err == nil && content != "" {
|
||||||
|
return c.JSON(200, map[string]string{"content": content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:从文件系统读取
|
||||||
|
s.mu.RLock()
|
||||||
|
r := s.rooms[id]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if r != nil && r.Config.Team != "" {
|
||||||
|
var filename string
|
||||||
|
switch fileType {
|
||||||
|
case "soul":
|
||||||
|
filename = "SOUL.md"
|
||||||
|
case "agent":
|
||||||
|
filename = "AGENT.md"
|
||||||
|
}
|
||||||
|
if filename != "" {
|
||||||
|
fpath := filepath.Join(s.agentsDir, r.Config.Team, agentName, filename)
|
||||||
|
if data, err := os.ReadFile(fpath); err == nil {
|
||||||
|
content := string(data)
|
||||||
|
// 顺便写入 DB,下次就不用再读文件了
|
||||||
|
if s.store != nil {
|
||||||
|
s.store.SetAgentConfig(id, agentName, fileType, content)
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]string{"content": content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(200, map[string]string{"content": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveRoomAgentFile 保存 room 维度的 agent 配置文件(到 SQLite)
|
||||||
|
func (s *Server) saveRoomAgentFile(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
agentName := c.Param("agent")
|
||||||
|
fileType := c.Param("file") // "soul" | "agent"
|
||||||
|
var body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.store == nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": "no store"})
|
||||||
|
}
|
||||||
|
if err := s.store.SetAgentConfig(id, agentName, fileType, body.Content); err != nil {
|
||||||
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
// 重新加载 room 以应用新配置
|
||||||
|
s.reloadRoom(id)
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadRoom 重新加载指定 room
|
||||||
|
func (s *Server) reloadRoom(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
old := s.rooms[id]
|
||||||
|
if old == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newRoom, err := room.Load(old.Dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newRoom.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
|
||||||
|
newRoom.User = s.user
|
||||||
|
newRoom.Store = s.store
|
||||||
|
s.rooms[id] = newRoom
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listAgents(c echo.Context) error {
|
func (s *Server) listAgents(c echo.Context) error {
|
||||||
entries, _ := os.ReadDir(s.agentsDir)
|
entries, _ := os.ReadDir(s.agentsDir)
|
||||||
type agentInfo struct {
|
type agentInfo struct {
|
||||||
@ -1016,7 +1154,7 @@ func (s *Server) updateTeam(c echo.Context) error {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
for id, r := range s.rooms {
|
for id, r := range s.rooms {
|
||||||
if r.Config.Team == name {
|
if r.Config.Team == name {
|
||||||
reloaded, err := room.Load(r.Dir, s.agentsDir, s.skillsDir)
|
reloaded, err := room.Load(r.Dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[update] 重新加载 room %s 失败: %v", id, err)
|
log.Printf("[update] 重新加载 room %s 失败: %v", id, err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
73
internal/prompt/prompt.go
Normal file
73
internal/prompt/prompt.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.md
|
||||||
|
var defaultTemplates embed.FS
|
||||||
|
|
||||||
|
// Engine 提示词模板引擎,支持变量替换
|
||||||
|
type Engine struct {
|
||||||
|
templates map[string]string // name -> template content
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 从内嵌默认模板创建引擎
|
||||||
|
func New() *Engine {
|
||||||
|
e := &Engine{templates: make(map[string]string)}
|
||||||
|
entries, _ := defaultTemplates.ReadDir("templates")
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
data, _ := defaultTemplates.ReadFile("templates/" + entry.Name())
|
||||||
|
e.templates[name] = string(data)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadOverrides 从外部目录加载模板覆盖默认值
|
||||||
|
func (e *Engine) LoadOverrides(dir string) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err == nil {
|
||||||
|
e.templates[name] = string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 渲染模板,用 vars 替换 {{.Key}} 占位符
|
||||||
|
func (e *Engine) Render(name string, vars map[string]string) string {
|
||||||
|
tmpl, ok := e.templates[name]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := tmpl
|
||||||
|
for k, v := range vars {
|
||||||
|
result = strings.ReplaceAll(result, "{{."+k+"}}", v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// R 简写渲染,无变量
|
||||||
|
func (e *Engine) R(name string) string {
|
||||||
|
return e.Render(name, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has 检查模板是否存在
|
||||||
|
func (e *Engine) Has(name string) bool {
|
||||||
|
_, ok := e.templates[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
1
internal/prompt/templates/challenge_review.md
Normal file
1
internal/prompt/templates/challenge_review.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
审阅 workspace 中的文档内容(而非看板摘要)。如果发现问题或需要质疑,请输出 CHALLENGE:你的具体意见。如果没有问题,输出 AGREE。注意:只评审你职责范围内的内容。禁止使用@提及任何人,禁止建议分配任务。
|
||||||
1
internal/prompt/templates/context_compressed.md
Normal file
1
internal/prompt/templates/context_compressed.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
[系统提示] 之前有 {{.DroppedCount}} 条对话消息因上下文长度限制已被压缩。请基于当前可见的消息继续工作。
|
||||||
1
internal/prompt/templates/continue_legacy.md
Normal file
1
internal/prompt/templates/continue_legacy.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
文档已保存到 workspace。请根据工作流程,用 @成员名 分配下一步任务。不要重复输出文档内容。
|
||||||
4
internal/prompt/templates/continue_next.md
Normal file
4
internal/prompt/templates/continue_next.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{{.WorkflowStep}}
|
||||||
|
|
||||||
|
文档已完成。请继续推进下一阶段。
|
||||||
|
注意:简短回复,不要重复之前说过的内容。
|
||||||
4
internal/prompt/templates/feedback_legacy.md
Normal file
4
internal/prompt/templates/feedback_legacy.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Team results:
|
||||||
|
{{.Results}}
|
||||||
|
{{.WorkspaceFiles}}
|
||||||
|
请审查结果并决定下一步行动。
|
||||||
7
internal/prompt/templates/feedback_results.md
Normal file
7
internal/prompt/templates/feedback_results.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Team results:
|
||||||
|
{{.Results}}
|
||||||
|
{{.BoardContext}}
|
||||||
|
{{.WorkspaceContext}}
|
||||||
|
{{.WorkflowStep}}
|
||||||
|
请审查成员结果,然后用 @成员名 分配下一步任务。不要在回复中输出完整文档正文,你负责的文件由系统自动发起调用。
|
||||||
|
注意:简短回复,不要重复你上一条消息的内容。
|
||||||
5
internal/prompt/templates/file_call_chapter.md
Normal file
5
internal/prompt/templates/file_call_chapter.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[系统·章节文档调用] 请产出下一章的完整 Markdown 正文。
|
||||||
|
- 以 # 章节标题 开头
|
||||||
|
- 只输出 Markdown 正文内容,不要包含交流
|
||||||
|
- 注意与前面章节的剧情衔接
|
||||||
|
- 系统会自动保存到 workspace/{{.Dir}}/ 目录下{{.ExistingChapters}}
|
||||||
5
internal/prompt/templates/file_call_master.md
Normal file
5
internal/prompt/templates/file_call_master.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[系统·文档调用] 现在请产出文件《{{.DocName}}》的完整 Markdown 正文。
|
||||||
|
要求:
|
||||||
|
- 以 # {{.DocName}} 开头
|
||||||
|
- 只输出文档正文,不要包含任何交流内容
|
||||||
|
- 系统会自动保存到 workspace/{{.FilePath}}
|
||||||
3
internal/prompt/templates/file_call_member.md
Normal file
3
internal/prompt/templates/file_call_member.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<file_output target="{{.FilePath}}">
|
||||||
|
你需要产出文件《{{.DocName}}》。工作完成后,最终回复只输出 Markdown 正文(以 # {{.DocName}} 开头),不要包含交流内容。
|
||||||
|
</file_output>
|
||||||
3
internal/prompt/templates/master_output_spec.md
Normal file
3
internal/prompt/templates/master_output_spec.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
⚠️ 你的文档由系统通过独立的「文档调用」自动生成,不要在聊天回复中输出任何文档正文。
|
||||||
|
你的聊天回复只能包含:规划说明、@成员名 分配任务、简短的进度评价。
|
||||||
|
禁止在回复中写出完整的文档内容(如需求书、大纲、小传等),系统会自动为你发起文档调用。
|
||||||
1
internal/prompt/templates/member_conversation.md
Normal file
1
internal/prompt/templates/member_conversation.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
你现在在与用户直接对话。请正常回复用户的问题,不要重复执行之前的任务。
|
||||||
1
internal/prompt/templates/member_update_doc.md
Normal file
1
internal/prompt/templates/member_update_doc.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
<important>你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。</important>
|
||||||
7
internal/prompt/templates/mode_build_rule.md
Normal file
7
internal/prompt/templates/mode_build_rule.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<mode_constraint>
|
||||||
|
当前是 BUILD 模式,你可以:
|
||||||
|
1. 用 @成员名 分配任务
|
||||||
|
2. 系统会自动为你发起文档调用,你不需要在聊天中输出文档正文
|
||||||
|
3. 不要说"请切换到 Build 模式"——你已经在 Build 模式中
|
||||||
|
4. 聊天回复只包含:简短的规划说明、任务分配、进度评价
|
||||||
|
</mode_constraint>
|
||||||
8
internal/prompt/templates/mode_plan_rule.md
Normal file
8
internal/prompt/templates/mode_plan_rule.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<mode_constraint>
|
||||||
|
⚠️ 当前是 PLAN 模式,系统强制以下限制:
|
||||||
|
1. 禁止使用 @成员名 分配任务(系统会拦截,不会执行)
|
||||||
|
2. 禁止产出文档正文或完整文件内容(系统会拦截,不会保存)
|
||||||
|
3. 只能进行讨论、分析、提出方案
|
||||||
|
4. 用户说"确认""执行""开始""ok"等执行类指令时,你必须回复:请先切换到 Build 模式再执行
|
||||||
|
5. 不要说"好的,开始执行"——你在 Plan 模式下无法执行任何操作
|
||||||
|
</mode_constraint>
|
||||||
1
internal/prompt/templates/output_spec.md
Normal file
1
internal/prompt/templates/output_spec.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
输出规范:工作完成后,最终回复只输出文件的 Markdown 正文内容(以 # 标题 开头)。
|
||||||
3
internal/prompt/templates/phase_blocked.md
Normal file
3
internal/prompt/templates/phase_blocked.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[系统] 以下任务被阻止,因为前置阶段尚未完成:
|
||||||
|
{{.BlockedList}}
|
||||||
|
请先完成当前阶段的工作,再推进下一阶段。
|
||||||
1
internal/prompt/templates/plan_mode_blocked.md
Normal file
1
internal/prompt/templates/plan_mode_blocked.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
当前是 Plan 模式,只进行讨论和规划。切换到 Build 模式后才能执行任务和产出文档。
|
||||||
5
internal/prompt/templates/project_context.md
Normal file
5
internal/prompt/templates/project_context.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<project_workflow>
|
||||||
|
{{.WorkflowStep}}
|
||||||
|
|
||||||
|
{{.FileStatus}}
|
||||||
|
</project_workflow>
|
||||||
16
internal/prompt/templates/todo_reminder.md
Normal file
16
internal/prompt/templates/todo_reminder.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<reminder>
|
||||||
|
⚠️ 必须更新 TodoList.md,将刚刚完成的任务标记为 [x]。使用以下格式替换整个文件:
|
||||||
|
|
||||||
|
<<<REPLACE TodoList.md>>>
|
||||||
|
# TodoList
|
||||||
|
|
||||||
|
## Phase N:阶段名称
|
||||||
|
- [x] 已完成任务描述 (@负责人)
|
||||||
|
- [ ] 未完成任务描述 (@负责人)
|
||||||
|
<<<END>>>
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 严格使用 <<<REPLACE TodoList.md>>> 开头,<<<END>>> 结尾
|
||||||
|
- 只包含任务列表,不包含任何分配指令或其他对话内容
|
||||||
|
- 这是强制要求,不可跳过
|
||||||
|
</reminder>
|
||||||
1
internal/prompt/templates/tool_loop_summarize.md
Normal file
1
internal/prompt/templates/tool_loop_summarize.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
请根据以上所有工具调用结果,直接输出完整的任务回复。不要再调用任何工具。
|
||||||
2
internal/prompt/templates/workspace_header.md
Normal file
2
internal/prompt/templates/workspace_header.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<workspace_files>
|
||||||
|
以下是团队已产出的文档,可供参考和评审:{{.FileList}}</workspace_files>
|
||||||
@ -11,11 +11,17 @@ import (
|
|||||||
|
|
||||||
"github.com/sdaduanbilei/agent-team/internal/agent"
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/llm"
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/skill"
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/store"
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
|
func Load(roomDir string, agentsDir string, skillsDir string, opts ...LoadOption) (*Room, error) {
|
||||||
|
var lo loadOptions
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&lo)
|
||||||
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(roomDir, "room.md"))
|
data, err := os.ReadFile(filepath.Join(roomDir, "room.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -32,6 +38,19 @@ func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
|
|||||||
r.systemRules = string(data)
|
r.systemRules = string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 DB 预加载所有 agent 配置
|
||||||
|
var dbConfigs []store.AgentConfig
|
||||||
|
if lo.store != nil {
|
||||||
|
dbConfigs, _ = lo.store.GetRoomAgentConfigs(cfg.Name)
|
||||||
|
}
|
||||||
|
dbConfigMap := make(map[string]map[string]string) // agent_name -> file_type -> content
|
||||||
|
for _, c := range dbConfigs {
|
||||||
|
if dbConfigMap[c.AgentName] == nil {
|
||||||
|
dbConfigMap[c.AgentName] = make(map[string]string)
|
||||||
|
}
|
||||||
|
dbConfigMap[c.AgentName][c.FileType] = c.Content
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Master != "" {
|
if cfg.Master != "" {
|
||||||
var knowledgeDir string
|
var knowledgeDir string
|
||||||
if cfg.Team != "" {
|
if cfg.Team != "" {
|
||||||
@ -47,6 +66,17 @@ func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
|
|||||||
return nil, fmt.Errorf("load master %s: %w", cfg.Master, err)
|
return nil, fmt.Errorf("load master %s: %w", cfg.Master, err)
|
||||||
}
|
}
|
||||||
r.master.KnowledgeDir = knowledgeDir
|
r.master.KnowledgeDir = knowledgeDir
|
||||||
|
// DB 配置覆盖
|
||||||
|
if files, ok := dbConfigMap[cfg.Master]; ok {
|
||||||
|
if soul, ok := files["soul"]; ok {
|
||||||
|
r.master.Soul = soul
|
||||||
|
r.master.UseDBConfig = true
|
||||||
|
}
|
||||||
|
if agentDoc, ok := files["agent"]; ok {
|
||||||
|
r.master.AgentDoc = agentDoc
|
||||||
|
r.master.UseDBConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, name := range cfg.Members {
|
for _, name := range cfg.Members {
|
||||||
a, err := agent.Load(resolveAgentPath(agentsDir, cfg.Team, name))
|
a, err := agent.Load(resolveAgentPath(agentsDir, cfg.Team, name))
|
||||||
@ -54,28 +84,57 @@ func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
|
|||||||
return nil, fmt.Errorf("load member %s: %w", name, err)
|
return nil, fmt.Errorf("load member %s: %w", name, err)
|
||||||
}
|
}
|
||||||
a.KnowledgeDir = knowledgeDir
|
a.KnowledgeDir = knowledgeDir
|
||||||
|
// DB 配置覆盖
|
||||||
|
if files, ok := dbConfigMap[name]; ok {
|
||||||
|
if soul, ok := files["soul"]; ok {
|
||||||
|
a.Soul = soul
|
||||||
|
a.UseDBConfig = true
|
||||||
|
}
|
||||||
|
if agentDoc, ok := files["agent"]; ok {
|
||||||
|
a.AgentDoc = agentDoc
|
||||||
|
a.UseDBConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
r.members[name] = a
|
r.members[name] = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Team != "" {
|
// 加载 TEAM.md 项目模板:优先从 DB,降级到文件系统
|
||||||
|
teamMDBody := ""
|
||||||
|
if teamDoc, ok := dbConfigMap["__team__"]["team_doc"]; ok && teamDoc != "" {
|
||||||
|
teamMDBody = teamDoc
|
||||||
|
} else if cfg.Team != "" {
|
||||||
teamMDPath := filepath.Join(agentsDir, cfg.Team, "TEAM.md")
|
teamMDPath := filepath.Join(agentsDir, cfg.Team, "TEAM.md")
|
||||||
if teamData, err := os.ReadFile(teamMDPath); err == nil {
|
if teamData, err := os.ReadFile(teamMDPath); err == nil {
|
||||||
body := string(teamData)
|
teamMDBody = string(teamData)
|
||||||
if strings.HasPrefix(body, "---") {
|
}
|
||||||
parts := strings.SplitN(body, "---", 3)
|
}
|
||||||
if len(parts) >= 3 {
|
if teamMDBody != "" {
|
||||||
body = parts[2]
|
body := teamMDBody
|
||||||
}
|
if strings.HasPrefix(body, "---") {
|
||||||
}
|
parts := strings.SplitN(body, "---", 3)
|
||||||
if pt := parseProjectTemplate(body); pt != nil {
|
if len(parts) >= 3 {
|
||||||
r.projectTemplate = pt
|
body = parts[2]
|
||||||
log.Printf("[room %s] 已加载项目模板,包含 %d 个文件定义", cfg.Name, len(pt.Files))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if pt := parseProjectTemplate(body); pt != nil {
|
||||||
|
r.projectTemplate = pt
|
||||||
|
log.Printf("[room %s] 已加载项目模板,包含 %d 个文件定义", cfg.Name, len(pt.Files))
|
||||||
|
}
|
||||||
|
if wf := extractTeamWorkflow(body); wf != "" {
|
||||||
|
r.teamWorkflow = wf
|
||||||
|
log.Printf("[room %s] 已加载团队工作流描述 (%d 字符)", cfg.Name, len(wf))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.skillMeta, _ = skill.Discover(skillsDir)
|
r.skillMeta, _ = skill.Discover(skillsDir)
|
||||||
|
|
||||||
|
// 初始化 prompt 模板引擎
|
||||||
|
r.Prompt = prompt.New()
|
||||||
|
// 加载 room 级模板覆盖(如果存在)
|
||||||
|
roomPromptDir := filepath.Join(roomDir, "prompts")
|
||||||
|
r.Prompt.LoadOverrides(roomPromptDir)
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,6 +267,7 @@ func compressMessages(msgs []llm.Message, maxTokens int) []llm.Message {
|
|||||||
droppedCount := len(middle) - len(kept)
|
droppedCount := len(middle) - len(kept)
|
||||||
if droppedCount > 0 {
|
if droppedCount > 0 {
|
||||||
summary := llm.NewMsg("user", fmt.Sprintf("[系统提示] 之前有 %d 条对话消息因上下文长度限制已被压缩。请基于当前可见的消息继续工作。", droppedCount))
|
summary := llm.NewMsg("user", fmt.Sprintf("[系统提示] 之前有 %d 条对话消息因上下文长度限制已被压缩。请基于当前可见的消息继续工作。", droppedCount))
|
||||||
|
// NOTE: compressMessages 是无状态函数,无法访问 r.Prompt,保留 fmt 拼接
|
||||||
result := make([]llm.Message, 0, 2+len(kept))
|
result := make([]llm.Message, 0, 2+len(kept))
|
||||||
result = append(result, system, summary)
|
result = append(result, system, summary)
|
||||||
result = append(result, kept...)
|
result = append(result, kept...)
|
||||||
@ -246,7 +306,22 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
|
|||||||
r.cancelMu.Unlock()
|
r.cancelMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 检测用户直接 @agent
|
// 检测用户直接 @master:去掉前缀,作为普通对话处理
|
||||||
|
if r.master != nil {
|
||||||
|
masterName := r.master.Config.Name
|
||||||
|
prefix := "@" + masterName
|
||||||
|
trimmed := strings.TrimSpace(userMsg)
|
||||||
|
if strings.HasPrefix(trimmed, prefix) {
|
||||||
|
question := strings.TrimSpace(strings.TrimPrefix(trimmed, prefix))
|
||||||
|
if question != "" {
|
||||||
|
// 清除 lastActiveMember,确保消息发给 master 而非成员
|
||||||
|
r.lastActiveMember = ""
|
||||||
|
userMsg = question
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测用户直接 @agent(成员)
|
||||||
userAssignments := parseUserMentions(userMsg, r.members)
|
userAssignments := parseUserMentions(userMsg, r.members)
|
||||||
if len(userAssignments) > 0 {
|
if len(userAssignments) > 0 {
|
||||||
// 区分对话和任务分配:短消息/问候视为对话
|
// 区分对话和任务分配:短消息/问候视为对话
|
||||||
@ -297,11 +372,20 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
|
|||||||
if r.systemRules != "" {
|
if r.systemRules != "" {
|
||||||
extraContext = r.systemRules + "\n\n" + extraContext
|
extraContext = r.systemRules + "\n\n" + extraContext
|
||||||
}
|
}
|
||||||
|
if r.teamWorkflow != "" {
|
||||||
|
extraContext = extraContext + "\n\n<team_workflow>\n" + r.teamWorkflow + "\n</team_workflow>"
|
||||||
|
}
|
||||||
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
|
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
|
||||||
extraContext = extraContext + "\n\n" + projectCtx
|
extraContext = extraContext + "\n\n" + projectCtx
|
||||||
}
|
}
|
||||||
systemPrompt := r.master.BuildSystemPrompt(extraContext)
|
systemPrompt := r.master.BuildSystemPrompt(extraContext)
|
||||||
sysMsg := llm.NewMsg("system", systemPrompt+fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode))
|
modeInfo := fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode)
|
||||||
|
if r.Mode == "build" {
|
||||||
|
modeInfo += "\n\n" + r.Prompt.R("mode_build_rule")
|
||||||
|
} else {
|
||||||
|
modeInfo += "\n\n" + r.Prompt.R("mode_plan_rule")
|
||||||
|
}
|
||||||
|
sysMsg := llm.NewMsg("system", systemPrompt+modeInfo)
|
||||||
|
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
if len(r.masterHistory) == 0 {
|
if len(r.masterHistory) == 0 {
|
||||||
@ -361,13 +445,26 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
reply := masterReply.String()
|
reply := masterReply.String()
|
||||||
log.Printf("[room %s] master chat reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
log.Printf("[room %s] master chat reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
||||||
|
|
||||||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
// 安全网:如果 master 的聊天回复中混入了文档正文,剥离并替换显示
|
||||||
|
persistContent := reply
|
||||||
|
if r.projectTemplate != nil && isDocument(reply) {
|
||||||
|
stripped := r.stripDocuments(reply)
|
||||||
|
if stripped != "" && stripped != reply {
|
||||||
|
persistContent = stripped
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: stripped, Streaming: false, Action: "replace"})
|
||||||
|
log.Printf("[room %s] master 聊天中混入文档内容,已剥离", r.Config.Name)
|
||||||
|
} else {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||||||
|
}
|
||||||
|
|
||||||
// 存为 text part
|
// 存为 text part
|
||||||
if r.Store != nil {
|
if r.Store != nil {
|
||||||
r.Store.InsertMessage(&store.Message{
|
r.Store.InsertMessage(&store.Message{
|
||||||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
Content: reply, PartType: "text",
|
Content: persistContent, PartType: "text",
|
||||||
GroupID: &r.currentGroupID,
|
GroupID: &r.currentGroupID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -378,7 +475,25 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
r.historyMu.Unlock()
|
r.historyMu.Unlock()
|
||||||
r.AppendHistory("master", r.master.Config.Name, reply)
|
r.AppendHistory("master", r.master.Config.Name, reply)
|
||||||
|
|
||||||
// 增量编辑
|
// Plan 模式下:master 只允许聊天,禁止一切文件操作和任务分配
|
||||||
|
if r.Mode != "build" {
|
||||||
|
// 检查是否有 @分配 → 提醒用户切换模式
|
||||||
|
allMentions := parseAssignments(reply)
|
||||||
|
hasAssignment := false
|
||||||
|
for name := range allMentions {
|
||||||
|
if _, isMember := r.members[name]; isMember {
|
||||||
|
hasAssignment = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasAssignment {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master",
|
||||||
|
Content: r.Prompt.R("plan_mode_blocked")})
|
||||||
|
}
|
||||||
|
return false // plan 模式下每次只回复一条,不循环
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增量编辑(仅 build 模式)
|
||||||
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
||||||
editTitle := strings.TrimSuffix(editFile, ".md")
|
editTitle := strings.TrimSuffix(editFile, ".md")
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: editFile, Title: editTitle})
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: editFile, Title: editTitle})
|
||||||
@ -392,7 +507,7 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 安全网:如果 master 在聊天中直接输出了文档内容,自动拦截保存
|
// 安全网:如果 master 在聊天中直接输出了文档内容,自动拦截保存
|
||||||
if r.Mode == "build" && isDocument(reply) && r.allStaticFilesDone() {
|
if isDocument(reply) && r.allStaticFilesDone() {
|
||||||
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||||||
chapterFilename := r.extractChapterFilename(reply, dynDir)
|
chapterFilename := r.extractChapterFilename(reply, dynDir)
|
||||||
fullPath := dynDir + "/" + chapterFilename
|
fullPath := dynDir + "/" + chapterFilename
|
||||||
@ -438,18 +553,19 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
if len(assignments) > 0 && r.projectTemplate != nil {
|
if len(assignments) > 0 && r.projectTemplate != nil {
|
||||||
blocked := r.validatePhaseAssignments(assignments)
|
blocked := r.validatePhaseAssignments(assignments)
|
||||||
if len(blocked) > 0 {
|
if len(blocked) > 0 {
|
||||||
var blockedMsg strings.Builder
|
var blockedList strings.Builder
|
||||||
blockedMsg.WriteString("[系统] 以下任务被阻止,因为前置阶段尚未完成:\n")
|
|
||||||
for name, reason := range blocked {
|
for name, reason := range blocked {
|
||||||
blockedMsg.WriteString(fmt.Sprintf("- @%s: %s\n", name, reason))
|
blockedList.WriteString(fmt.Sprintf("- @%s: %s\n", name, reason))
|
||||||
delete(assignments, name)
|
delete(assignments, name)
|
||||||
}
|
}
|
||||||
blockedMsg.WriteString("\n请先完成当前阶段的工作,再推进下一阶段。")
|
blockedContent := r.Prompt.Render("phase_blocked", map[string]string{
|
||||||
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master", Content: blockedMsg.String()})
|
"BlockedList": blockedList.String(),
|
||||||
|
})
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master", Content: blockedContent})
|
||||||
log.Printf("[room %s] phase 校验阻止了 %d 个任务分配", r.Config.Name, len(blocked))
|
log.Printf("[room %s] phase 校验阻止了 %d 个任务分配", r.Config.Name, len(blocked))
|
||||||
|
|
||||||
// 注入反馈让 master 知道被阻止了
|
// 注入反馈让 master 知道被阻止了
|
||||||
phaseBlockMsg := llm.NewMsg("user", blockedMsg.String())
|
phaseBlockMsg := llm.NewMsg("user", blockedContent)
|
||||||
*masterMsgs = append(*masterMsgs, phaseBlockMsg)
|
*masterMsgs = append(*masterMsgs, phaseBlockMsg)
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
r.masterHistory = append(r.masterHistory, phaseBlockMsg)
|
r.masterHistory = append(r.masterHistory, phaseBlockMsg)
|
||||||
@ -459,7 +575,7 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
|
|
||||||
if len(assignments) > 0 && r.Mode != "build" {
|
if len(assignments) > 0 && r.Mode != "build" {
|
||||||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
||||||
Content: "当前是 Plan 模式,无法执行任务。请切换到 Build 模式后发送消息开始执行。"})
|
Content: r.Prompt.R("plan_mode_blocked")})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,21 +599,16 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
for memberName, result := range results {
|
for memberName, result := range results {
|
||||||
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||||||
}
|
}
|
||||||
feedbackMsg := "Team results:\n" + resultsStr.String()
|
feedbackMsg := r.Prompt.Render("feedback_results", map[string]string{
|
||||||
if boardCtx := board.ToContext(); boardCtx != "" {
|
"Results": resultsStr.String(),
|
||||||
feedbackMsg += "\n\nTeam board:\n" + boardCtx
|
"BoardContext": board.ToContext(),
|
||||||
}
|
"WorkspaceContext": r.buildWorkspaceContext(),
|
||||||
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
"WorkflowStep": r.buildWorkflowStep(),
|
||||||
feedbackMsg += "\n\n" + wsCtx
|
})
|
||||||
}
|
|
||||||
if stepCtx := r.buildWorkflowStep(); stepCtx != "" {
|
|
||||||
feedbackMsg = stepCtx + "\n\n" + feedbackMsg
|
|
||||||
}
|
|
||||||
feedbackMsg += "\n\n请审查成员结果,然后用 @成员名 分配下一步任务。不要在回复中输出完整文档正文,你负责的文件由系统自动发起调用。\n注意:简短回复,不要重复你上一条消息的内容。"
|
|
||||||
|
|
||||||
// 提醒更新 TodoList
|
// 提醒更新 TodoList
|
||||||
if r.hasTodoList() {
|
if r.hasTodoList() {
|
||||||
feedbackMsg += "\n\n<reminder>如果有任务完成,请用 <<<EDIT TodoList.md>>> 格式更新 TodoList,标记已完成的项目。</reminder>"
|
feedbackMsg += "\n\n" + r.Prompt.R("todo_reminder")
|
||||||
}
|
}
|
||||||
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||||||
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||||||
@ -542,7 +653,9 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
// 只有 file call(无成员任务):注入 continue 提示
|
// 只有 file call(无成员任务):注入 continue 提示
|
||||||
if len(pendingFiles) > 0 {
|
if len(pendingFiles) > 0 {
|
||||||
if stepCtx := r.buildWorkflowStep(); stepCtx != "" {
|
if stepCtx := r.buildWorkflowStep(); stepCtx != "" {
|
||||||
continueMsg := stepCtx + "\n\n文档已完成。请继续推进下一阶段。\n注意:简短回复,不要重复之前说过的内容。"
|
continueMsg := r.Prompt.Render("continue_next", map[string]string{
|
||||||
|
"WorkflowStep": stepCtx,
|
||||||
|
})
|
||||||
continueLLMMsg := llm.NewMsg("user", continueMsg)
|
continueLLMMsg := llm.NewMsg("user", continueMsg)
|
||||||
*masterMsgs = append(*masterMsgs, continueLLMMsg)
|
*masterMsgs = append(*masterMsgs, continueLLMMsg)
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
@ -575,19 +688,55 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
reply := masterReply.String()
|
reply := masterReply.String()
|
||||||
log.Printf("[room %s] master reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
log.Printf("[room %s] master reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
||||||
|
|
||||||
|
// Plan 模式下:只聊天,禁止一切后续操作
|
||||||
|
if r.Mode != "build" {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: reply, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
assistantMsg := llm.NewMsg("assistant", reply)
|
||||||
|
*masterMsgs = append(*masterMsgs, assistantMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, assistantMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
r.AppendHistory("master", r.master.Config.Name, reply)
|
||||||
|
|
||||||
|
// 检查是否尝试了分配或文档
|
||||||
|
allMentions := parseAssignments(reply)
|
||||||
|
hasAssignment := false
|
||||||
|
for name := range allMentions {
|
||||||
|
if _, isMember := r.members[name]; isMember {
|
||||||
|
hasAssignment = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasAssignment {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master",
|
||||||
|
Content: r.Prompt.R("plan_mode_blocked")})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var savedDocTitles []string
|
var savedDocTitles []string
|
||||||
persistContent := reply
|
persistContent := reply
|
||||||
|
|
||||||
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
// Build 模式下执行文件产出
|
||||||
persistContent = fmt.Sprintf("已更新《%s》", strings.TrimSuffix(editFile, ".md"))
|
{
|
||||||
savedDocTitles = append(savedDocTitles, strings.TrimSuffix(editFile, ".md"))
|
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
||||||
} else if docs := splitDocuments(reply); len(docs) > 0 {
|
persistContent = fmt.Sprintf("已更新《%s》", strings.TrimSuffix(editFile, ".md"))
|
||||||
for _, doc := range docs {
|
savedDocTitles = append(savedDocTitles, strings.TrimSuffix(editFile, ".md"))
|
||||||
title := extractTitle(doc)
|
} else if docs := splitDocuments(reply); len(docs) > 0 {
|
||||||
filename := titleToFilename(title, r.master.Config.Name)
|
for _, doc := range docs {
|
||||||
r.saveWorkspace(filename, doc)
|
title := extractTitle(doc)
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: filename, Title: title})
|
filename := titleToFilename(title, r.master.Config.Name)
|
||||||
savedDocTitles = append(savedDocTitles, title)
|
r.saveWorkspace(filename, doc)
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: filename, Title: title})
|
||||||
|
savedDocTitles = append(savedDocTitles, title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,7 +770,7 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
|
|
||||||
if len(assignments) == 0 {
|
if len(assignments) == 0 {
|
||||||
if len(savedDocTitles) > 0 && r.Mode == "build" {
|
if len(savedDocTitles) > 0 && r.Mode == "build" {
|
||||||
continueMsg := "文档已保存到 workspace。请根据工作流程,用 @成员名 分配下一步任务。不要重复输出文档内容。"
|
continueMsg := r.Prompt.R("continue_legacy")
|
||||||
continueLLMMsg := llm.NewMsg("user", continueMsg)
|
continueLLMMsg := llm.NewMsg("user", continueMsg)
|
||||||
*masterMsgs = append(*masterMsgs, continueLLMMsg)
|
*masterMsgs = append(*masterMsgs, continueLLMMsg)
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
@ -634,7 +783,7 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
|
|
||||||
if r.Mode != "build" {
|
if r.Mode != "build" {
|
||||||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
||||||
Content: "当前是 Plan 模式,无法执行任务。请切换到 Build 模式后发送消息开始执行。"})
|
Content: r.Prompt.R("plan_mode_blocked")})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -647,14 +796,17 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
for memberName, result := range results {
|
for memberName, result := range results {
|
||||||
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||||||
}
|
}
|
||||||
feedbackMsg := "Team results:\n" + resultsStr.String()
|
var wsFilesList string
|
||||||
if wsFiles := r.listWorkspaceFiles(); len(wsFiles) > 0 {
|
if wsFiles := r.listWorkspaceFiles(); len(wsFiles) > 0 {
|
||||||
feedbackMsg += "\n\n当前产出物文件:\n"
|
wsFilesList = "\n\n当前产出物文件:\n"
|
||||||
for _, f := range wsFiles {
|
for _, f := range wsFiles {
|
||||||
feedbackMsg += "- " + f + "\n"
|
wsFilesList += "- " + f + "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
feedbackMsg += "\n\n请审查结果并决定下一步行动。"
|
feedbackMsg := r.Prompt.Render("feedback_legacy", map[string]string{
|
||||||
|
"Results": resultsStr.String(),
|
||||||
|
"WorkspaceFiles": wsFilesList,
|
||||||
|
})
|
||||||
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||||||
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
@ -689,7 +841,7 @@ func (r *Room) handleMemberConversation(ctx context.Context, userName, userMsg s
|
|||||||
}
|
}
|
||||||
systemPrompt := member.BuildSystemPrompt(extraCtx)
|
systemPrompt := member.BuildSystemPrompt(extraCtx)
|
||||||
r.memberConvos[memberName] = []llm.Message{
|
r.memberConvos[memberName] = []llm.Message{
|
||||||
llm.NewMsg("system", systemPrompt+"\n\n你现在在与用户直接对话。请正常回复用户的问题,不要重复执行之前的任务。"),
|
llm.NewMsg("system", systemPrompt+"\n\n"+r.Prompt.R("member_conversation")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -737,6 +889,14 @@ func (r *Room) handleMemberConversation(ctx context.Context, userName, userMsg s
|
|||||||
|
|
||||||
// handleDirectAssign 处理用户直接 @agent 指派的任务
|
// handleDirectAssign 处理用户直接 @agent 指派的任务
|
||||||
func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]string) error {
|
func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]string) error {
|
||||||
|
// Plan 模式下阻止任务执行
|
||||||
|
if r.Mode != "build" {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master",
|
||||||
|
Content: r.Prompt.R("plan_mode_blocked")})
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
skillXML := skill.ToXML(r.skillMeta)
|
skillXML := skill.ToXML(r.skillMeta)
|
||||||
board := &SharedBoard{}
|
board := &SharedBoard{}
|
||||||
|
|
||||||
|
|||||||
@ -47,8 +47,20 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st
|
|||||||
llm.NewMsg("user", taskMsg),
|
llm.NewMsg("user", taskMsg),
|
||||||
}
|
}
|
||||||
|
|
||||||
// tool calling 循环
|
var finalReply string
|
||||||
finalReply := r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
if targetFile != nil {
|
||||||
|
// FILE CALL: 静默调用,不流式输出到聊天
|
||||||
|
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
|
r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: targetFile.Path, Title: docName})
|
||||||
|
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: "章节"})
|
||||||
|
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
||||||
|
} else {
|
||||||
|
// CHAT CALL: 保持现有流式输出逻辑
|
||||||
|
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
||||||
|
}
|
||||||
|
|
||||||
if r.memberConvos == nil {
|
if r.memberConvos == nil {
|
||||||
r.memberConvos = make(map[string][]llm.Message)
|
r.memberConvos = make(map[string][]llm.Message)
|
||||||
@ -118,14 +130,17 @@ func (r *Room) buildMemberContext(name, task string, board *SharedBoard, tools [
|
|||||||
}
|
}
|
||||||
if r.memberArtifacts != nil {
|
if r.memberArtifacts != nil {
|
||||||
if _, hasDoc := r.memberArtifacts[name]; hasDoc {
|
if _, hasDoc := r.memberArtifacts[name]; hasDoc {
|
||||||
taskMsg += "\n\n<important>你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。</important>"
|
taskMsg += "\n\n" + r.Prompt.R("member_update_doc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Caller-Decided: 预先确定目标文件
|
// Caller-Decided: 预先确定目标文件
|
||||||
targetFile = r.findMemberTargetFile(name)
|
targetFile = r.findMemberTargetFile(name)
|
||||||
if targetFile != nil {
|
if targetFile != nil {
|
||||||
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
taskMsg += fmt.Sprintf("\n\n<file_output target=\"%s\">\n你需要产出文件《%s》。工作完成后,最终回复只输出 Markdown 正文(以 # %s 开头),不要包含交流内容。\n</file_output>", targetFile.Path, docName, docName)
|
taskMsg += "\n\n" + r.Prompt.Render("file_call_member", map[string]string{
|
||||||
|
"FilePath": targetFile.Path,
|
||||||
|
"DocName": docName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -177,7 +192,7 @@ func (r *Room) runToolLoop(ctx context.Context, name string, member *agent.Agent
|
|||||||
|
|
||||||
// 强制无 tools 生成总结
|
// 强制无 tools 生成总结
|
||||||
if finalReply == "" {
|
if finalReply == "" {
|
||||||
*memberMsgs = append(*memberMsgs, llm.NewMsg("user", "请根据以上所有工具调用结果,直接输出完整的任务回复。不要再调用任何工具。"))
|
*memberMsgs = append(*memberMsgs, llm.NewMsg("user", r.Prompt.R("tool_loop_summarize")))
|
||||||
result, err := member.ChatWithTools(ctx, *memberMsgs, nil, nil)
|
result, err := member.ChatWithTools(ctx, *memberMsgs, nil, nil)
|
||||||
if err == nil && result.Content != "" {
|
if err == nil && result.Content != "" {
|
||||||
finalReply = result.Content
|
finalReply = result.Content
|
||||||
@ -202,6 +217,7 @@ func (r *Room) routeMemberOutput(ctx context.Context, name, task string, member
|
|||||||
r.saveWorkspace(targetFile.Path, content)
|
r.saveWorkspace(targetFile.Path, content)
|
||||||
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: targetFile.Path, Title: docName})
|
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: targetFile.Path, Title: docName})
|
||||||
|
r.emit(Event{Type: EvtFileDone, Agent: name, Filename: targetFile.Path, Title: docName})
|
||||||
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||||||
if r.memberArtifacts == nil {
|
if r.memberArtifacts == nil {
|
||||||
r.memberArtifacts = make(map[string]string)
|
r.memberArtifacts = make(map[string]string)
|
||||||
@ -379,7 +395,7 @@ func (r *Room) runChallengeRound(ctx context.Context, board *SharedBoard, skillX
|
|||||||
}
|
}
|
||||||
memberSystem := member.BuildSystemPrompt(extraCtx)
|
memberSystem := member.BuildSystemPrompt(extraCtx)
|
||||||
memberMsgs := []llm.Message{
|
memberMsgs := []llm.Message{
|
||||||
llm.NewMsg("system", memberSystem+"\n\n审阅 workspace 中的文档内容(而非看板摘要)。如果发现问题或需要质疑,请输出 CHALLENGE:你的具体意见。如果没有问题,输出 AGREE。注意:只评审你职责范围内的内容。禁止使用@提及任何人,禁止建议分配任务。"),
|
llm.NewMsg("system", memberSystem+"\n\n"+r.Prompt.R("challenge_review")),
|
||||||
llm.NewMsg("user", "请审阅 workspace 中的文档并给出你的专业反馈。"),
|
llm.NewMsg("user", "请审阅 workspace 中的文档并给出你的专业反馈。"),
|
||||||
}
|
}
|
||||||
var reply strings.Builder
|
var reply strings.Builder
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/sdaduanbilei/agent-team/internal/agent"
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/llm"
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/skill"
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/store"
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/user"
|
"github.com/sdaduanbilei/agent-team/internal/user"
|
||||||
@ -64,6 +65,8 @@ type Room struct {
|
|||||||
systemRules string // SYSTEM.md 全局规则
|
systemRules string // SYSTEM.md 全局规则
|
||||||
|
|
||||||
projectTemplate *ProjectTemplate // 项目模板(可为 nil)
|
projectTemplate *ProjectTemplate // 项目模板(可为 nil)
|
||||||
|
teamWorkflow string // TEAM.md 中的流程描述(非 project-template 部分)
|
||||||
|
Prompt *prompt.Engine // 提示词模板引擎
|
||||||
|
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
currentGroupID int64 // 当前用户消息的 group_id
|
currentGroupID int64 // 当前用户消息的 group_id
|
||||||
@ -87,6 +90,8 @@ const (
|
|||||||
EvtScheduleRun EventType = "schedule_run"
|
EvtScheduleRun EventType = "schedule_run"
|
||||||
EvtTokenUsage EventType = "token_usage"
|
EvtTokenUsage EventType = "token_usage"
|
||||||
EvtFileRead EventType = "file_read"
|
EvtFileRead EventType = "file_read"
|
||||||
|
EvtFileWorking EventType = "file_working" // file-llm 开始生成文件
|
||||||
|
EvtFileDone EventType = "file_done" // file-llm 文件生成完成
|
||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
@ -126,6 +131,17 @@ type ProjectTemplate struct {
|
|||||||
Files []ProjectFile
|
Files []ProjectFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractTeamWorkflow 提取 TEAM.md 中除 project-template 代码块外的流程描述
|
||||||
|
func extractTeamWorkflow(body string) string {
|
||||||
|
re := regexp.MustCompile("(?s)```project-template\\s*\\n.+?```")
|
||||||
|
cleaned := re.ReplaceAllString(body, "")
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
if cleaned == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
// parseProjectTemplate 从 TEAM.md body 中提取 project-template 代码块并解析
|
// parseProjectTemplate 从 TEAM.md body 中提取 project-template 代码块并解析
|
||||||
func parseProjectTemplate(body string) *ProjectTemplate {
|
func parseProjectTemplate(body string) *ProjectTemplate {
|
||||||
re := regexp.MustCompile("(?s)```project-template\\s*\\n(.+?)```")
|
re := regexp.MustCompile("(?s)```project-template\\s*\\n(.+?)```")
|
||||||
@ -181,6 +197,20 @@ func parseProjectTemplate(body string) *ProjectTemplate {
|
|||||||
return &ProjectTemplate{Files: files}
|
return &ProjectTemplate{Files: files}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadOption 用于 room.Load 的可选参数
|
||||||
|
type LoadOption func(*loadOptions)
|
||||||
|
|
||||||
|
type loadOptions struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStore 在加载时传入 store,用于从 DB 读取 agent 配置
|
||||||
|
func WithStore(s *store.Store) LoadOption {
|
||||||
|
return func(o *loadOptions) {
|
||||||
|
o.store = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type BoardEntry struct {
|
type BoardEntry struct {
|
||||||
Author string
|
Author string
|
||||||
Content string
|
Content string
|
||||||
|
|||||||
@ -97,8 +97,14 @@ func (r *Room) buildWorkflowStep() string {
|
|||||||
sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch))
|
sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString("系统将自动发起章节文档调用,你不需要在回复中输出章节正文。\n")
|
isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name
|
||||||
sb.WriteString("请简短说明接下来要写哪一章,系统会自动发起文档调用。\n")
|
if isMasterDyn {
|
||||||
|
sb.WriteString("系统将自动发起章节文档调用,你不需要在回复中输出章节正文。\n")
|
||||||
|
sb.WriteString("请简短说明接下来要写哪一章,系统会自动发起文档调用。\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("请用 @%s 分配章节写作任务,任务中注明章节序号和标题。\n", dynOwner))
|
||||||
|
sb.WriteString("每次分配一章,写完后你会收到完成通知,再安排下一章。\n")
|
||||||
|
}
|
||||||
sb.WriteString("</next_action>\n")
|
sb.WriteString("</next_action>\n")
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("所有模板文件已完成。请根据你的 AGENT.md 工作流程决定下一步行动(如向用户交付、或进入下一阶段)。")
|
sb.WriteString("所有模板文件已完成。请根据你的 AGENT.md 工作流程决定下一步行动(如向用户交付、或进入下一阶段)。")
|
||||||
@ -227,6 +233,7 @@ func (r *Room) listChapterFiles(dir string) []string {
|
|||||||
// masterChapterFileCall 为 master 发起一次章节文档调用
|
// masterChapterFileCall 为 master 发起一次章节文档调用
|
||||||
func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) {
|
func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) {
|
||||||
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...")
|
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...")
|
||||||
|
r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: dir + "/", Title: "章节"})
|
||||||
|
|
||||||
existingChapters := r.listChapterFiles(dir)
|
existingChapters := r.listChapterFiles(dir)
|
||||||
var existingList string
|
var existingList string
|
||||||
@ -237,14 +244,10 @@ func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filePrompt := fmt.Sprintf(
|
filePrompt := r.Prompt.Render("file_call_chapter", map[string]string{
|
||||||
"[系统·章节文档调用] 请产出下一章的完整 Markdown 正文。\n"+
|
"Dir": dir,
|
||||||
"要求:\n"+
|
"ExistingChapters": existingList,
|
||||||
"- 以 # 第X章 章节标题 开头\n"+
|
})
|
||||||
"- 只输出章节正文,不要夹杂任何交流内容\n"+
|
|
||||||
"- 不要说\"以下是\"\"下面是\"等引导语,直接输出正文\n"+
|
|
||||||
"- 系统会自动保存到 workspace/%s/ 目录下%s",
|
|
||||||
dir, existingList)
|
|
||||||
|
|
||||||
if chapterHint != "" {
|
if chapterHint != "" {
|
||||||
filePrompt += "\n\n你之前的规划:" + chapterHint
|
filePrompt += "\n\n你之前的规划:" + chapterHint
|
||||||
@ -286,6 +289,8 @@ func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: fullPath, Title: docName})
|
||||||
|
|
||||||
// 发送完成状态到聊天
|
// 发送完成状态到聊天
|
||||||
statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, fullPath)
|
statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, fullPath)
|
||||||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
|
||||||
@ -317,17 +322,19 @@ func (r *Room) extractChapterFilename(content, dir string) string {
|
|||||||
title = strings.ReplaceAll(title, " ", "-")
|
title = strings.ReplaceAll(title, " ", "-")
|
||||||
title = strings.ReplaceAll(title, "/", "-")
|
title = strings.ReplaceAll(title, "/", "-")
|
||||||
title = strings.ReplaceAll(title, "\\", "-")
|
title = strings.ReplaceAll(title, "\\", "-")
|
||||||
if !strings.HasSuffix(title, ".md") {
|
// 先去除可能已有的 .md 后缀,再统一添加,避免双重扩展名
|
||||||
title += ".md"
|
title = strings.TrimSuffix(title, ".md")
|
||||||
}
|
title += ".md"
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildProjectContext 构建项目模板上下文,注入到成员 system prompt
|
// buildProjectContext 构建项目模板上下文,注入到 agent system prompt
|
||||||
func (r *Room) buildProjectContext(agentName string) string {
|
func (r *Room) buildProjectContext(agentName string) string {
|
||||||
if r.projectTemplate == nil {
|
if r.projectTemplate == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
isMaster := r.master != nil && agentName == r.master.Config.Name
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("<project_template>\n")
|
sb.WriteString("<project_template>\n")
|
||||||
sb.WriteString("项目文件结构(系统自动管理文件保存):\n\n")
|
sb.WriteString("项目文件结构(系统自动管理文件保存):\n\n")
|
||||||
@ -346,8 +353,15 @@ func (r *Room) buildProjectContext(agentName string) string {
|
|||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" %s @%s phase:%d%s\n", f.Path, f.Owner, f.Phase, marker))
|
sb.WriteString(fmt.Sprintf(" %s @%s phase:%d%s\n", f.Path, f.Owner, f.Phase, marker))
|
||||||
}
|
}
|
||||||
sb.WriteString("\n输出规范:工作完成后,最终回复只输出文件的 Markdown 正文内容(以 # 标题 开头)。\n")
|
|
||||||
sb.WriteString("系统会自动保存到对应文件。不要在文档中夹杂状态描述或对话内容。\n")
|
if isMaster {
|
||||||
|
// master:文档通过系统 file call 产出,聊天中只做规划和分配
|
||||||
|
sb.WriteString("\n" + r.Prompt.R("master_output_spec") + "\n")
|
||||||
|
} else {
|
||||||
|
// 成员:最终回复输出文档正文
|
||||||
|
sb.WriteString("\n" + r.Prompt.R("output_spec") + "\n")
|
||||||
|
sb.WriteString("系统会自动保存到对应文件。不要在文档中夹杂状态描述或对话内容。\n")
|
||||||
|
}
|
||||||
sb.WriteString("</project_template>")
|
sb.WriteString("</project_template>")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@ -448,14 +462,17 @@ func (r *Room) findPendingMasterFiles() []ProjectFile {
|
|||||||
func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile) {
|
func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile) {
|
||||||
docName := strings.TrimSuffix(file.Path, ".md")
|
docName := strings.TrimSuffix(file.Path, ".md")
|
||||||
r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在编写《%s》...", docName))
|
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})
|
||||||
|
|
||||||
filePrompt := fmt.Sprintf(
|
filePrompt := r.Prompt.Render("file_call_master", map[string]string{
|
||||||
"[系统·文档调用] 现在请产出文件《%s》的完整 Markdown 正文。\n要求:\n"+
|
"DocName": docName,
|
||||||
"- 以 # %s 开头\n"+
|
"FilePath": file.Path,
|
||||||
"- 只输出文档正文,不要夹杂任何交流内容\n"+
|
})
|
||||||
"- 不要说\"以下是\"\"下面是\"等引导语,直接输出文档\n"+
|
|
||||||
"- 系统会自动保存到 workspace/%s",
|
// TodoList 特殊格式要求
|
||||||
docName, docName, file.Path)
|
if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") {
|
||||||
|
filePrompt += "\n\n格式要求:每个任务项必须包含负责人,格式为 `- [ ] 任务描述 (@负责人)`。已完成的用 `- [x]`。"
|
||||||
|
}
|
||||||
|
|
||||||
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
filePrompt += "\n\n" + wsCtx
|
filePrompt += "\n\n" + wsCtx
|
||||||
@ -476,6 +493,11 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi
|
|||||||
content = "# " + docName + "\n\n" + content
|
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.saveWorkspace(file.Path, content)
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
||||||
|
|
||||||
@ -487,6 +509,8 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
||||||
|
|
||||||
assistantMsg := llm.NewMsg("assistant", reply)
|
assistantMsg := llm.NewMsg("assistant", reply)
|
||||||
*masterMsgs = append(*masterMsgs, assistantMsg)
|
*masterMsgs = append(*masterMsgs, assistantMsg)
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
|
|||||||
@ -75,7 +75,9 @@ func (r *Room) buildWorkspaceContext() string {
|
|||||||
if sb.Len() == 0 {
|
if sb.Len() == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "<workspace_files>\n以下是团队已产出的文档,可供参考和评审:" + sb.String() + "</workspace_files>"
|
return r.Prompt.Render("workspace_header", map[string]string{
|
||||||
|
"FileList": sb.String(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasTodoList 检查 workspace 中是否存在 TodoList.md
|
// hasTodoList 检查 workspace 中是否存在 TodoList.md
|
||||||
@ -145,6 +147,29 @@ 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)
|
||||||
|
if r.memberArtifacts == nil {
|
||||||
|
r.memberArtifacts = make(map[string]string)
|
||||||
|
}
|
||||||
|
r.memberArtifacts[name] = fullPath
|
||||||
|
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: EvtTaskDone, Agent: name, Task: task})
|
||||||
|
return fullPath, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 降级:原有 isDocument 逻辑
|
// 降级:原有 isDocument 逻辑
|
||||||
if isDocument(finalReply) {
|
if isDocument(finalReply) {
|
||||||
title := extractTitle(finalReply)
|
title := extractTitle(finalReply)
|
||||||
@ -171,6 +196,35 @@ func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripTrailingAssignments 去除文档末尾混入的 @分配 指令段落
|
||||||
|
// 用于 TodoList 等文档文件,防止 master 将工作流指令写入文件正文
|
||||||
|
func stripTrailingAssignments(content string) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
cutAt := -1
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
// 分隔线 "---" 后面跟的是工作流指令,截断
|
||||||
|
if trimmed == "---" {
|
||||||
|
// 检查后续是否有 @ 分配或非任务内容
|
||||||
|
rest := strings.Join(lines[i+1:], "\n")
|
||||||
|
if strings.Contains(rest, "@") || strings.Contains(rest, "Phase") {
|
||||||
|
cutAt = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 独立的 @成员名 开头的行(非任务列表项)
|
||||||
|
if strings.HasPrefix(trimmed, "@") && !strings.HasPrefix(trimmed, "@主编") {
|
||||||
|
// 检查是否在列表项中(任务列表有 - [ ] 前缀)
|
||||||
|
cutAt = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cutAt >= 0 {
|
||||||
|
return strings.TrimRight(strings.Join(lines[:cutAt], "\n"), "\n ")
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
// splitDocuments 从文本中拆分出独立文档段落(旧路径兼容)。
|
// splitDocuments 从文本中拆分出独立文档段落(旧路径兼容)。
|
||||||
func splitDocuments(text string) []string {
|
func splitDocuments(text string) []string {
|
||||||
parts := strings.Split("\n"+text, "\n# ")
|
parts := strings.Split("\n"+text, "\n# ")
|
||||||
@ -232,7 +286,28 @@ func splitContentAndStatus(reply, filename string) (fileContent, statusMsg strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyDocumentEdit 解析并应用文档编辑指令
|
// applyDocumentEdit 解析并应用文档编辑指令
|
||||||
|
// 支持两种格式:
|
||||||
|
// 1. 全文替换:<<<REPLACE filename>>>\n新内容\n<<<END>>>
|
||||||
|
// 2. 局部替换:<<<EDIT filename>>><<<FIND>>>旧内容<<<REPLACE>>>新内容<<<END>>>
|
||||||
func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) {
|
func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) {
|
||||||
|
// 格式1: 全文替换(优先处理)
|
||||||
|
replaceRe := regexp.MustCompile(`(?s)<<<REPLACE\s+(.+?)>>>\s*\n(.*?)\n?<<<END>>>`)
|
||||||
|
replaceMatches := replaceRe.FindAllStringSubmatch(content, -1)
|
||||||
|
for _, m := range replaceMatches {
|
||||||
|
fname := strings.TrimSpace(m[1])
|
||||||
|
newContent := strings.TrimSpace(m[2])
|
||||||
|
if newContent == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.saveWorkspace(fname, newContent)
|
||||||
|
filename = fname
|
||||||
|
applied = true
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式2: 局部替换
|
||||||
editRe := regexp.MustCompile(`(?s)<<<EDIT\s+(.+?)>>>\s*<<<FIND>>>\s*(.+?)\s*<<<REPLACE>>>\s*(.+?)\s*<<<END>>>`)
|
editRe := regexp.MustCompile(`(?s)<<<EDIT\s+(.+?)>>>\s*<<<FIND>>>\s*(.+?)\s*<<<REPLACE>>>\s*(.+?)\s*<<<END>>>`)
|
||||||
matches := editRe.FindAllStringSubmatch(content, -1)
|
matches := editRe.FindAllStringSubmatch(content, -1)
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
|
|||||||
58
internal/store/agent_config.go
Normal file
58
internal/store/agent_config.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
// AgentConfig 存储 room 维度的 agent 配置(SOUL.md / AGENT.md / TEAM.md)
|
||||||
|
type AgentConfig struct {
|
||||||
|
RoomID string
|
||||||
|
AgentName string // agent 名称,TEAM.md 用 "__team__"
|
||||||
|
FileType string // "soul" | "agent" | "team_doc" | "memory"
|
||||||
|
Content string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentConfigTeamDoc = "__team__"
|
||||||
|
|
||||||
|
func (s *Store) GetAgentConfig(roomID, agentName, fileType string) (string, error) {
|
||||||
|
var content string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT content FROM agent_configs WHERE room_id = ? AND agent_name = ? AND file_type = ?`,
|
||||||
|
roomID, agentName, fileType,
|
||||||
|
).Scan(&content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetAgentConfig(roomID, agentName, fileType, content string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO agent_configs (room_id, agent_name, file_type, content)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(room_id, agent_name, file_type)
|
||||||
|
DO UPDATE SET content = excluded.content, updated_at = datetime('now')`,
|
||||||
|
roomID, agentName, fileType, content,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoomAgentConfigs 获取某个 room 下所有 agent 配置
|
||||||
|
func (s *Store) GetRoomAgentConfigs(roomID string) ([]AgentConfig, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT room_id, agent_name, file_type, content, updated_at FROM agent_configs WHERE room_id = ?`,
|
||||||
|
roomID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var configs []AgentConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var c AgentConfig
|
||||||
|
if err := rows.Scan(&c.RoomID, &c.AgentName, &c.FileType, &c.Content, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
configs = append(configs, c)
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
@ -79,6 +79,16 @@ func (s *Store) migrate() error {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fv_room_file ON file_versions(room_id, filename);
|
CREATE INDEX IF NOT EXISTS idx_fv_room_file ON file_versions(room_id, filename);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_configs (
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
agent_name TEXT NOT NULL,
|
||||||
|
file_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (room_id, agent_name, file_type)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ac_room ON agent_configs(room_id);
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
24ae1a32-f7da-4b71-9a0b-1b0ef980f6bf
|
1ee6643f-5997-464c-b8b1-95d34fe1f0a5
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
|||||||
46822
|
94443
|
||||||
|
|||||||
@ -4,6 +4,8 @@ author: Agent Team
|
|||||||
repo_url: https://gitea.catter.cn/agent-teams/writing-team
|
repo_url: https://gitea.catter.cn/agent-teams/writing-team
|
||||||
agents:
|
agents:
|
||||||
- 主编
|
- 主编
|
||||||
|
- 写手
|
||||||
|
- 读者
|
||||||
- 策划编辑
|
- 策划编辑
|
||||||
- 合规审查员
|
- 合规审查员
|
||||||
- 搜索员
|
- 搜索员
|
||||||
@ -11,5 +13,5 @@ agents:
|
|||||||
skills:
|
skills:
|
||||||
- web-search
|
- web-search
|
||||||
|
|
||||||
installed_at: 2026-03-07
|
installed_at: 2026-03-09
|
||||||
updated_at:
|
updated_at:
|
||||||
|
|||||||
@ -33,7 +33,7 @@ function formatTime(ts: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatView() {
|
export function ChatView() {
|
||||||
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems } = useStore()
|
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles } = useStore()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
||||||
const [mentionIndex, setMentionIndex] = useState(0)
|
const [mentionIndex, setMentionIndex] = useState(0)
|
||||||
@ -188,8 +188,12 @@ export function ChatView() {
|
|||||||
|
|
||||||
{/* Agent status bar + Input area */}
|
{/* Agent status bar + Input area */}
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
{/* 成员执行状态条 */}
|
{/* 状态条 */}
|
||||||
<AgentStatusBar todos={activeRoomId ? (todoItems[activeRoomId] || []) : []} room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined} />
|
<AgentStatusBar
|
||||||
|
todos={activeRoomId ? (todoItems[activeRoomId] || []) : []}
|
||||||
|
room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined}
|
||||||
|
workingFiles={activeRoomId ? (workingFiles[activeRoomId] || []) : []}
|
||||||
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* @ Mention dropdown */}
|
{/* @ Mention dropdown */}
|
||||||
{mentionQuery !== null && filteredMentions.length > 0 && (
|
{mentionQuery !== null && filteredMentions.length > 0 && (
|
||||||
@ -408,6 +412,21 @@ function highlightMentions(content: string): string {
|
|||||||
return content.replace(/@([\w\u4e00-\u9fff]+)/g, '`@$1`')
|
return content.replace(/@([\w\u4e00-\u9fff]+)/g, '`@$1`')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修复流式输出中未闭合的 markdown 标记 */
|
||||||
|
function fixStreamingMarkdown(text: string): string {
|
||||||
|
// 修复未闭合的粗体 **
|
||||||
|
const boldCount = (text.match(/\*\*/g) || []).length
|
||||||
|
if (boldCount % 2 !== 0) text += '**'
|
||||||
|
// 修复未闭合的斜体 *(排除已闭合的 **)
|
||||||
|
const cleanedForItalic = text.replace(/\*\*/g, '')
|
||||||
|
const italicCount = (cleanedForItalic.match(/\*/g) || []).length
|
||||||
|
if (italicCount % 2 !== 0) text += '*'
|
||||||
|
// 修复未闭合的行内代码 `
|
||||||
|
const backtickCount = (text.match(/`/g) || []).length
|
||||||
|
if (backtickCount % 2 !== 0) text += '`'
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
/** 移除 master 消息中的文档内容(已作为 artifact 展示) */
|
/** 移除 master 消息中的文档内容(已作为 artifact 展示) */
|
||||||
function stripDocumentContent(content: string): string {
|
function stripDocumentContent(content: string): string {
|
||||||
const idx = content.indexOf('\n# ')
|
const idx = content.indexOf('\n# ')
|
||||||
@ -452,16 +471,32 @@ function MarqueeText({ text, className }: { text: string; className?: string })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentStatusBar({ todos, room }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string } }) {
|
function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[] }) {
|
||||||
// 只在 master 本人是活跃 agent 或正在思考时显示 master 状态
|
// 只在 master 本人是活跃 agent 或正在思考时显示 master 状态
|
||||||
const showMaster = room && (
|
const showMaster = room && (
|
||||||
room.status === 'thinking' ||
|
room.status === 'thinking' ||
|
||||||
(room.status === 'working' && (!room.activeAgent || room.activeAgent === room.master))
|
(room.status === 'working' && (!room.activeAgent || room.activeAgent === room.master))
|
||||||
)
|
)
|
||||||
if (todos.length === 0 && !showMaster) return null
|
if (todos.length === 0 && !showMaster && workingFiles.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mb-2 px-1 py-1.5 overflow-hidden">
|
<div className="flex items-center gap-2 mb-2 px-1 py-1.5 overflow-hidden flex-wrap">
|
||||||
|
{/* 文件生成状态 */}
|
||||||
|
{workingFiles.map(f => (
|
||||||
|
<div
|
||||||
|
key={`file-${f.agent}-${f.filename}`}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-purple-500/10 text-purple-400 ring-1 ring-purple-500/30"
|
||||||
|
>
|
||||||
|
<span className="relative flex w-2.5 h-2.5 shrink-0">
|
||||||
|
<span className="absolute inset-0 rounded-full bg-purple-500 animate-ping opacity-75" />
|
||||||
|
<span className="relative rounded-full w-2.5 h-2.5 bg-purple-500" />
|
||||||
|
</span>
|
||||||
|
<FileText className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="font-semibold shrink-0">{f.agent}</span>
|
||||||
|
<span className="opacity-70">正在生成</span>
|
||||||
|
<span className="font-semibold">{f.title || f.filename}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{showMaster && room && (
|
{showMaster && room && (
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap bg-[var(--color-warning)]/10 text-[var(--color-warning)] ring-1 ring-[var(--color-warning)]/30 min-w-0 max-w-[50%] shrink">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap bg-[var(--color-warning)]/10 text-[var(--color-warning)] ring-1 ring-[var(--color-warning)]/30 min-w-0 max-w-[50%] shrink">
|
||||||
<span className="relative flex w-2.5 h-2.5 shrink-0">
|
<span className="relative flex w-2.5 h-2.5 shrink-0">
|
||||||
@ -745,7 +780,7 @@ function MessageBubble({ msg, userName }: { msg: Message; userName?: string }) {
|
|||||||
: 'dark:prose-invert [&_code]:bg-[var(--accent)]/15 [&_code]:text-[var(--accent)]'
|
: 'dark:prose-invert [&_code]:bg-[var(--accent)]/15 [&_code]:text-[var(--accent)]'
|
||||||
}`}>
|
}`}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
{highlightMentions(msg.content)}
|
{msg.streaming ? fixStreamingMarkdown(highlightMentions(msg.content)) : highlightMentions(msg.content)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
{msg.streaming && (
|
{msg.streaming && (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Crown, Users, ShoppingCart, X, Loader2, Save } from 'lucide-react'
|
import { Crown, Users, ShoppingCart, X, Loader2, Save, FileText } from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { MarkdownEditor } from './MarkdownEditor'
|
import { MarkdownEditor } from './MarkdownEditor'
|
||||||
|
|
||||||
@ -94,31 +94,28 @@ function SelectTeamModal({ roomId, onClose }: { roomId: string; onClose: () => v
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentEditModal({ team, agentName, onClose }: { team: string; agentName: string; onClose: () => void }) {
|
function AgentEditModal({ roomId, agentName, onClose }: { roomId: string; agentName: string; onClose: () => void }) {
|
||||||
const [tab, setTab] = useState<'soul' | 'agent' | 'memory'>('soul')
|
const [tab, setTab] = useState<'soul' | 'agent'>('soul')
|
||||||
const [soul, setSoul] = useState('')
|
const [soul, setSoul] = useState('')
|
||||||
const [agent, setAgent] = useState('')
|
const [agent, setAgent] = useState('')
|
||||||
const [memory, setMemory] = useState('')
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const base = `${API}/teams/${team}/agents/${agentName}/files`
|
const base = `${API}/rooms/${roomId}/agents/${agentName}/files`
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${base}/SOUL.md`).then(r => r.ok ? r.json() : { content: '' }).then(d => d.content || '').catch(() => ''),
|
fetch(`${base}/soul`).then(r => r.ok ? r.json() : { content: '' }).then(d => d.content || '').catch(() => ''),
|
||||||
fetch(`${base}/AGENT.md`).then(r => r.ok ? r.json() : { content: '' }).then(d => d.content || '').catch(() => ''),
|
fetch(`${base}/agent`).then(r => r.ok ? r.json() : { content: '' }).then(d => d.content || '').catch(() => ''),
|
||||||
fetch(`${base}/memory/${currentMonth}.md`).then(r => r.ok ? r.json() : { content: '' }).then(d => d.content || '').catch(() => ''),
|
]).then(([s, a]) => { setSoul(s); setAgent(a); setLoading(false) })
|
||||||
]).then(([s, a, m]) => { setSoul(s); setAgent(a); setMemory(m); setLoading(false) })
|
}, [roomId, agentName])
|
||||||
}, [team, agentName])
|
|
||||||
|
|
||||||
const fileMap = { soul: { path: 'SOUL.md', value: soul, set: setSoul }, agent: { path: 'AGENT.md', value: agent, set: setAgent }, memory: { path: `memory/${currentMonth}.md`, value: memory, set: setMemory } }
|
const fileMap = { soul: { type: 'soul', value: soul, set: setSoul }, agent: { type: 'agent', value: agent, set: setAgent } }
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const { path, value } = fileMap[tab]
|
const { type, value } = fileMap[tab]
|
||||||
await fetch(`${API}/teams/${team}/agents/${agentName}/files/${path}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: value }) })
|
await fetch(`${API}/rooms/${roomId}/agents/${agentName}/files/${type}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: value }) })
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@ -133,10 +130,10 @@ function AgentEditModal({ team, agentName, onClose }: { team: string; agentName:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 px-5 pt-3">
|
<div className="flex gap-1 px-5 pt-3">
|
||||||
{(['soul', 'agent', 'memory'] as const).map(t => (
|
{(['soul', 'agent'] as const).map(t => (
|
||||||
<button key={t} onClick={() => setTab(t)}
|
<button key={t} onClick={() => setTab(t)}
|
||||||
className={`px-4 py-1.5 rounded text-sm transition-colors ${tab === t ? 'bg-[var(--accent)] text-white' : 'hover:bg-[var(--bg-hover)] text-[var(--text-secondary)]'}`}>
|
className={`px-4 py-1.5 rounded text-sm transition-colors ${tab === t ? 'bg-[var(--accent)] text-white' : 'hover:bg-[var(--bg-hover)] text-[var(--text-secondary)]'}`}>
|
||||||
{t === 'soul' ? 'Soul' : t === 'agent' ? 'Agent' : 'Memory'}
|
{t === 'soul' ? '人设' : '工作流'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -165,10 +162,66 @@ function AgentEditModal({ team, agentName, onClose }: { team: string; agentName:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TeamDocModal({ roomId, onClose }: { roomId: string; onClose: () => void }) {
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API}/rooms/${roomId}/team-doc`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { setContent(d.content || ''); setLoading(false) })
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [roomId])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
await fetch(`${API}/rooms/${roomId}/team-doc`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content })
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl w-full max-w-2xl shadow-2xl flex flex-col" style={{ maxHeight: '80vh' }}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-[var(--accent)]" />
|
||||||
|
<span className="font-semibold">团队文档</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden p-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownEditor value={content} onChange={setContent} height={400} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 pb-4 flex justify-end">
|
||||||
|
<button onClick={save} disabled={saving || loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MemberList() {
|
export function MemberList() {
|
||||||
const { activeRoomId, rooms } = useStore()
|
const { activeRoomId, rooms } = useStore()
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingAgent, setEditingAgent] = useState<string | null>(null)
|
const [editingAgent, setEditingAgent] = useState<string | null>(null)
|
||||||
|
const [showTeamDoc, setShowTeamDoc] = useState(false)
|
||||||
|
|
||||||
const room = rooms.find(r => r.id === activeRoomId)
|
const room = rooms.find(r => r.id === activeRoomId)
|
||||||
|
|
||||||
@ -222,17 +275,27 @@ export function MemberList() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showModal && <SelectTeamModal roomId={room.id} onClose={() => setShowModal(false)} />}
|
{showModal && <SelectTeamModal roomId={room.id} onClose={() => setShowModal(false)} />}
|
||||||
{editingAgent && <AgentEditModal team={room.team} agentName={editingAgent} onClose={() => setEditingAgent(null)} />}
|
{editingAgent && <AgentEditModal roomId={room.id} agentName={editingAgent} onClose={() => setEditingAgent(null)} />}
|
||||||
|
{showTeamDoc && <TeamDocModal roomId={room.id} onClose={() => setShowTeamDoc(false)} />}
|
||||||
<div className="w-[200px] bg-[var(--channel-list-bg)] border-r border-[var(--border)] flex flex-col overflow-hidden">
|
<div className="w-[200px] bg-[var(--channel-list-bg)] border-r border-[var(--border)] flex flex-col overflow-hidden">
|
||||||
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center justify-between">
|
<div className="h-12 px-4 border-b border-[var(--border)] flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-[var(--text-muted)]">成员 — {1 + memberAgents.length}</span>
|
<span className="text-sm font-semibold text-[var(--text-muted)]">成员 — {1 + memberAgents.length}</span>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => setShowModal(true)}
|
<button
|
||||||
title="切换团队"
|
onClick={() => setShowTeamDoc(true)}
|
||||||
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
title="团队文档"
|
||||||
>
|
className="text-[var(--text-muted)] hover:text-[var(--accent)] transition-colors p-0.5"
|
||||||
{room.team}
|
>
|
||||||
</button>
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
title="切换团队"
|
||||||
|
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{room.team}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-2 scrollbar-thin">
|
<div className="flex-1 overflow-y-auto py-2 scrollbar-thin">
|
||||||
|
|||||||
@ -26,6 +26,7 @@ interface AppState {
|
|||||||
tokenUsage: Record<string, TokenUsage>
|
tokenUsage: Record<string, TokenUsage>
|
||||||
todoItems: Record<string, TodoItem[]>
|
todoItems: Record<string, TodoItem[]>
|
||||||
fileReaders: Record<string, Record<string, string[]>>
|
fileReaders: Record<string, Record<string, string[]>>
|
||||||
|
workingFiles: Record<string, { agent: string; filename: string; title?: string }[]>
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark') => void
|
setTheme: (theme: 'light' | 'dark') => void
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
@ -81,6 +82,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
tokenUsage: {},
|
tokenUsage: {},
|
||||||
todoItems: {},
|
todoItems: {},
|
||||||
fileReaders: {},
|
fileReaders: {},
|
||||||
|
workingFiles: {},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
applyTheme(theme)
|
applyTheme(theme)
|
||||||
@ -270,6 +272,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
: r),
|
: r),
|
||||||
messages: { ...s.messages, [roomId]: cleanMsgs },
|
messages: { ...s.messages, [roomId]: cleanMsgs },
|
||||||
todoItems: { ...s.todoItems, [roomId]: todos },
|
todoItems: { ...s.todoItems, [roomId]: todos },
|
||||||
|
workingFiles: { ...s.workingFiles, [roomId]: [] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -379,6 +382,22 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
workspace: { ...s.workspace, [roomId]: files },
|
workspace: { ...s.workspace, [roomId]: files },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (ev.type === 'file_working') {
|
||||||
|
set(s => {
|
||||||
|
const files = [...(s.workingFiles[roomId] || [])]
|
||||||
|
// 避免重复
|
||||||
|
if (!files.some(f => f.agent === ev.agent && f.filename === ev.filename)) {
|
||||||
|
files.push({ agent: ev.agent, filename: ev.filename, title: ev.title })
|
||||||
|
}
|
||||||
|
return { workingFiles: { ...s.workingFiles, [roomId]: files } }
|
||||||
|
})
|
||||||
|
} else if (ev.type === 'file_done') {
|
||||||
|
set(s => {
|
||||||
|
const files = (s.workingFiles[roomId] || []).filter(
|
||||||
|
f => !(f.agent === ev.agent && f.filename === ev.filename)
|
||||||
|
)
|
||||||
|
return { workingFiles: { ...s.workingFiles, [roomId]: files } }
|
||||||
|
})
|
||||||
} else if (ev.type === 'file_read') {
|
} else if (ev.type === 'file_read') {
|
||||||
set(s => {
|
set(s => {
|
||||||
const roomReaders = { ...(s.fileReaders[roomId] || {}) }
|
const roomReaders = { ...(s.fileReaders[roomId] || {}) }
|
||||||
|
|||||||
@ -59,3 +59,5 @@ export type WsEvent =
|
|||||||
| { type: 'schedule_run'; agent: string; content: string }
|
| { type: 'schedule_run'; agent: string; content: string }
|
||||||
| { type: 'token_usage'; agent: string; prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
| { type: 'token_usage'; agent: string; prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||||
| { type: 'file_read'; agent: string; filename: string }
|
| { type: 'file_read'; agent: string; filename: string }
|
||||||
|
| { type: 'file_working'; agent: string; filename: string; title?: string }
|
||||||
|
| { type: 'file_done'; agent: string; filename: string; title?: string }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user