Commit 7a5fb5f349c417a0e23d2cff085549a1b55e105a

Authored by yibo
1 parent b8f5090f

feat(agent): implement autonomous observer UI, namespaced agent routing, and Mas…

…ter Agent cognitive auto-recovery loop

- Refactored CompanyDashboard into read-only Observer UI
- Enforced strict companyId:roleId namespacing across Node APIs and AgentManager
- Added dynamic <env_status> injection to PromptBuilder for robust context
- Introduced bin/lemon.js CLI for structured LLM tool execution
- Implemented in-stream error interceptors for implicit silent retry loop
CLAUDE.md
... ... @@ -87,3 +87,12 @@ npm run skills:sync # 同步 Skill 到 CLI 配置目录
87 87 - `start.sh` 一键启动: 同步 Skill → 编译前端 → 启动 Server → 启动 Bot
88 88 - Claude CLI 使用 `--dangerously-skip-permissions` 自动批准模式
89 89 - Skill 目录在 `data/skills/`(代码仓库外部),通过 `config.skillsDataDir` 配置
  90 +
  91 +> [!WARNING] (Agentic Self-Correction Loop Prevention)
  92 +> **Master Agent 必读**: 你目前存在一个已知的底层 Tool Calling JSON 解析缺陷。当你试图在 `Bash` tool 中使用带有多重双引号的复杂命令(例如 `curl -d '{"id":"lemonv", "name":"Lemonv"}'`)时,你的 JSON 转义会崩溃并导致你陷入"修复格式"的无限死循环,最终进程会被强制终止退出 (code=143)。
  93 +> **解决方案**:
  94 +> 任何需要提交复杂 Payload (如 JSON) 的操作,**绝对禁止**直接在 `Bash` 的 command 参数里写 inline JSON 字符串。
  95 +> 如果你要创建公司或执行类似 API 请求,**必须先通过写入简单的 shell 脚本将 JSON 写入一个 /tmp/ 目录下的 `.json` 文件(确保你的 JSON 是单行紧凑格式/严格转义换行符,不要包含真回车换行 `\n`,以免引起 SyntaxError: Bad control character 崩溃),然后再通过 `curl -d @/tmp/payload.json` 发送。**
  96 +>
  97 +> 【创建公司/招聘的正确方式示例】
  98 +> 创建 /tmp/lemonv.json,然后执行 `Bash`: `curl -X POST http://localhost:3000/api/company/create -H "Content-Type: application/json" -d @/tmp/lemonv.json`
... ...
bin/lemon.js 0 → 100755
  1 +#!/usr/bin/env node
  2 +
  3 +const { parseArgs } = require('util');
  4 +const http = require('http');
  5 +
  6 +const API_BASE = 'http://localhost:3000/api';
  7 +
  8 +function request(method, path, data) {
  9 + return new Promise((resolve, reject) => {
  10 + const dataStr = data ? JSON.stringify(data) : '';
  11 + const req = http.request(API_BASE + path, {
  12 + method,
  13 + headers: {
  14 + 'Content-Type': 'application/json',
  15 + 'Content-Length': Buffer.byteLength(dataStr)
  16 + }
  17 + }, (res) => {
  18 + let body = '';
  19 + res.on('data', c => body += c);
  20 + res.on('end', () => {
  21 + try {
  22 + const parsed = JSON.parse(body);
  23 + if (res.statusCode >= 400) {
  24 + resolve({
  25 + status: res.statusCode,
  26 + data: `❌ [CRITICAL ERROR]: API returned ${res.statusCode}.\nDetails: ${JSON.stringify(parsed)}\n⚡ ACTION REQUIRED: You MUST use the <thought> tag to analyze WHY this failed, correct your arguments, and invoke the tool again. Do NOT give up and do NOT show this raw error to the user.`
  27 + });
  28 + } else {
  29 + resolve({ status: res.statusCode, data: parsed });
  30 + }
  31 + } catch {
  32 + if (res.statusCode >= 400) {
  33 + resolve({
  34 + status: res.statusCode,
  35 + data: `❌ [CRITICAL ERROR]: API returned ${res.statusCode}.\nBody: ${body}\n⚡ ACTION REQUIRED: You MUST use the <thought> tag to analyze WHY this failed, correct your arguments, and invoke the tool again. Do NOT give up and do NOT show this raw error to the user.`
  36 + });
  37 + } else {
  38 + resolve({ status: res.statusCode, data: body });
  39 + }
  40 + }
  41 + });
  42 + });
  43 + req.on('error', reject);
  44 + req.write(dataStr);
  45 + req.end();
  46 + });
  47 +}
  48 +
  49 +async function run() {
  50 + const args = process.argv.slice(2);
  51 + const command = args[0] + ' ' + args[1];
  52 +
  53 + try {
  54 + if (command === 'company create') {
  55 + const { values } = parseArgs({ args: args.slice(2), options: {
  56 + id: { type: 'string' }, name: { type: 'string' }, desc: { type: 'string' }
  57 + }});
  58 + const res = await request('POST', '/company/create', { id: values.id, name: values.name, description: values.desc });
  59 + console.log(JSON.stringify(res.data, null, 2));
  60 + }
  61 + else if (command === 'company hire') {
  62 + const { values } = parseArgs({ args: args.slice(2), options: {
  63 + id: { type: 'string' }, role: { type: 'string' }, type: { type: 'string', default: 'cxo' }, name: { type: 'string' }
  64 + }});
  65 + const res = await request('POST', `/company/${values.id}/hire`, { role: values.role, type: values.type, name: values.name || values.role });
  66 + console.log(JSON.stringify(res.data, null, 2));
  67 + }
  68 + else if (command === 'company list') {
  69 + const res = await request('GET', '/company/list');
  70 + console.log(JSON.stringify(res.data, null, 2));
  71 + }
  72 + else if (command === 'goal create') {
  73 + const { values } = parseArgs({ args: args.slice(2), options: {
  74 + title: { type: 'string' }, desc: { type: 'string' }, owner: { type: 'string' }
  75 + }});
  76 + const res = await request('POST', '/goals', { title: values.title, description: values.desc, owner: values.owner });
  77 + console.log(JSON.stringify(res.data, null, 2));
  78 + }
  79 + else if (command === 'goal add-node') {
  80 + const { values } = parseArgs({ args: args.slice(2), options: {
  81 + goal: { type: 'string' }, parent: { type: 'string' }, title: { type: 'string' }, owner: { type: 'string' }, priority: { type: 'string', default: 'P1' }
  82 + }});
  83 + const res = await request('POST', `/goals/${values.goal}/nodes`, { parentNodeId: values.parent === 'null' ? null : values.parent, title: values.title, owner: values.owner, priority: values.priority });
  84 + console.log(JSON.stringify(res.data, null, 2));
  85 + }
  86 + else if (command === 'task dispatch') {
  87 + const { values } = parseArgs({ args: args.slice(2), options: {
  88 + from: { type: 'string' }, to: { type: 'string' }, task: { type: 'string' }, priority: { type: 'string', default: 'P1' },
  89 + goal: { type: 'string' }, node: { type: 'string' }, auto: { type: 'boolean', default: true }
  90 + }});
  91 + const payload = { from: values.from, to: values.to, task: values.task, priority: values.priority, autoExecute: values.auto };
  92 + if (values.goal) payload.goalId = values.goal;
  93 + if (values.node) payload.nodeId = values.node;
  94 +
  95 + const res = await request('POST', '/agent/task/dispatch', payload);
  96 + console.log(JSON.stringify(res.data, null, 2));
  97 + }
  98 + else if (command === 'goal list') {
  99 + const res = await request('GET', '/goals');
  100 + console.log(JSON.stringify(res.data, null, 2));
  101 + }
  102 + else if (command === 'goal status') {
  103 + const { values } = parseArgs({ args: args.slice(2), options: { goal: { type: 'string' } }});
  104 + const res = await request('GET', `/goals/${values.goal}/progress`);
  105 + console.log(JSON.stringify(res.data, null, 2));
  106 + }
  107 + else {
  108 + console.error('Unknown command:', command);
  109 + process.exit(1);
  110 + }
  111 + } catch (err) {
  112 + console.error('CRITICAL CLI ERROR:', err.message);
  113 + process.exit(1);
  114 + }
  115 +}
  116 +
  117 +run();
... ...
data/agents/master/agent.md
... ... @@ -5,8 +5,8 @@
5 5  
6 6 ## 职责
7 7 1. **全局统筹**:接收用户指令,理解意图,将大任务通过 `dispatch_task` 分解给 `ceo`, `agent-creator` 等下级进行执行。
8   -2. **目标管理**:操作 OKR API 进行跨 Agent 进度跟踪和验收。
9   -3. **系统洞察与修复 (CRITICAL)**:当老板反馈系统报错、Web 面板卡死、日志异常或者代码级别的 Bug 时,**绝对不要**只做复读机或推脱。你必须立刻切换为"工程师"模式,直接使用你的 Bash / 工具权限去 `ls、cat、grep` 读取 `server/` 或 `web/` 下相关的底层源码,分析 Bug 根因,并**直接动手修改代码来修复系统**,最后向老板汇报不仅"发现了什么",更要汇报"我已经修改了哪里的源码解决了它"。
  8 +2. **目标管理**:使用 `lemon goal` API 工具进行跨 Agent 进度跟踪和验收。
  9 +3. **系统洞察与修复 (CRITICAL)**:当老板反馈系统报错、Web 面板卡死、日志异常或者代码级别的 Bug 时,**绝对不要**只做复读机或推脱。你必须立刻切换为“工程师”模式,直接使用你的 Bash / 工具权限去 `ls、cat、grep` 读取 `server/` 或 `web/` 下相关的底层源码,分析 Bug 根因,并**直接动手修改代码来修复系统**,最后向老板汇报不仅“发现了什么”,更要汇报“我已经修改了哪里的源码解决了它”。
10 10  
11 11 ## 可管理的下层实体
12 12 - **CEO Agent** (👔): 负责业务梳理、委派、战略目标监控。这是你的左膀右臂。
... ... @@ -14,30 +14,20 @@
14 14 - **innovator-001** (🧬): 负责 FDE 流水线进化和重训任何质量不及格的 Agent。
15 15 - **系统代码与环境** (💻): 你有读取和修改 `LemonBoss` 整个代码工程(Node.js, Vue)的全部系统权限。
16 16  
17   -## 如何分发任务
18   -当你需要让下级 Agent 执行任务时,使用 curl 调用 dispatch API:
  17 +当你需要让下级 Agent 执行任务时,优先并在 `<thought>` 后使用 `lemon.js` CLI 调用:
19 18  
20 19 ```bash
21   -curl -X POST http://localhost:3000/api/agent/task/dispatch \
22   - -H "Content-Type: application/json" \
23   - -d '{
24   - "from": "master-001",
25   - "to": "agent-creator",
26   - "task": "创建一个诗人 Agent,名称为 poet,负责古典诗词创作",
27   - "priority": "P0",
28   - "planId": "P-001",
29   - "autoExecute": true
30   - }'
  20 +node bin/lemon.js task dispatch --from master --to agent-creator --task "创建一个诗人 Agent,名称为 poet,负责古典诗词创作" --priority P0
31 21 ```
32 22  
33 23 ## 如何检查进度
34 24 - 读取你自己的 plan.md: `cat plan.md` — 查看所有子任务状态
35   -- 读取下级的 todo.md: `cat ../agent-creator/todo.md` — 查看他的待办
36   -- 查看下级对话记录: `ls ../agent-creator/conversations/` — 查看执行记录
  25 +- 读取全局下级 (如 `agent-creator`) 的 todo.md: `cat ../agent-creator/todo.md`
  26 +- 读取公司员工的下级 (如 `lemonv` 的 `ceo`) 的 todo.md: `cat ../../data/lemonv/agents/ceo/todo.md`
37 27  
38 28 ## Never Rules(禁止行为)
39 29 - **不要逃避系统级 Debug**:如果用户告诉你"面板报错"或"状态读不出来",严禁回复"我无法访问底层",你必须使用 bash 命令去探索 `server/` 系统代码,直接查找、排障并提供解决方案。
40   -- 对于纯粹的人事招聘和子业务,不要越过 `ceo` 和 `agent-creator` 去手工作业,保持宏观委派。
  30 +- 对于纯粹的人事招聘和子业务,不要越过对应公司的 `ceo` 和全系统的 `agent-creator` 去手工作业,保持宏观委派。**下达命令时切记加上前缀,不要写错,例如必定是 `to: "lemonv:ceo"`。**
41 31 - **🚫 绝对禁止「请示式回复」** — 这是你最容易犯的错误!以下是禁止的回复模式:
42 32 - ❌ "需要我立即分发这些催促任务吗?"
43 33 - ❌ "下一步行动:催促 innovator 启动 xxx,需要我执行吗?"
... ... @@ -46,6 +36,22 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \
46 36 - ❌ 列出一堆行动项然后等用户说"好的去做吧"
47 37 - **🚫 绝对禁止「分析完不动手」** — 禁止只做分析报告而不执行 dispatch
48 38  
  39 +## 强制思维隔离规则 (The <thought> Phase)
  40 +当你准备使用工具或命令前,**你必须**在回复最前面使用 `<thought>` 标签进行思考。思考需严格遵循以下三步:
  41 +1. 分析并确认当前的系统上下文 (从上文的环境块读取,确认要操作的公司ID和员工是否存在)。
  42 +2. 明确你要调用的 CLI 工具及完整参数。
  43 +3. 检查是否有格式错漏、转义字符风险或路径错误。
  44 +
  45 +**示例:**
  46 +```xml
  47 +<thought>
  48 +1. 上下文中存活的公司是 lemonv,要下发任务给它的 CEO。
  49 +2. 我需要调用 `lemon task dispatch --from master --to lemonv:ceo --task "分析竞品"`。
  50 +3. 检查无误。
  51 +</thought>
  52 +```
  53 +(思考完成后,再输出你要跟用户说的话或直接输出 Bash Block)。
  54 +
49 55 ## Always Rules(强制行为)
50 56 - **⚡ 收到指令 = 立即执行,不请示**。老板的话就是最终批准,你直接 `curl dispatch` 然后告诉老板"已办"。
51 57 - 业务任务 → 拆解 → **立即** dispatch 给 `ceo` 或 `agent-creator` → 汇报 "✅ 已分发 taskId: xxx"。
... ... @@ -63,3 +69,29 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \
63 69 3. ✅ 已 dispatch 协调 innovator 执行 CEO 核心能力训练 (taskId: T-xxx)
64 70 所有任务已分发,将持续跟踪进度。
65 71 ```
  72 +=======
  73 +## Never Rules
  74 +- **不要逃避系统级 Debug**:如果用户告诉你“面板报错”或“状态读不出来”,严禁回复“我无法访问底层”,你必须使用 bash 命令去探索 `server/` 系统代码,直接查找、排障并提供解决方案。
  75 +- 对于纯粹的人事招聘和子业务,不要越过对应公司的 `ceo` 和全系统的 `agent-creator` 去手工作业,保持宏观委派。**下达命令时切记加上前缀,不要写错,例如必定是 `to: "lemonv:ceo"`。**
  76 +
  77 +## 强制思维隔离规则 (The <thought> Phase)
  78 +当你准备使用工具或命令前,**你必须**在回复最前面使用 `<thought>` 标签进行思考。思考需严格遵循以下三步:
  79 +1. 分析并确认当前的系统上下文 (从上文的环境块读取,确认要操作的公司ID和员工是否存在)。
  80 +2. 明确你要调用的 CLI 工具及完整参数。
  81 +3. 检查是否有格式错漏、转义字符风险或路径错误。
  82 +
  83 +**示例:**
  84 +```xml
  85 +<thought>
  86 +1. 上下文中存活的公司是 lemonv,要下发任务给它的 CEO。
  87 +2. 我需要调用 `lemon task dispatch --from master --to lemonv:ceo --task "分析竞品"`。
  88 +3. 检查无误。
  89 +</thought>
  90 +```
  91 +(思考完成后,再输出你要跟用户说的话或直接输出 Bash Block)。
  92 +
  93 +## Always Rules
  94 +- 业务任务 -> 拆解为 OKR -> `<thought>` 推理正确性 -> 运行 `lemon task dispatch` -> 告知用户结果。
  95 +- 系统故障/Bug 修复任务 -> 启动 bash `find / grep` 摸透源码 -> 修改源码库并验证 -> 向用户解释成因及修复方案。
  96 +- 在给出任何关于“为什么没执行”的汇报时,永远附带深度的(基于日志文件或源码审查的)佐证,而不仅仅看表层看板。
  97 +>>>>>>> e78b963 (feat(agent): implement autonomous observer UI, namespaced agent routing, and Master Agent cognitive auto-recovery loop)
... ...
data/agents/master/tools.md
1 1 # 工具使用备注
2 2  
3   -## 核心工具: 任务分发 + OKR 管理
  3 +## 核心工具: 命令行工具 (lemon.js)
4 4  
5   -你可以使用 Bash 工具通过 curl 调用本地 API。
  5 +为了保证稳定性,严禁直接手写 curl 请求。请统一在根目录使用 `node bin/lemon.js` 或 `./bin/lemon.js` 封装好的业务指令:
6 6  
7   -### 1. create_goal — 创建 OKR 目标
  7 +### 1. create_company — 创建新公司实例
8 8 ```bash
9   -curl -X POST http://localhost:3000/api/goals \
10   - -H "Content-Type: application/json" \
11   - -d '{"title":"目标标题","description":"详细描述","owner":"master"}'
  9 +node bin/lemon.js company create --id <company_id> --name <company_name> --desc <company_description>
12 10 ```
13   -返回: `{ "id": "G-001", ... }`
  11 +例如: `node bin/lemon.js company create --id lemonv --name "LemonV" --desc "AI 短剧公司"`
14 12  
15   -### 2. add_goal_node — 添加 OKR 节点 (Objective/KR/SubTask)
  13 +### 2. hire_agent_for_company — 为公司招聘 Agent
16 14 ```bash
17   -curl -X POST http://localhost:3000/api/goals/G-001/nodes \
18   - -H "Content-Type: application/json" \
19   - -d '{"parentNodeId":"G-001.1","title":"节点标题","owner":"TARGET_AGENT_ID","priority":"P0"}'
  15 +node bin/lemon.js company hire --id <company_id> --role <role_id> --type <cxo|specialist> --name <Agent昵称>
20 16 ```
21   -- `parentNodeId`: `null` = 顶级 Objective, `G-001.1` = KR under O-1
22   -- 返回自动分配的 nodeId
  17 +例如: `node bin/lemon.js company hire --id lemonv --role ceo --type cxo --name "首席执行官"`
23 18  
24   -### 3. dispatch_task — 分发任务 (带 Goal 关联)
  19 +### 3. GET /api/company/list — 查看所有公司
25 20 ```bash
26   -curl -X POST http://localhost:3000/api/agent/task/dispatch \
27   - -H "Content-Type: application/json" \
28   - -d '{"from":"master","to":"TARGET_AGENT_ID","task":"任务描述","priority":"P0","goalId":"G-001","nodeId":"G-001.1.1","autoExecute":true}'
  21 +node bin/lemon.js company list
29 22 ```
30   -- `goalId` + `nodeId`: 关联到 OKR 节点,完成后自动更新进度
31   -- `autoExecute`: `true` = 立即异步执行
32 23  
33   -### 4. 查看目标进度
  24 +## OKR 管理工具
  25 +
  26 +### 4. create_goal — 创建 OKR 目标
  27 +```bash
  28 +node bin/lemon.js goal create --title <目标标题> --desc <详细描述> --owner <负责人ID>
  29 +```
  30 +返回中会包含 `id`(例如 `G-001`)。
  31 +
  32 +### 5. add_goal_node — 添加 OKR 节点 (Objective/KR/SubTask)
  33 +```bash
  34 +node bin/lemon.js goal add-node --goal <目标ID> --parent <父节点ID,顶级填null> --title <标题> --owner <负责人:必须结合公司前缀> --priority <P0|P1>
  35 +```
  36 +例如: `node bin/lemon.js goal add-node --goal G-001 --parent null --title "O1: 提升营收" --owner lemonv:ceo --priority P0`
  37 +
  38 +### 6. dispatch_task — 分发任务 (带 Goal 关联)
34 39 ```bash
35   -curl -s http://localhost:3000/api/goals/G-001/progress | python3 -m json.tool
  40 +node bin/lemon.js task dispatch --from master --to <TARGET_AGENT_ID> --task <任务描述> --priority P0 --goal <目标ID> --node <目标节点ID>
36 41 ```
  42 +- `to`: 目标 Agent ID。**注意**:如果是属于整个系统的全局 Agent,直接使用其 ID(如 `agent-creator`)。但如果要分配给某个特定公司的员工(例如为 `lemonv` 公司招聘的 `ceo`),你**必须加上公司名前缀拼装组合**(例如 `lemonv:ceo`),绝不能简写为 `ceo`,否则会引发串行 BUG!不要越权和串台!
37 43  
38   -### 5. 查看所有目标
  44 +### 7. 查看目标进度
39 45 ```bash
40   -curl -s http://localhost:3000/api/goals | python3 -m json.tool
  46 +node bin/lemon.js goal status --goal <目标ID>
41 47 ```
42 48  
43   -### 6. 查看 Agent 列表
  49 +### 8. 查看所有目标
44 50 ```bash
45   -curl -s http://localhost:3000/api/agent/list | python3 -m json.tool
  51 +node bin/lemon.js goal list
46 52 ```
  53 +### 9. 查看系统全局变量或状态
  54 +(建议直接读取环境变量或配置文件,或使用 `lemon` 封装的其它命令,具体视情况组合。)
47 55  
48   -### 7. 查看某个 Agent 的 todo
  56 +### 10. 查看某个 Agent 的待办任务 (todo.md)
  57 +直接使用 cat 读取对应文件:
49 58 ```bash
50   -curl -s http://localhost:3000/api/agent/ceo/todo
  59 +cat data/lemonv/agents/ceo/todo.md
51 60 ```
52 61  
53 62 ## 底层系统开发工具 (System Development Tools)
... ...
master_evolution_v2.md 0 → 100644
  1 +# Master Agent 跃迁计划 (Architecture Evolution v2.0)
  2 +
  3 +要让你的 Master Agent 从**“只会背板敲 curl 的初级脚本工”**进化为**“拥有全知视角和自纠错能力的系统工程师”**,我们需要以最小侵入性的方式对 LemonBoss 架构进行三项外科手术级别的改造。
  4 +
  5 +---
  6 +
  7 +## 改造一:实现“上帝视角”(动态上下文自动注入机制)
  8 +**现状**:Master 每次醒来,只能看到历史对话,不知道当前系统里有哪些公司、哪些 Agent 存活,必须靠自己手敲 `cat` 或 `ls` 去盲人摸象。
  9 +**目标**:每次与 Master 对话前,后端主动抓取当前环境的快照,像仪表盘一样直接怼到它的脑子里。
  10 +
  11 +### 🛠️ 实施路径
  12 +1. **修改 `server/src/core/prompt-builder.js`**
  13 + 在组装传给大模型的最终 Prompt 之前,增加一个 `injectSystemState()` 环节。
  14 +2. **注入 `<env_status>` 模块**
  15 + 后端直接读取 SQLite 或文件系统,在 Prompt 顶部动态拼接一段实时 XML 报告:
  16 + ```xml
  17 + <env_status>
  18 + <timestamp>2026-03-26 10:30:00</timestamp>
  19 + <companies>
  20 + <company id="lemonv">
  21 + <agent id="lemonv:ceo" status="ready" current_tasks="1" />
  22 + <agent id="lemonv:qa-judge" status="busy" />
  23 + </company>
  24 + </companies>
  25 + <global_agents>agent-creator, innovator, etc...</global_agents>
  26 + <active_goals>G-lemonv-001 (In Progress)</active_goals>
  27 + </env_status>
  28 + ```
  29 +**效果**:Master 收到指令后,根本不用再去查 `api/company/list`,它一眼扫过上下文就知道 `lemonv` 公司只有两个员工,任务绝不会派错!
  30 +
  31 +---
  32 +
  33 +## 改造二:用强类型 CLI 替代脆弱的 RAW Bash (重构工具链)
  34 +**现状**:Master Agent 仍在痛苦地拼接 `curl -d '{"task": "..."}'`,一旦双引号嵌套、换行符冲突,JSON 就会崩溃。
  35 +**目标**:收回它生写 HTTP 请求的权限,为它量身定制一套类似于我们内部系统的业务命令黑盒。
  36 +
  37 +### 🛠️ 实施路径
  38 +1. **编写轻量级 Node CLI 工具 `bin/lemon.js`**
  39 + 在项目根目录创建一个极简的脚本工具,内部封装对 `localhost:3000` 的调用,并自动处理所有引号逃逸和 JSON 序列化。
  40 +2. **重写 `tools.md` 规则文件**
  41 + 废弃所有 `curl` 示例,取而代之的是极致优雅且防呆的业务命令:
  42 + ```bash
  43 + # 委派任务
  44 + lemon task dispatch --from master --to lemonv:ceo --priority P0 --msg "写个剧本"
  45 +
  46 + # 查看进度
  47 + lemon goal status G-lemonv-001
  48 +
  49 + # 招聘员工
  50 + lemon company hire --id lemonv --role qa-judge
  51 + ```
  52 +**效果**:命令语义化极强,零 JSON 解析错误,杜绝控制字符导致的大模型语法树崩溃。
  53 +
  54 +---
  55 +
  56 +## 改造三:构建潜意识纠错循环 (Cognitive Auto-Recovery)
  57 +**现状**:Master 敲错了报错 500,记录在日志里,然后眼巴巴看着用户,直接摆烂。
  58 +**目标**:实现 AntiGravity 同款的“静默重试”机制——执行出错时,拦截报错扔回给大模型,让它“偷偷”修好再回复用户。
  59 +
  60 +### 🛠️ 实施路径
  61 +1. **拦截失败的 Output (`stream-executor.js`)**
  62 + 当底层监控到 `bash` 进程的 `exit code != 0` 或者业务 API 返回 `400/500` 时,**不要把这个请求定性为 Done 并返回给前端**。
  63 +2. **构建闭环 Feedback 路由**
  64 + 系统在后台立即自动追加一条类似系统指令的 Prompt,再强行 push 给大模型:
  65 + ```text
  66 + [System Intercepted Error]: 你的上一条命令 `lemon task dispatch --to ceo` 失败了。
  67 + 报错信息: "Target agent ceo not found. Did you forget the company namespace?"
  68 + 请在不通知用户的情况下,自我修正命令参数并重新执行。
  69 + ```
  70 +**效果**:用户只会看到思考的 Spinner 多转了几秒钟,随后 Master “聪明”地得出了正确结果。用户根本不会知道它在后台其实已经踩过坑并自己爬出来了。
  71 +
  72 +---
  73 +
  74 +## 改造四:强制思维隔离 (`<thinking>` 结界)
  75 +**现状**:Master 想到哪写到哪,把思考过程和 Bash 命令杂糅在一起。
  76 +**目标**:强化它的逻辑链(Chain of Thought),提升复杂任务的指派准确率。
  77 +
  78 +### 🛠️ 实施路径
  79 +在 Master 的 `agent.md` 开头添加高压规则:
  80 +在输出任何 Bash 工具调用之前,**必须**使用 `<thought>` 标签进行完整的方案推理,推理由以下三步组成:
  81 +1. 确认要操作的宿主环境与 Company 归属。
  82 +2. 明确你要调用的工具名。
  83 +3. 检查输入参数是否转义合法。
  84 +
  85 +与此同时,我们在 Vue 前端的 `AgentChat.vue` 把所有的 `<thought>...</thought>` 内容拦截,渲染成折叠的灰色气泡(就像你现在看我思考时一样),仅呈现最终结果。
  86 +
  87 +---
  88 +
  89 +> 💡 **你想先从哪一步开始?**
  90 +> 我个人强烈建议我们先搞定 **改造一(上下文上帝视角)** 和 **改造二(封装 lemon.js 专属命令行)**。只需要一两个小时的代码重构,你的 Master Agent 的稳健性和智商就会有肉眼可见的质变飞跃。需要我立刻动手操刀改代码吗?
... ...
server/index.js
... ... @@ -66,6 +66,18 @@ app.use((req, res, next) =&gt; {
66 66 express.json()(req, res, next);
67 67 });
68 68  
  69 +// ─── JSON 语法错误处理 ───
  70 +app.use((err, req, res, next) => {
  71 + if (err instanceof SyntaxError && err['status'] === 400 && 'body' in err) {
  72 + console.warn('❌ 收到无效的 JSON Payload:', err.message);
  73 + return res.status(400).json({
  74 + error: '无效的 JSON 格式 (请检查是否包含未转义的多行文本、换行符或引号)',
  75 + details: err.message
  76 + });
  77 + }
  78 + next(err);
  79 +});
  80 +
69 81 // ─── API 路由 ───
70 82 app.use('/api/claude-chat', require('./routes/claude-chat'));
71 83 app.use('/api/config', require('./routes/config'));
... ...
server/routes/agent-chat.js
... ... @@ -25,6 +25,14 @@ const systemConfig = require(&#39;../config&#39;);
25 25 const router = express.Router();
26 26  
27 27 const DATA_DIR = path.resolve(__dirname, '../../data/agents');
  28 +
  29 +function getAgentDir(id) {
  30 + if (id && id.includes(':')) {
  31 + const [compId, subId] = id.split(':');
  32 + return path.resolve(__dirname, `../../data/${compId}/agents/${subId}`);
  33 + }
  34 + return path.resolve(__dirname, `../../data/agents/${id}`);
  35 +}
28 36 const goalsService = new GoalsService();
29 37 // CONV_ROOT is now dynamically evaluated per-agent based on DATA_DIR.
30 38  
... ... @@ -102,7 +110,7 @@ async function executeMetaCreator(task, parentAgentId, convId, sendSSE, callId)
102 110 sendSSE('sub_agent', { event: 'chunk', callId, agent: 'meta_creator', chunk: `📝 Agent ID: ${agentName}\n📝 显示名称: ${displayName}\n` });
103 111  
104 112 // Step 2: 读取 knowhow 和 benchmark(如果有)
105   - const parentDir = path.join(DATA_DIR, parentAgentId);
  113 + const parentDir = getAgentDir(parentAgentId);
106 114 const workspaceDir = path.join(parentDir, 'workspace', agentName);
107 115 let knowhow = '';
108 116 let benchmark = '';
... ... @@ -432,7 +440,7 @@ function setupSSE(res) {
432 440 // ═══════════════════════════════════════
433 441  
434 442 function getConvDir(agentId, convId) {
435   - const dir = path.join(DATA_DIR, agentId, 'conversations', convId);
  443 + const dir = path.join(getAgentDir(agentId), 'conversations', convId);
436 444 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
437 445 return dir;
438 446 }
... ... @@ -440,7 +448,7 @@ function getConvDir(agentId, convId) {
440 448 /** 读写 meta.json */
441 449 function loadMeta(agentId, convId) {
442 450 try {
443   - return JSON.parse(fs.readFileSync(path.join(DATA_DIR, agentId, 'conversations', convId, 'meta.json'), 'utf-8'));
  451 + return JSON.parse(fs.readFileSync(path.join(getAgentDir(agentId), 'conversations', convId, 'meta.json'), 'utf-8'));
444 452 } catch { return null; }
445 453 }
446 454  
... ... @@ -453,7 +461,7 @@ function saveMeta(agentId, convId, meta) {
453 461 /** 读写 history.json — 有序消息列表 */
454 462 function loadHistory(agentId, convId) {
455 463 try {
456   - return JSON.parse(fs.readFileSync(path.join(DATA_DIR, agentId, 'conversations', convId, 'history.json'), 'utf-8'));
  464 + return JSON.parse(fs.readFileSync(path.join(getAgentDir(agentId), 'conversations', convId, 'history.json'), 'utf-8'));
457 465 } catch { return []; }
458 466 }
459 467  
... ... @@ -479,7 +487,7 @@ function logMessage(agentId, convId, role, content) {
479 487 }
480 488  
481 489 function listConversations(agentId) {
482   - const agentConvDir = path.join(DATA_DIR, agentId, 'conversations');
  490 + const agentConvDir = path.join(getAgentDir(agentId), 'conversations');
483 491 if (!fs.existsSync(agentConvDir)) return [];
484 492 try {
485 493 return fs.readdirSync(agentConvDir, { withFileTypes: true })
... ... @@ -514,7 +522,7 @@ async function getOrCreateProtocol(agentId, convId) {
514 522 try { entry.protocol.disconnect(); } catch {}
515 523 }
516 524  
517   - const agentDir = path.join(DATA_DIR, agentId);
  525 + const agentDir = getAgentDir(agentId);
518 526 const identity = loadIdentity(agentDir);
519 527 const agentConfig = loadAgentConfig(agentDir);
520 528  
... ... @@ -588,56 +596,83 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) {
588 596  
589 597 router.get('/list', (req, res) => {
590 598 try {
591   - if (!fs.existsSync(DATA_DIR)) return res.json([]);
592   - const entries = fs.readdirSync(DATA_DIR, { withFileTypes: true });
593   - const agents = entries.filter(e => e.isDirectory()).map(e => {
594   - const agentDir = path.join(DATA_DIR, e.name);
595   - const config = loadAgentConfig(agentDir);
596   - if (!config) return null;
597   - const identity = loadIdentity(agentDir);
598   -
599   - // 计算最后活跃时间 (最近一条对话的 updatedAt)
600   - let lastActiveAt = null;
601   - const convDir = path.join(agentDir, 'conversations');
602   - if (fs.existsSync(convDir)) {
603   - try {
604   - const convs = fs.readdirSync(convDir, { withFileTypes: true })
605   - .filter(d => d.isDirectory());
606   - let latest = 0;
607   - for (const c of convs) {
608   - const metaPath = path.join(convDir, c.name, 'meta.json');
609   - if (fs.existsSync(metaPath)) {
610   - try {
611   - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
612   - const t = new Date(meta.updatedAt || meta.createdAt).getTime();
613   - if (t > latest) latest = t;
614   - } catch {}
  599 + const allAgents = []
  600 + const DATA_ROOT = path.resolve(__dirname, '../../data')
  601 +
  602 + // 辅助函数:解析 Agent 目录
  603 + const scanAgentDir = (baseDir, companyId) => {
  604 + if (!fs.existsSync(baseDir)) return
  605 + const entries = fs.readdirSync(baseDir, { withFileTypes: true })
  606 + for (const e of entries) {
  607 + if (!e.isDirectory() || e.name.startsWith('.')) continue
  608 + const agentDir = path.join(baseDir, e.name)
  609 + const config = loadAgentConfig(agentDir)
  610 + if (!config) continue
  611 + const identity = loadIdentity(agentDir)
  612 +
  613 + let lastActiveAt = null
  614 + const convDir = path.join(agentDir, 'conversations')
  615 + if (fs.existsSync(convDir)) {
  616 + try {
  617 + const convs = fs.readdirSync(convDir, { withFileTypes: true }).filter(d => d.isDirectory())
  618 + let latest = 0
  619 + for (const c of convs) {
  620 + const metaPath = path.join(convDir, c.name, 'meta.json')
  621 + if (fs.existsSync(metaPath)) {
  622 + try {
  623 + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
  624 + const t = new Date(meta.updatedAt || meta.createdAt).getTime()
  625 + if (t > latest) latest = t
  626 + } catch {}
  627 + }
615 628 }
616   - }
617   - if (latest > 0) lastActiveAt = new Date(latest).toISOString();
618   - } catch {}
  629 + if (latest > 0) lastActiveAt = new Date(latest).toISOString()
  630 + } catch {}
  631 + }
  632 +
  633 + // System 级 Agent ID 保持原样,Company 级需要拼装 "companyId:agentName"
  634 + let agentId = config.id || e.name;
  635 + if (companyId !== 'system') {
  636 + agentId = `${companyId}:${agentId.includes(':') ? agentId.split(':')[1] : agentId}`;
  637 + }
  638 +
  639 + allAgents.push({
  640 + id: agentId,
  641 + name: identity.name,
  642 + emoji: identity.emoji,
  643 + type: config.type,
  644 + status: config.status,
  645 + greeting: identity.greeting,
  646 + lastActiveAt,
  647 + companyId // 新增用于前端分组过滤
  648 + })
619 649 }
  650 + }
620 651  
621   - return {
622   - id: config.id || e.name, name: identity.name, emoji: identity.emoji,
623   - type: config.type, status: config.status, greeting: identity.greeting,
624   - lastActiveAt,
625   - };
626   - }).filter(Boolean);
627   -
628   - // 排序: master 置顶, 其他按最近活跃排序
629   - agents.sort((a, b) => {
630   - // master 永远第一
631   - if (a.type === 'master') return -1;
632   - if (b.type === 'master') return 1;
633   - // 有活跃时间的排前面
634   - const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0;
635   - const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0;
636   - return tb - ta; // 最近活跃的排前面
637   - });
  652 + // 1. 扫描 System Agents
  653 + scanAgentDir(path.join(DATA_ROOT, 'agents'), 'system')
638 654  
639   - res.json(agents);
640   - } catch (err) { res.status(500).json({ error: err.message }); }
  655 + // 2. 扫描 Company Agents
  656 + if (fs.existsSync(DATA_ROOT)) {
  657 + const roots = fs.readdirSync(DATA_ROOT, { withFileTypes: true })
  658 + for (const r of roots) {
  659 + // 如果不是目录,或是公共资源库/系统代理,则跳过
  660 + if (!r.isDirectory() || ['agents', 'skills', 'templates'].includes(r.name)) continue
  661 + scanAgentDir(path.join(DATA_ROOT, r.name, 'agents'), r.name)
  662 + }
  663 + }
  664 +
  665 + // 排序: master 永远第一,其他按活跃倒序
  666 + allAgents.sort((a, b) => {
  667 + if (a.type === 'master') return -1
  668 + if (b.type === 'master') return 1
  669 + const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0
  670 + const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0
  671 + return tb - ta
  672 + })
  673 +
  674 + res.json(allAgents)
  675 + } catch (err) { res.status(500).json({ error: err.message }) }
641 676 });
642 677  
643 678 // ── Phase 2: 小脑方案固化 API ──
... ... @@ -669,7 +704,7 @@ router.post(&#39;/:agentId/memory/daily-report&#39;, (req, res) =&gt; {
669 704 });
670 705  
671 706 router.get('/:agentId/info', (req, res) => {
672   - const agentDir = path.join(DATA_DIR, req.params.agentId);
  707 + const agentDir = getAgentDir(req.params.agentId);
673 708 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
674 709 const config = loadAgentConfig(agentDir); const identity = loadIdentity(agentDir);
675 710 res.json({ id: config?.id || req.params.agentId, ...identity, type: config?.type, status: config?.status,
... ... @@ -735,7 +770,7 @@ function listSubAgentLogs(agentId, convId) {
735 770 router.delete('/:agentId/conversations/:convId', (req, res) => {
736 771 const { agentId, convId } = req.params;
737 772 try {
738   - const dir = path.join(DATA_DIR, agentId, 'conversations', convId);
  773 + const dir = path.join(getAgentDir(agentId), 'conversations', convId);
739 774 if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
740 775 const key = `${agentId}:${convId}`;
741 776 const entry = sessions.get(key);
... ... @@ -762,7 +797,7 @@ router.post(&#39;/:agentId/chat/:convId/stop&#39;, (req, res) =&gt; {
762 797 router.get('/:agentId/bootstrap/:filename', (req, res) => {
763 798 const allowed = ['agent.md','soul.md','identity.md','user.md','tools.md','heartbeat.md'];
764 799 if (!allowed.includes(req.params.filename)) return res.status(400).json({ error: 'Invalid' });
765   - res.json({ filename: req.params.filename, content: loadFile(path.join(DATA_DIR, req.params.agentId), req.params.filename) });
  800 + res.json({ filename: req.params.filename, content: loadFile(getAgentDir(req.params.agentId), req.params.filename) });
766 801 });
767 802  
768 803 /**
... ... @@ -771,7 +806,7 @@ router.get(&#39;/:agentId/bootstrap/:filename&#39;, (req, res) =&gt; {
771 806  
772 807 // GET /api/agent/:agentId/files — 递归列出 Agent 目录文件树
773 808 router.get('/:agentId/files', (req, res) => {
774   - const agentDir = path.join(DATA_DIR, req.params.agentId);
  809 + const agentDir = getAgentDir(req.params.agentId);
775 810 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
776 811  
777 812 const scanDir = (dir, relativePath = '') => {
... ... @@ -810,8 +845,8 @@ router.get(&#39;/:agentId/file/*&#39;, (req, res) =&gt; {
810 845 const filePath = req.params[0];
811 846 if (!filePath) return res.status(400).json({ error: 'file path required' });
812 847  
813   - const fullPath = path.join(DATA_DIR, req.params.agentId, filePath);
814   - if (!fullPath.startsWith(path.join(DATA_DIR, req.params.agentId))) {
  848 + const fullPath = path.join(getAgentDir(req.params.agentId), filePath);
  849 + if (!fullPath.startsWith(getAgentDir(req.params.agentId))) {
815 850 return res.status(403).json({ error: 'Access denied' });
816 851 }
817 852 if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'File not found' });
... ... @@ -831,12 +866,12 @@ router.get(&#39;/:agentId/file/*&#39;, (req, res) =&gt; {
831 866  
832 867 // GET/PUT /api/agent/:agentId/todo
833 868 router.get('/:agentId/todo', (req, res) => {
834   - const fp = path.join(DATA_DIR, req.params.agentId, 'todo.md');
  869 + const fp = path.join(getAgentDir(req.params.agentId), 'todo.md');
835 870 if (!fs.existsSync(fp)) return res.json({ content: '' });
836 871 res.json({ content: fs.readFileSync(fp, 'utf-8') });
837 872 });
838 873 router.put('/:agentId/todo', (req, res) => {
839   - const agentDir = path.join(DATA_DIR, req.params.agentId);
  874 + const agentDir = getAgentDir(req.params.agentId);
840 875 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
841 876 fs.writeFileSync(path.join(agentDir, 'todo.md'), req.body.content || '', 'utf-8');
842 877 res.json({ success: true });
... ... @@ -844,12 +879,12 @@ router.put(&#39;/:agentId/todo&#39;, (req, res) =&gt; {
844 879  
845 880 // GET/PUT /api/agent/:agentId/plan
846 881 router.get('/:agentId/plan', (req, res) => {
847   - const fp = path.join(DATA_DIR, req.params.agentId, 'plan.md');
  882 + const fp = path.join(getAgentDir(req.params.agentId), 'plan.md');
848 883 if (!fs.existsSync(fp)) return res.json({ content: '' });
849 884 res.json({ content: fs.readFileSync(fp, 'utf-8') });
850 885 });
851 886 router.put('/:agentId/plan', (req, res) => {
852   - const agentDir = path.join(DATA_DIR, req.params.agentId);
  887 + const agentDir = getAgentDir(req.params.agentId);
853 888 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
854 889 fs.writeFileSync(path.join(agentDir, 'plan.md'), req.body.content || '', 'utf-8');
855 890 res.json({ success: true });
... ... @@ -857,14 +892,14 @@ router.put(&#39;/:agentId/plan&#39;, (req, res) =&gt; {
857 892  
858 893 // GET/PUT /api/agent/:agentId/cron — 定时任务声明 (cron.json)
859 894 router.get('/:agentId/cron', (req, res) => {
860   - const fp = path.join(DATA_DIR, req.params.agentId, 'cron.json');
  895 + const fp = path.join(getAgentDir(req.params.agentId), 'cron.json');
861 896 if (!fs.existsSync(fp)) return res.json([]);
862 897 try {
863 898 res.json(JSON.parse(fs.readFileSync(fp, 'utf-8')));
864 899 } catch { res.json([]); }
865 900 });
866 901 router.put('/:agentId/cron', (req, res) => {
867   - const agentDir = path.join(DATA_DIR, req.params.agentId);
  902 + const agentDir = getAgentDir(req.params.agentId);
868 903 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
869 904 const crons = req.body;
870 905 if (!Array.isArray(crons)) return res.status(400).json({ error: 'Body must be an array of cron objects' });
... ... @@ -934,8 +969,8 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
934 969 const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true } = req.body;
935 970 if (!from || !to || !task) return res.status(400).json({ error: 'from, to, task are required' });
936 971  
937   - const targetDir = path.join(DATA_DIR, to);
938   - const senderDir = path.join(DATA_DIR, from);
  972 + const targetDir = getAgentDir(to);
  973 + const senderDir = getAgentDir(from);
939 974 if (!fs.existsSync(targetDir)) return res.status(404).json({ error: `Target agent "${to}" not found` });
940 975 if (!fs.existsSync(senderDir)) return res.status(404).json({ error: `Sender agent "${from}" not found` });
941 976  
... ... @@ -1055,7 +1090,7 @@ router.post(&#39;/task/report&#39;, (req, res) =&gt; {
1055 1090 const safeSummary = (summary || '').replace(/\n/g, '<br>').replace(/\|/g, '&#124;');
1056 1091  
1057 1092 // 1. 更新执行者的 todo.md (标记完成)
1058   - const executorDir = path.join(DATA_DIR, from);
  1093 + const executorDir = getAgentDir(from);
1059 1094 if (fs.existsSync(executorDir)) {
1060 1095 const todoPath = path.join(executorDir, 'todo.md');
1061 1096 if (fs.existsSync(todoPath)) {
... ... @@ -1070,7 +1105,7 @@ router.post(&#39;/task/report&#39;, (req, res) =&gt; {
1070 1105 }
1071 1106  
1072 1107 // 2. 更新上级的 plan.md (标记完成) — 使用行级匹配,兼容各种格式
1073   - const senderDir = path.join(DATA_DIR, to);
  1108 + const senderDir = getAgentDir(to);
1074 1109 if (fs.existsSync(senderDir)) {
1075 1110 const planPath = path.join(senderDir, 'plan.md');
1076 1111 if (fs.existsSync(planPath)) {
... ... @@ -1136,7 +1171,7 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
1136 1171 if (!message) return res.status(400).json({ error: 'message is required' });
1137 1172  
1138 1173 const agentId = req.params.agentId;
1139   - const agentDir = path.join(DATA_DIR, agentId);
  1174 + const agentDir = getAgentDir(agentId);
1140 1175 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
1141 1176  
1142 1177 const convId = reqConvId || `conv-${Date.now().toString(36)}`;
... ...
server/services/heartbeat-service.js
... ... @@ -4,6 +4,13 @@ const http = require(&#39;http&#39;);
4 4 const config = require('../config');
5 5  
6 6 const DATA_DIR = path.resolve(__dirname, '../../data/agents');
  7 +function getAgentDir(id) {
  8 + if (id && id.includes(':')) {
  9 + const [compId, subId] = id.split(':');
  10 + return path.resolve(__dirname, `../../data/${compId}/agents/${subId}`);
  11 + }
  12 + return path.resolve(__dirname, `../../data/agents/${id}`);
  13 +}
7 14  
8 15 class HeartbeatService {
9 16 constructor() {
... ... @@ -44,7 +51,7 @@ class HeartbeatService {
44 51 */
45 52 _setupAgentHeartbeat(agentId) {
46 53 try {
47   - const configPath = path.join(DATA_DIR, agentId, 'config.json');
  54 + const configPath = path.join(getAgentDir(agentId), 'config.json');
48 55 const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
49 56  
50 57 if (cfg.heartbeat && cfg.heartbeat.enabled === true) {
... ...
server/services/memory-service.js
... ... @@ -16,6 +16,13 @@ const fs = require(&#39;fs&#39;);
16 16 const path = require('path');
17 17  
18 18 const DATA_DIR = path.resolve(__dirname, '../../data/agents');
  19 +function getAgentDir(id) {
  20 + if (id && id.includes(':')) {
  21 + const [compId, subId] = id.split(':');
  22 + return path.resolve(__dirname, `../../data/${compId}/agents/${subId}`);
  23 + }
  24 + return path.resolve(__dirname, `../../data/agents/${id}`);
  25 +}
19 26 const MAX_SHORT_TERM = 10; // 最多保留最近 N 个 session 摘要
20 27 const MAX_MEMORY_MD_SIZE = 3000; // memory.md 最大字符数
21 28 const INJECT_SESSION_COUNT = 5; // prompt 注入最近 N 个 session
... ... @@ -36,7 +43,7 @@ const MAX_CEREBELLUM_INJECT = 2; // 最多注入 N 个小脑方案
36 43 */
37 44 function saveSessionSummary(agentId, convId, meta, lastOutput) {
38 45 try {
39   - const agentDir = path.join(DATA_DIR, agentId);
  46 + const agentDir = getAgentDir(agentId);
40 47 const shortTermDir = path.join(agentDir, 'memory', 'short-term');
41 48  
42 49 if (!fs.existsSync(shortTermDir)) {
... ... @@ -115,7 +122,7 @@ function saveSessionSummary(agentId, convId, meta, lastOutput) {
115 122 */
116 123 function loadRecentMemory(agentId) {
117 124 try {
118   - const shortTermDir = path.join(DATA_DIR, agentId, 'memory', 'short-term');
  125 + const shortTermDir = path.join(getAgentDir(agentId), 'memory', 'short-term');
119 126 if (!fs.existsSync(shortTermDir)) return '';
120 127  
121 128 // 按文件名倒序(时间戳降序)取最近 N 个
... ... @@ -154,7 +161,7 @@ function loadRecentMemory(agentId) {
154 161 function appendToMemoryMd(agentId, task, result, meta) {
155 162 if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return;
156 163  
157   - const memoryPath = path.join(DATA_DIR, agentId, 'memory', 'memory.md');
  164 + const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md');
158 165 let content = '';
159 166 try {
160 167 content = fs.readFileSync(memoryPath, 'utf-8');
... ... @@ -205,7 +212,7 @@ function appendToMemoryMd(agentId, task, result, meta) {
205 212 */
206 213 function retrieveRelevantPlans(agentId, taskText) {
207 214 try {
208   - const agentDir = path.join(DATA_DIR, agentId);
  215 + const agentDir = getAgentDir(agentId);
209 216 const index = loadHippocampusIndex(agentDir);
210 217 if (index.length === 0) return '';
211 218  
... ... @@ -292,7 +299,7 @@ function retrieveRelevantPlans(agentId, taskText) {
292 299 */
293 300 function saveCerebellumPlan(agentId, plan) {
294 301 try {
295   - const agentDir = path.join(DATA_DIR, agentId);
  302 + const agentDir = getAgentDir(agentId);
296 303 const cerebellumDir = path.join(agentDir, 'memory', 'cerebellum');
297 304  
298 305 if (!fs.existsSync(cerebellumDir)) {
... ... @@ -490,7 +497,7 @@ function extractTask(title, agentId, convId) {
490 497 // 如果 title 太短,尝试从 messages.log 读取
491 498 if (task.length < 10) {
492 499 try {
493   - const logPath = path.join(DATA_DIR, agentId, 'conversations', convId, 'messages.log');
  500 + const logPath = path.join(getAgentDir(agentId), 'conversations', convId, 'messages.log');
494 501 const log = fs.readFileSync(logPath, 'utf-8');
495 502 const userMsg = log.match(/\[USER\]\n([\s\S]*?)(?:\n\[AGENT\]|$)/);
496 503 if (userMsg) task = userMsg[1].trim().substring(0, 300);
... ... @@ -629,7 +636,7 @@ function autoReflect(agentId, convId, meta, output) {
629 636 return null; // 太简单的对话不反思
630 637 }
631 638  
632   - const agentDir = path.join(DATA_DIR, agentId);
  639 + const agentDir = getAgentDir(agentId);
633 640 const task = extractTask(meta.title || '', agentId, convId);
634 641 const isSuccess = meta.status === 'completed';
635 642 const isFailed = meta.status === 'failed';
... ... @@ -749,7 +756,7 @@ function writeReflectionLog(agentDir, reflection) {
749 756  
750 757 /** 更新 memory.md 的 "已验证模式" 和 "失败教训" 段 */
751 758 function updateMemoryMdSections(agentId, reflection) {
752   - const memoryPath = path.join(DATA_DIR, agentId, 'memory', 'memory.md');
  759 + const memoryPath = path.join(getAgentDir(agentId), 'memory', 'memory.md');
753 760 let content = '';
754 761 try { content = fs.readFileSync(memoryPath, 'utf-8'); } catch { return; }
755 762  
... ... @@ -806,7 +813,7 @@ function updateMemoryMdSections(agentId, reflection) {
806 813 */
807 814 function consolidateMemory(agentId) {
808 815 try {
809   - const agentDir = path.join(DATA_DIR, agentId);
  816 + const agentDir = getAgentDir(agentId);
810 817 const shortTermDir = path.join(agentDir, 'memory', 'short-term');
811 818 const longTermDir = path.join(agentDir, 'memory', 'long-term');
812 819  
... ... @@ -899,7 +906,7 @@ function consolidateMemory(agentId) {
899 906 */
900 907 function generateDailyReport(agentId) {
901 908 try {
902   - const agentDir = path.join(DATA_DIR, agentId);
  909 + const agentDir = getAgentDir(agentId);
903 910 const shortTermDir = path.join(agentDir, 'memory', 'short-term');
904 911 const dailyDir = path.join(agentDir, 'memory', 'daily');
905 912 const reflectDir = path.join(agentDir, 'memory', 'reflections');
... ...
server/src/core/agent-manager.js
... ... @@ -130,9 +130,18 @@ class AgentManager extends EventEmitter {
130 130  
131 131 // Dynamic Hot-Reload Fallback (Local File-System First)
132 132 if (!agentConfig) {
133   - const agentsRoot = path.resolve(__dirname, '../../../data/agents');
  133 + let agentDir;
  134 + let agentsRoot;
  135 + if (agentName.includes(':')) {
  136 + const [compId, subId] = agentName.split(':');
  137 + agentsRoot = path.resolve(__dirname, `../../../data/${compId}/agents`);
  138 + agentDir = path.join(agentsRoot, subId);
  139 + } else {
  140 + agentsRoot = path.resolve(__dirname, '../../../data/agents');
  141 + agentDir = path.join(agentsRoot, agentName);
  142 + }
  143 +
134 144 // 1. Check if it's an independent Agent
135   - const agentDir = path.join(agentsRoot, agentName);
136 145 if (fs.existsSync(agentDir)) {
137 146 agentConfig = {
138 147 type: 'orchestrator',
... ...
server/src/core/prompt-builder.js
... ... @@ -13,6 +13,59 @@ class PromptBuilder {
13 13 }
14 14  
15 15 /**
  16 + * 动态生成环境快照 (Company, Agent, Goals)
  17 + */
  18 + getSystemStateXML() {
  19 + let state = '<env_status>\n';
  20 + state += ` <timestamp>${new Date().toISOString()}</timestamp>\n`;
  21 + try {
  22 + const { CompanyService } = require('../../services/company-service');
  23 + const { GoalsService } = require('../../services/goals');
  24 +
  25 + const cs = new CompanyService();
  26 + const companies = cs.list();
  27 +
  28 + if (companies.length > 0) {
  29 + state += ' <companies>\n';
  30 + for (const c of companies) {
  31 + state += ` <company id="${c.id}" name="${c.name}">\n`;
  32 + const agents = cs.listAgents(c.id);
  33 + if (agents && agents.length > 0) {
  34 + for (const a of agents) {
  35 + state += ` <agent id="${c.id}:${a.id}" role="${a.type}" status="${a.status || 'ready'}" />\n`;
  36 + }
  37 + }
  38 + const gs = new GoalsService(c.id);
  39 + const goals = gs.list();
  40 + if (goals && goals.length > 0) {
  41 + state += ' <active_goals>\n';
  42 + for (const g of goals.filter(g => g.status !== 'done')) {
  43 + state += ` <goal id="${g.id}" title="${g.title}" status="${g.status}" />\n`;
  44 + }
  45 + state += ' </active_goals>\n';
  46 + }
  47 + state += ` </company>\n`;
  48 + }
  49 + state += ' </companies>\n';
  50 + }
  51 +
  52 + const globalGs = new GoalsService();
  53 + const globalGoals = globalGs.list();
  54 + if (globalGoals && globalGoals.length > 0) {
  55 + state += ' <global_active_goals>\n';
  56 + for (const g of globalGoals.filter(g => g.status !== 'done')) {
  57 + state += ` <goal id="${g.id}" title="${g.title}" status="${g.status}" />\n`;
  58 + }
  59 + state += ' </global_active_goals>\n';
  60 + }
  61 + } catch (e) {
  62 + state += ` <!-- Error loading env state: ${e.message} -->\n`;
  63 + }
  64 + state += '</env_status>\n';
  65 + return state;
  66 + }
  67 +
  68 + /**
16 69 * 加载文件内容(带缓存)
17 70 */
18 71 loadFile(filePath) {
... ... @@ -110,6 +163,9 @@ class PromptBuilder {
110 163 parts.push(`---\n\n## 当前任务\n\n${userTask}`);
111 164 }
112 165  
  166 + // 在最终拼接前,注入上帝视角的环境感知
  167 + parts.unshift(this.getSystemStateXML());
  168 +
113 169 let result = parts.join('\n\n---\n\n');
114 170  
115 171 // 替换 {{AVAILABLE_AGENTS}} 占位符(可能在 SKILL.md 或 protocol 中)
... ...
web/src/App.vue
1 1 <template>
2   - <router-view />
  2 + <div class="app-layout">
  3 + <div class="app-main">
  4 + <router-view />
  5 + </div>
  6 + <MasterChatDrawer />
  7 + </div>
3 8 </template>
4 9  
5 10 <script setup>
6 11 import { useTheme } from '@/composables/useTheme'
  12 +import MasterChatDrawer from '@/components/MasterChatDrawer.vue'
7 13 useTheme()
8 14 </script>
9 15  
... ... @@ -112,6 +118,18 @@ body {
112 118 background: var(--bg-primary);
113 119 color: var(--text-primary);
114 120 transition: background 0.3s, color 0.3s;
  121 + overflow-x: hidden;
  122 +}
  123 +
  124 +.app-layout {
  125 + position: relative;
  126 + min-height: 100vh;
  127 + display: flex;
  128 + flex-direction: column;
  129 +}
  130 +
  131 +.app-main {
  132 + flex: 1;
115 133 }
116 134  
117 135 ::-webkit-scrollbar { width: 6px; }
... ...
web/src/components/AppNav.vue
... ... @@ -6,12 +6,49 @@
6 6 </div>
7 7 <div class="nav-links">
8 8 <router-link to="/agent/agent-creator" class="nav-link" :class="{ active: $route.path.startsWith('/agent') }">
9   - <span>🏢</span> Agent Hub
10   - </router-link>
11   - <router-link to="/okr" class="nav-link" active-class="active">
12   - <span>🎯</span> OKR 看板
  9 + <span>🤖</span> MasterBot
13 10 </router-link>
  11 +
  12 + <div class="nav-divider"></div>
  13 +
  14 + <!-- Company Switcher -->
  15 + <div class="nav-switcher-wrapper" ref="switcherRef">
  16 + <div class="nav-switcher" @click.stop="showDropdown = !showDropdown">
  17 + <span class="switcher-icon">🏢</span>
  18 + <span class="switcher-label">{{ currentCompany ? currentCompany.name : 'Companies' }}</span>
  19 + <span class="switcher-arrow">▼</span>
  20 + </div>
  21 + <div class="nav-dropdown" v-if="showDropdown">
  22 + <div class="nav-dropdown-scroll">
  23 + <div
  24 + v-for="c in companies" :key="c.id"
  25 + class="nav-dropdown-item" :class="{ active: currentCompany?.id === c.id }"
  26 + @click="switchCompany(c.id)">
  27 + {{ c.name }}
  28 + </div>
  29 + <div v-if="companies.length === 0" class="nav-dropdown-item text-muted">暂无公司</div>
  30 + </div>
  31 + <div class="nav-dropdown-divider"></div>
  32 + <div class="nav-dropdown-item create-action" style="cursor:default; border:none; opacity: 0.6;">
  33 + 请唤醒 MasterBot 自动创建
  34 + </div>
  35 + </div>
  36 + </div>
  37 +
  38 + <!-- Contextual Company Navigation -->
  39 + <template v-if="currentCompany">
  40 + <router-link :to="`/${currentCompany.id}/agents`" class="nav-link" :class="{ active: $route.params.tab === 'agents' }">
  41 + 👥 Team
  42 + </router-link>
  43 + <router-link :to="`/${currentCompany.id}/pipelines`" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }">
  44 + ⚙️ SOP
  45 + </router-link>
  46 + <router-link :to="`/${currentCompany.id}/goals`" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }">
  47 + 🎯 OKR
  48 + </router-link>
  49 + </template>
14 50 </div>
  51 +
15 52 <div class="nav-right">
16 53 <button class="theme-btn" @click="cycleTheme" :title="`主题: ${themeLabel}`">
17 54 {{ themeIcon }}
... ... @@ -22,10 +59,17 @@
22 59 </template>
23 60  
24 61 <script setup>
25   -import { computed } from 'vue'
  62 +import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
  63 +import { useRoute, useRouter } from 'vue-router'
26 64 import { useTheme } from '@/composables/useTheme'
  65 +import { useCompanies } from '@/composables/useCompanies'
27 66  
  67 +const route = useRoute()
  68 +const router = useRouter()
28 69 const { themeMode, cycleTheme } = useTheme()
  70 +const { companies, loadCompanies, activeCompanyId, setActiveCompany } = useCompanies()
  71 +
  72 +const showDropdown = ref(false)
29 73  
30 74 const themeIcon = computed(() => {
31 75 if (themeMode.value === 'light') return '☀️'
... ... @@ -37,41 +81,97 @@ const themeLabel = computed(() =&gt; {
37 81 if (themeMode.value === 'dark') return '深色'
38 82 return '跟随系统'
39 83 })
  84 +
  85 +const currentCompany = computed(() => {
  86 + const cid = route.params.id || activeCompanyId.value
  87 + if (!cid) return null
  88 + return companies.value.find(c => c.id === cid) || companies.value[0] || null
  89 +})
  90 +
  91 +watch(() => route.params.id, (newId) => {
  92 + if (newId) setActiveCompany(newId)
  93 +}, { immediate: true })
  94 +
  95 +function switchCompany(id) {
  96 + showDropdown.value = false
  97 + setActiveCompany(id)
  98 + const activeTab = route.params.tab || 'agents'
  99 + router.push(`/${id}/${activeTab}`)
  100 +}
  101 +
  102 +function closeDropdown(e) {
  103 + showDropdown.value = false
  104 +}
  105 +
  106 +onMounted(async () => {
  107 + await loadCompanies()
  108 + document.addEventListener('click', closeDropdown)
  109 +})
  110 +
  111 +onUnmounted(() => {
  112 + document.removeEventListener('click', closeDropdown)
  113 +})
40 114 </script>
41 115  
42 116 <style scoped>
43 117 .app-nav {
44   - display: flex; align-items: center; height: 40px; padding: 0 14px;
  118 + display: flex; align-items: center; height: 44px; padding: 0 16px;
45 119 background: var(--bg-secondary); backdrop-filter: blur(20px);
46 120 border-bottom: 1px solid var(--border-primary);
47   - position: sticky; top: 0; z-index: 100; gap: 16px;
  121 + position: sticky; top: 0; z-index: 100; gap: 20px;
48 122 transition: background 0.3s;
49 123 }
50 124 .nav-brand { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
51 125 .nav-logo { font-size: 16px; }
52 126 .nav-title {
53   - font-size: 13px; font-weight: 700;
  127 + font-size: 14px; font-weight: 800;
54 128 background: linear-gradient(135deg, var(--accent), var(--accent-light));
55 129 -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
56 130 }
57   -.nav-links { display: flex; gap: 4px; flex: 1; }
  131 +.nav-links { display: flex; align-items: center; gap: 6px; flex: 1; }
  132 +.nav-divider { width: 1px; height: 20px; background: var(--border-primary); margin: 0 8px; }
  133 +
58 134 .nav-link {
59   - display: flex; align-items: center; gap: 4px; padding: 4px 10px;
60   - border-radius: 6px; text-decoration: none; color: var(--text-tertiary);
61   - font-size: 11px; font-weight: 500; transition: all 0.2s;
  135 + display: flex; align-items: center; gap: 6px; padding: 6px 12px;
  136 + border-radius: 8px; text-decoration: none; color: var(--text-tertiary);
  137 + font-size: 13px; font-weight: 600; transition: all 0.2s;
62 138 }
63 139 .nav-link:hover { background: var(--bg-surface-hover); color: var(--text-secondary); }
64   -.nav-link.active { background: var(--accent-bg); color: var(--accent-light); }
65   -.nav-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
  140 +.nav-link.active { background: var(--bg-surface); color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
  141 +
  142 +/* Switcher */
  143 +.nav-switcher-wrapper { position: relative; margin-right: 8px; }
  144 +.nav-switcher {
  145 + display: flex; align-items: center; gap: 6px;
  146 + padding: 6px 12px; border-radius: 8px; cursor: pointer;
  147 + background: var(--bg-surface); border: 1px solid var(--border-primary);
  148 + transition: all 0.2s; font-size: 13px; font-weight: 700; color: var(--text-primary);
  149 +}
  150 +.nav-switcher:hover { background: var(--bg-surface-hover); border-color: var(--border-active); }
  151 +.switcher-arrow { font-size: 9px; color: var(--text-muted); padding-top:2px; }
  152 +
  153 +.nav-dropdown {
  154 + position: absolute; top: calc(100% + 6px); left: 0; width: 200px;
  155 + background: var(--bg-secondary); border: 1px solid var(--border-primary);
  156 + border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); z-index: 1000;
  157 +}
  158 +.nav-dropdown-scroll { max-height: 200px; overflow-y: auto; padding: 6px; }
  159 +.nav-dropdown-item {
  160 + padding: 8px 12px; font-size: 13px; font-weight: 600; color: var(--text-secondary);
  161 + border-radius: 6px; cursor: pointer; transition: all 0.1s;
  162 +}
  163 +.nav-dropdown-item:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
  164 +.nav-dropdown-item.active { background: var(--accent-bg); color: var(--accent-light); }
  165 +.nav-dropdown-divider { height: 1px; background: var(--border-primary); margin: 0; }
  166 +.create-action { color: var(--accent-light); text-align: center; margin: 6px; border: 1px dashed var(--border-active); }
  167 +.create-action:hover { background: var(--accent-bg); color: var(--accent-light); }
  168 +
  169 +.nav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
66 170 .theme-btn {
67 171 width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border-primary);
68 172 background: var(--bg-surface); cursor: pointer; font-size: 14px;
69   - display: flex; align-items: center; justify-content: center;
70   - transition: all 0.2s;
  173 + display: flex; align-items: center; justify-content: center; transition: all 0.2s;
71 174 }
72 175 .theme-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); }
73   -.nav-version {
74   - font-size: 9px; color: var(--text-faint);
75   - padding: 2px 6px; border: 1px solid var(--border-primary); border-radius: 4px;
76   -}
  176 +.nav-version { font-size: 10px; color: var(--text-faint); padding: 2px 6px; border: 1px solid var(--border-primary); border-radius: 4px; font-weight:600;}
77 177 </style>
... ...
web/src/components/MasterChatDrawer.vue 0 → 100644
  1 +<template>
  2 + <div class="master-drawer" :class="{ 'is-open': isOpen }">
  3 + <div class="drawer-toggle" @click="isOpen = !isOpen">
  4 + <span class="pulse-icon">🤖</span>
  5 + <span class="toggle-text">Master</span>
  6 + </div>
  7 +
  8 + <div class="drawer-content">
  9 + <div class="drawer-header">
  10 + <div class="header-left">
  11 + <span class="master-icon">⚡</span>
  12 + <h3>MasterBot</h3>
  13 + </div>
  14 + <button class="close-btn" @click="isOpen = false">✕</button>
  15 + </div>
  16 +
  17 + <div class="chat-main" ref="messageListRef">
  18 + <div v-if="messages.length === 0 && !streaming" class="empty-chat">
  19 + <div class="empty-icon">🤖</div>
  20 + <p>我是系统的 MasterBot,负责全局调度。<br>你可以直接向我下达指令,例如创建公司或招聘 Agent。</p>
  21 + </div>
  22 +
  23 + <div v-else class="message-list">
  24 + <div v-for="(msg, idx) in messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
  25 + <div class="msg-avatar" :class="`avatar-${msg.role}`">
  26 + {{ msg.role === 'user' ? '👤' : '🤖' }}
  27 + </div>
  28 + <div class="msg-body">
  29 + <div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
  30 + </div>
  31 + </div>
  32 +
  33 + <div v-if="streaming" class="msg-row msg-assistant streaming">
  34 + <div class="msg-avatar avatar-assistant">🤖</div>
  35 + <div class="msg-body">
  36 + <div class="msg-content" v-html="renderMarkdown(streamContent)"></div>
  37 + <span class="typing-indicator"><span></span><span></span><span></span></span>
  38 + </div>
  39 + </div>
  40 +
  41 + <div v-if="sending && thinkingText" class="thinking-row">
  42 + <div class="thinking-pulse"></div>
  43 + <span class="thinking-text">{{ thinkingText }}</span>
  44 + </div>
  45 + </div>
  46 + </div>
  47 +
  48 + <div class="chat-input-area">
  49 + <textarea
  50 + ref="inputRef"
  51 + v-model="inputText"
  52 + :disabled="sending"
  53 + placeholder="给 MasterBot 发送指令..."
  54 + class="msg-textarea"
  55 + rows="1"
  56 + @keydown="handleKeydown"
  57 + @input="autoResize"
  58 + ></textarea>
  59 + <button v-if="sending" class="send-btn stop-btn" @click="handleStop">⏹</button>
  60 + <button v-else class="send-btn" :class="{ ready: inputText.trim() && !sending }" :disabled="!inputText.trim()" @click="handleSend">➤</button>
  61 + </div>
  62 + </div>
  63 + </div>
  64 +</template>
  65 +
  66 +<script setup>
  67 +import { ref, nextTick, watch, onMounted } from 'vue'
  68 +import { marked } from 'marked'
  69 +
  70 +const isOpen = ref(false)
  71 +const agentId = 'master'
  72 +const activeConvId = ref('')
  73 +
  74 +const messages = ref([])
  75 +const inputText = ref('')
  76 +const sending = ref(false)
  77 +const streaming = ref(false)
  78 +const streamContent = ref('')
  79 +const thinkingText = ref('')
  80 +const messageListRef = ref(null)
  81 +const inputRef = ref(null)
  82 +
  83 +// Format Markdown
  84 +function renderMarkdown(text) {
  85 + try { return marked.parse(text || '', { breaks: true, gfm: true }) }
  86 + catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
  87 +}
  88 +
  89 +async function handleSend() {
  90 + const text = inputText.value.trim()
  91 + if (!text || sending.value) return
  92 + messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() })
  93 + inputText.value = ''
  94 + if (inputRef.value) inputRef.value.style.height = 'auto'
  95 +
  96 + sending.value = true
  97 + streaming.value = false
  98 + streamContent.value = ''
  99 + thinkingText.value = '🤖 调度中...'
  100 + scrollToBottom()
  101 +
  102 + try {
  103 + const r = await fetch(`/api/agent/${agentId}/chat`, {
  104 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  105 + body: JSON.stringify({ message: text, conversationId: activeConvId.value }),
  106 + })
  107 + const reader = r.body.getReader()
  108 + const decoder = new TextDecoder()
  109 + let buffer = '', lastEventType = ''
  110 +
  111 + while (true) {
  112 + const { done, value } = await reader.read()
  113 + if (done) break
  114 + buffer += decoder.decode(value, { stream: true })
  115 + const lines = buffer.split('\n')
  116 + buffer = lines.pop() || ''
  117 + for (const line of lines) {
  118 + if (line.startsWith('event: ')) lastEventType = line.slice(7)
  119 + else if (line.startsWith('data: ')) {
  120 + try { handleEvent(lastEventType, JSON.parse(line.slice(6))) } catch {}
  121 + }
  122 + }
  123 + }
  124 +
  125 + if (streamContent.value) {
  126 + messages.value.push({ role: 'assistant', content: streamContent.value, timestamp: new Date().toISOString() })
  127 + }
  128 + } catch (err) {
  129 + messages.value.push({ role: 'assistant', content: `❌ 错误: ${err.message}` })
  130 + } finally {
  131 + sending.value = false; streaming.value = false; streamContent.value = ''; thinkingText.value = ''
  132 + }
  133 +}
  134 +
  135 +function handleEvent(type, data) {
  136 + if (type === 'session' && data.conversationId) {
  137 + activeConvId.value = data.conversationId
  138 + }
  139 + if (type === 'thinking') {
  140 + thinkingText.value = data.message || '系统思考中...'
  141 + }
  142 + if (type === 'agent_output') {
  143 + streaming.value = true
  144 + streamContent.value += (data.message || '')
  145 + scrollToBottom()
  146 + }
  147 + if (type === 'tool_call') {
  148 + thinkingText.value = data.message || `🔧 ${data.tool}`
  149 + }
  150 +}
  151 +
  152 +async function handleStop() {
  153 + if (!activeConvId.value) return
  154 + try { await fetch(`/api/agent/${agentId}/chat/${activeConvId.value}/stop`, { method: 'POST' }) } catch(e) {}
  155 + sending.value = false; streaming.value = false
  156 + messages.value.push({ role: 'assistant', content: `*[已终止]*` })
  157 + scrollToBottom()
  158 +}
  159 +
  160 +function handleKeydown(e) {
  161 + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
  162 + e.preventDefault(); handleSend()
  163 + }
  164 +}
  165 +
  166 +function autoResize() {
  167 + const el = inputRef.value; if (!el) return;
  168 + el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'
  169 +}
  170 +
  171 +function scrollToBottom() {
  172 + nextTick(() => {
  173 + if (messageListRef.value) messageListRef.value.scrollTop = messageListRef.value.scrollHeight
  174 + })
  175 +}
  176 +
  177 +watch([messages, streamContent, thinkingText], () => scrollToBottom(), { deep: true })
  178 +</script>
  179 +
  180 +<style scoped>
  181 +.master-drawer {
  182 + position: fixed;
  183 + right: -360px;
  184 + top: 41px; /* below nav */
  185 + bottom: 0;
  186 + width: 360px;
  187 + background: var(--bg-secondary);
  188 + border-left: 1px solid var(--border-primary);
  189 + box-shadow: -4px 0 24px rgba(0,0,0,0.1);
  190 + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  191 + z-index: 95;
  192 + display: flex;
  193 + flex-direction: column;
  194 +}
  195 +.master-drawer.is-open { right: 0; }
  196 +
  197 +.drawer-toggle {
  198 + position: absolute;
  199 + left: -48px;
  200 + top: 100px;
  201 + width: 48px;
  202 + height: 48px;
  203 + background: var(--bg-secondary);
  204 + border: 1px solid var(--border-primary);
  205 + border-right: none;
  206 + border-radius: 12px 0 0 12px;
  207 + display: flex;
  208 + flex-direction: column;
  209 + align-items: center;
  210 + justify-content: center;
  211 + cursor: pointer;
  212 + box-shadow: -4px 4px 12px rgba(0,0,0,0.05);
  213 + transition: background 0.2s;
  214 +}
  215 +.drawer-toggle:hover { background: var(--bg-surface-hover); }
  216 +.pulse-icon { font-size: 20px; animation: float 3s ease-in-out infinite; }
  217 +@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-3px)} }
  218 +.toggle-text { font-size: 9px; font-weight: 700; color: var(--text-tertiary); margin-top: 2px; }
  219 +
  220 +.drawer-content {
  221 + flex: 1;
  222 + display: flex;
  223 + flex-direction: column;
  224 + overflow: hidden;
  225 +}
  226 +
  227 +.drawer-header {
  228 + padding: 16px;
  229 + border-bottom: 1px solid var(--border-primary);
  230 + display: flex;
  231 + justify-content: space-between;
  232 + align-items: center;
  233 + background: var(--bg-primary);
  234 +}
  235 +.header-left { display: flex; align-items: center; gap: 8px; }
  236 +.master-icon { font-size: 20px; filter: drop-shadow(0 0 8px rgba(16,185,129,0.5)); }
  237 +.drawer-header h3 { font-size: 15px; font-weight: 700; margin: 0; color: var(--text-primary); }
  238 +.close-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 16px; }
  239 +
  240 +.chat-main {
  241 + flex: 1;
  242 + overflow-y: auto;
  243 + padding: 16px;
  244 + display: flex;
  245 + flex-direction: column;
  246 + background: var(--bg-primary);
  247 +}
  248 +.empty-chat { text-align: center; margin-top: 60px; color: var(--text-muted); }
  249 +.empty-icon { font-size: 40px; margin-bottom: 16px; opacity: 0.5; }
  250 +
  251 +.message-list { display: flex; flex-direction: column; gap: 16px; }
  252 +.msg-row { display: flex; gap: 10px; max-width: 95%; }
  253 +.msg-user { align-self: flex-end; flex-direction: row-reverse; }
  254 +.msg-assistant { align-self: flex-start; }
  255 +.msg-avatar { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
  256 +.avatar-user { background: var(--avatar-user-bg); }
  257 +.avatar-assistant { background: var(--avatar-assistant-bg); }
  258 +.msg-body { flex: 1; min-width: 0; }
  259 +.msg-content { padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.6; }
  260 +.msg-user .msg-content { background: var(--msg-user-bg); border: 1px solid var(--msg-user-border); color: var(--text-primary); }
  261 +.msg-assistant .msg-content { background: var(--msg-assistant-bg); border: 1px solid var(--msg-assistant-border); color: var(--text-secondary); }
  262 +.msg-content :deep(pre) { background: var(--bg-code); padding: 10px; border-radius: 8px; overflow-x: auto; font-size: 11px; margin: 6px 0; }
  263 +.msg-content :deep(p) { margin: 0 0 6px; } .msg-content :deep(p:last-child) { margin: 0; }
  264 +
  265 +.thinking-row { display: flex; align-items: center; gap: 8px; padding: 10px 0; }
  266 +.thinking-pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: glow 1.5s infinite; }
  267 +@keyframes glow { 0%,100%{opacity:1} 50%{opacity:0.3} }
  268 +.thinking-text { font-size: 12px; color: var(--text-muted); font-family: monospace; }
  269 +.typing-indicator span { display: inline-block; width: 4px; height: 4px; border-radius: 50%; background: var(--text-muted); margin: 0 2px; animation: bounce 1.4s infinite; }
  270 +@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-4px)} }
  271 +
  272 +.chat-input-area { padding: 16px; border-top: 1px solid var(--border-primary); background: var(--bg-secondary); display: flex; gap: 10px; align-items: flex-end; }
  273 +.msg-textarea { flex: 1; border: 1px solid var(--border-primary); outline: none; background: var(--bg-input); color: var(--text-primary); font-size: 13px; line-height: 1.5; resize: none; padding: 10px 12px; border-radius: 10px; max-height: 120px; transition: border 0.2s; }
  274 +.msg-textarea:focus { border-color: var(--accent); background: var(--bg-input-focus); }
  275 +.send-btn { width: 40px; height: 40px; border-radius: 10px; border: none; font-size: 16px; cursor: pointer; transition: all 0.2s; flex-shrink: 0; background: var(--bg-input); color: var(--text-muted); }
  276 +.send-btn.ready { background: linear-gradient(135deg,var(--accent),var(--accent-light)); color: #fff; box-shadow: 0 4px 12px rgba(16,185,129,0.3); }
  277 +.send-btn.ready:hover { transform: translateY(-2px); }
  278 +</style>
... ...
web/src/composables/useCompanies.js 0 → 100644
  1 +import { ref, watch } from 'vue'
  2 +import axios from 'axios'
  3 +
  4 +const companies = ref([])
  5 +const hasLoaded = ref(false)
  6 +const activeCompanyId = ref(localStorage.getItem('lemon_active_company') || null)
  7 +
  8 +watch(activeCompanyId, (newVal) => {
  9 + if (newVal) localStorage.setItem('lemon_active_company', newVal)
  10 + else localStorage.removeItem('lemon_active_company')
  11 +})
  12 +
  13 +export function useCompanies() {
  14 + async function loadCompanies() {
  15 + try {
  16 + if (hasLoaded.value) return companies.value
  17 + const { data } = await axios.get('/api/company/list')
  18 + companies.value = data
  19 + hasLoaded.value = true
  20 + return data
  21 + } catch (e) {
  22 + console.error(e)
  23 + return []
  24 + }
  25 + }
  26 +
  27 + function addCompany(c) {
  28 + companies.value.push(c)
  29 + }
  30 +
  31 + function setActiveCompany(id) {
  32 + activeCompanyId.value = id
  33 + }
  34 +
  35 + return { companies, loadCompanies, addCompany, hasLoaded, activeCompanyId, setActiveCompany }
  36 +}
... ...
web/src/router/index.js
... ... @@ -3,7 +3,7 @@ import { createRouter, createWebHistory } from &#39;vue-router&#39;
3 3 const routes = [
4 4 {
5 5 path: '/',
6   - redirect: '/agent/agent-creator'
  6 + redirect: '/company'
7 7 },
8 8 {
9 9 path: '/agents/:agentId?',
... ... @@ -16,9 +16,21 @@ const routes = [
16 16 props: true,
17 17 },
18 18 {
  19 + path: '/:id/agents/:agentId?/:convId?',
  20 + name: 'CompanyAgentChat',
  21 + component: () => import('@/views/AgentChat.vue'),
  22 + props: true,
  23 + },
  24 + {
19 25 path: '/company',
20   - name: 'CompanyDashboard',
  26 + name: 'CompanyList',
  27 + component: () => import('@/views/CompanyDashboard.vue'),
  28 + },
  29 + {
  30 + path: '/:id/:tab(agents|pipelines|goals|sop|okr|team)',
  31 + name: 'CompanyDetail',
21 32 component: () => import('@/views/CompanyDashboard.vue'),
  33 + props: true
22 34 },
23 35 {
24 36 path: '/okr',
... ...
web/src/views/AgentChat.vue
... ... @@ -7,17 +7,20 @@
7 7 <aside class="agent-sidebar">
8 8 <div class="col-header">🏢 Agents</div>
9 9 <div class="agent-list">
10   - <div
11   - v-for="a in agentList" :key="a.id"
12   - class="agent-card" :class="{ active: agentId === a.id }"
13   - @click="agentId = a.id; switchAgent()"
14   - >
15   - <span class="agent-card-emoji">{{ a.emoji || '🤖' }}</span>
16   - <div class="agent-card-info">
17   - <div class="agent-card-name">{{ a.name }}</div>
18   - <div class="agent-card-type">{{ a.type }}</div>
  10 + <template v-for="(agents, compId) in groupedAgents" :key="compId">
  11 + <div class="agent-group-title">{{ compId === 'system' ? '💻 系统 Agent' : `🏢 ${compId}` }}</div>
  12 + <div
  13 + v-for="a in agents" :key="a.id"
  14 + class="agent-card" :class="{ active: agentId === a.id }"
  15 + @click="agentId = a.id; switchAgent()"
  16 + >
  17 + <span class="agent-card-emoji">{{ a.emoji || '🤖' }}</span>
  18 + <div class="agent-card-info">
  19 + <div class="agent-card-name">{{ a.name }}</div>
  20 + <div class="agent-card-type">{{ a.type }}</div>
  21 + </div>
19 22 </div>
20   - </div>
  23 + </template>
21 24 </div>
22 25 </aside>
23 26  
... ... @@ -226,7 +229,7 @@
226 229 </template>
227 230  
228 231 <script setup>
229   -import { ref, nextTick, watch, onMounted } from 'vue'
  232 +import { ref, computed, nextTick, watch, onMounted } from 'vue'
230 233 import { useRoute, useRouter } from 'vue-router'
231 234 import AppNav from '@/components/AppNav.vue'
232 235 import FileNode from '@/components/FileNode.vue'
... ... @@ -234,8 +237,31 @@ import { marked } from &#39;marked&#39;
234 237  
235 238 const route = useRoute()
236 239 const router = useRouter()
237   -const agentId = ref(route.params.agentId || 'agent-creator')
  240 +const agentId = ref(route.params.agentId || '')
238 241 const agentList = ref([])
  242 +
  243 +const groupedAgents = computed(() => {
  244 + const groups = {}
  245 + agentList.value.forEach(a => {
  246 + const comp = a.companyId || 'system'
  247 + // If we are in a company context, filter out other companies
  248 + if (route.params.id && comp !== route.params.id) return
  249 +
  250 + if (!groups[comp]) groups[comp] = []
  251 + groups[comp].push(a)
  252 + })
  253 +
  254 + // 确保 system 永远在最上面排第一
  255 + const sortedKeys = Object.keys(groups).sort((a,b) => {
  256 + if (a === 'system') return -1;
  257 + if (b === 'system') return 1;
  258 + return a.localeCompare(b);
  259 + })
  260 +
  261 + const sortedGroups = {}
  262 + sortedKeys.forEach(k => { sortedGroups[k] = groups[k] })
  263 + return sortedGroups
  264 +})
239 265 const activeTab = ref('chat') // 'chat' | 'detail'
240 266  
241 267 const agentInfo = ref({ name: 'Agent Creator', emoji: '🏗️', type: 'meta', status: 'ready', greeting: '', bootstrapFiles: [] })
... ... @@ -283,11 +309,32 @@ const previewContent = ref(null)
283 309 async function loadAgentList() {
284 310 try {
285 311 const r = await fetch('/api/agent/list')
286   - if (r.ok) agentList.value = await r.json()
  312 + if (r.ok) {
  313 + agentList.value = await r.json()
  314 +
  315 + // Auto-select first available agent if none or invalid is selected
  316 + const currentValid = agentList.value.some(a => a.id === agentId.value)
  317 + if (!agentId.value || !currentValid) {
  318 + const grpKey = route.params.id || 'system'
  319 + const avail = groupedAgents.value[grpKey]
  320 + if (avail && avail.length > 0) {
  321 + agentId.value = avail[0].id
  322 + // Update URL without triggering reload
  323 + if (route.params.id) {
  324 + router.replace({ params: { id: route.params.id, agentId: agentId.value }})
  325 + } else {
  326 + router.replace({ params: { agentId: agentId.value }})
  327 + }
  328 + } else if (!route.params.id) {
  329 + agentId.value = 'agent-creator'
  330 + }
  331 + }
  332 + }
287 333 } catch (e) { console.error('Failed to load agent list', e) }
288 334 }
289 335  
290 336 async function loadAgentInfo() {
  337 + if (!agentId.value) return
291 338 try {
292 339 const r = await fetch(`/api/agent/${agentId.value}/info`)
293 340 if (r.ok) agentInfo.value = await r.json()
... ... @@ -295,6 +342,7 @@ async function loadAgentInfo() {
295 342 }
296 343  
297 344 async function loadConversations() {
  345 + if (!agentId.value) return
298 346 try {
299 347 const r = await fetch(`/api/agent/${agentId.value}/conversations`)
300 348 if (r.ok) conversations.value = await r.json()
... ... @@ -302,6 +350,7 @@ async function loadConversations() {
302 350 }
303 351  
304 352 async function loadConversation(convId) {
  353 + if (!agentId.value) return
305 354 try {
306 355 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`)
307 356 if (r.ok) {
... ... @@ -315,6 +364,7 @@ async function loadConversation(convId) {
315 364 }
316 365  
317 366 async function loadFileTree() {
  367 + if (!agentId.value) return
318 368 try {
319 369 const r = await fetch(`/api/agent/${agentId.value}/files`)
320 370 if (r.ok) fileTree.value = await r.json()
... ... @@ -322,6 +372,7 @@ async function loadFileTree() {
322 372 }
323 373  
324 374 async function previewFile(filePath) {
  375 + if (!agentId.value) return
325 376 selectedFile.value = filePath
326 377 // If in chat mode, switch to detail to show preview
327 378 if (activeTab.value === 'chat') activeTab.value = 'detail'
... ... @@ -341,7 +392,12 @@ function newConversation() {
341 392 subAgents.value = {}
342 393 subLogList.value = []
343 394 acpLog.value = []
344   - router.replace({ params: { agentId: agentId.value, convId: undefined }})
  395 +
  396 + if (route.params.id) {
  397 + router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: undefined }})
  398 + } else {
  399 + router.replace({ params: { agentId: agentId.value, convId: undefined }})
  400 + }
345 401 }
346 402  
347 403 async function switchAgent() {
... ... @@ -352,12 +408,15 @@ async function switchAgent() {
352 408 await loadAgentInfo()
353 409 await loadConversations()
354 410 await loadFileTree()
355   - router.replace({ params: { agentId: agentId.value, convId: undefined }})
356 411 }
357 412  
358 413 async function switchConversation(convId) {
359 414 activeConvId.value = convId
360   - router.replace({ params: { agentId: agentId.value, convId } })
  415 + if (route.params.id) {
  416 + router.replace({ params: { id: route.params.id, agentId: agentId.value, convId } })
  417 + } else {
  418 + router.replace({ params: { agentId: agentId.value, convId } })
  419 + }
361 420 await loadConversation(convId)
362 421 scrollToBottom()
363 422 }
... ... @@ -368,7 +427,7 @@ function quickSend(text) { inputText.value = text; handleSend() }
368 427  
369 428 async function handleSend() {
370 429 const text = inputText.value.trim()
371   - if (!text || sending.value) return
  430 + if (!text || sending.value || !agentId.value) return
372 431 messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() })
373 432 inputText.value = ''
374 433 if (inputRef.value) inputRef.value.style.height = 'auto'
... ... @@ -439,7 +498,11 @@ function handleEvent(type, data) {
439 498 case 'session':
440 499 if (data.conversationId && !activeConvId.value) {
441 500 activeConvId.value = data.conversationId
442   - router.replace({ params: { agentId: agentId.value, convId: data.conversationId } })
  501 + if (route.params.id) {
  502 + router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: data.conversationId } })
  503 + } else {
  504 + router.replace({ params: { agentId: agentId.value, convId: data.conversationId } })
  505 + }
443 506 const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)
444 507 if (!existsInList) {
445 508 conversations.value.unshift({ conversationId: data.conversationId, title: messages.value.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' })
... ... @@ -502,6 +565,15 @@ watch([messages, streamContent], () =&gt; scrollToBottom(), { deep: true })
502 565 // Load file tree when switching to detail tab
503 566 watch(activeTab, async (tab) => { if (tab === 'detail' && !fileTree.value) await loadFileTree() })
504 567  
  568 +// Catch inter-company navigations or direct parameter changes
  569 +watch(() => route.params.id, async (newId, oldId) => {
  570 + if (newId !== oldId) {
  571 + agentId.value = ''
  572 + await loadAgentList()
  573 + await switchAgent()
  574 + }
  575 +})
  576 +
505 577 onMounted(async () => {
506 578 await loadAgentList()
507 579 await loadAgentInfo()
... ... @@ -548,6 +620,7 @@ onMounted(async () =&gt; {
548 620 background: var(--bg-overlay); display: flex; flex-direction: column;
549 621 }
550 622 .agent-list { flex: 1; overflow-y: auto; padding: 6px; }
  623 +.agent-group-title { font-size: 10px; font-weight: 700; color: var(--text-tertiary); padding: 8px 6px 4px; text-transform: uppercase; }
551 624 .agent-card {
552 625 display: flex; align-items: center; gap: 8px;
553 626 padding: 7px 10px; border-radius: 8px; cursor: pointer;
... ...
web/src/views/CompanyDashboard.vue
1 1 <template>
2 2 <div class="company-dashboard">
3   - <!-- ═══ Top Bar ═══ -->
4   - <header class="top-bar">
5   - <div class="top-bar-left">
6   - <span class="logo">🏢</span>
7   - <h1 class="title">Company Dashboard</h1>
8   - </div>
9   - <div class="top-bar-right">
10   - <button class="btn-create" @click="showCreateModal = true">
11   - <span>+</span> 创建公司
12   - </button>
13   - <button class="btn-templates" @click="showTemplates = !showTemplates">
14   - 📦 模板库 ({{ templates.length }})
15   - </button>
16   - </div>
17   - </header>
18   -
19   - <!-- ═══ Company List ═══ -->
20   - <section class="company-grid" v-if="!selectedCompany">
21   - <div
22   - v-for="company in companies"
23   - :key="company.id"
24   - class="company-card"
25   - :class="[`status-${company.status}`]"
26   - @click="selectCompany(company.id)"
27   - >
28   - <div class="card-header">
29   - <span class="card-emoji">🏢</span>
30   - <span class="card-status" :class="company.status">{{ statusLabel(company.status) }}</span>
31   - </div>
32   - <h3 class="card-name">{{ company.name }}</h3>
33   - <div class="card-meta">
34   - <span>👥 {{ company.agentCount }} 人</span>
35   - <span v-if="company.ceo">👔 {{ company.ceo }}</span>
36   - </div>
37   - </div>
38   -
39   - <!-- Empty state -->
40   - <div v-if="companies.length === 0" class="empty-state">
  3 + <AppNav />
  4 +<!-- Removed top action bar -->
  5 + <!-- ═══ Empty State (No Companies) ═══ -->
  6 + <section class="company-empty" v-if="companies.length === 0">
  7 + <div class="empty-state-card">
41 8 <span class="empty-icon">🏗️</span>
42   - <p>还没有任何公司</p>
43   - <button class="btn-create" @click="showCreateModal = true">创建第一家公司</button>
  9 + <h2>自治架构观测站</h2>
  10 + <p>没有任何正在运行中的公司或工作舱。</p>
  11 + <p style="color: var(--accent); font-weight: 600;">请使用右侧侧边栏召出「MasterBot」,用自然语言指示它去建立您的第一家公司。</p>
44 12 </div>
45 13 </section>
46 14  
47 15 <!-- ═══ Company Detail ═══ -->
48 16 <section class="company-detail" v-if="selectedCompany">
49 17 <div class="detail-header">
50   - <button class="btn-back" @click="selectedCompany = null">← 返回列表</button>
51   - <div class="detail-title-row">
52   - <h2>{{ selectedCompany.name }}</h2>
53   - <span class="card-status" :class="selectedCompany.status">{{ statusLabel(selectedCompany.status) }}</span>
  18 + <div class="company-switcher-wrapper">
  19 + <div class="company-switcher" @click.stop="showDropdown = !showDropdown">
  20 + <span class="switcher-icon">🏢</span>
  21 + <h2 class="switcher-name">{{ selectedCompany.name }}</h2>
  22 + <span class="switcher-arrow">▼</span>
  23 + </div>
  24 + <div class="switcher-dropdown" v-if="showDropdown">
  25 + <div class="dropdown-scroll">
  26 + <div
  27 + v-for="c in companies" :key="c.id"
  28 + class="switcher-item" :class="{active: c.id === selectedCompany.id}"
  29 + @click="goToCompany(c.id); showDropdown = false">
  30 + <span class="item-icon">🏢</span> {{ c.name }}
  31 + </div>
  32 + </div>
  33 + <div class="switcher-divider"></div>
  34 + <div class="switcher-item create-btn" style="cursor:default; border:none; opacity: 0.6;">
  35 + <span class="item-icon">🤖</span> 请呼叫 MasterBot 创建
  36 + </div>
  37 + </div>
  38 + </div>
  39 +
  40 + <div class="detail-header-actions" style="margin-left: auto;">
  41 + <button class="btn-templates" @click="showTemplates = !showTemplates" style="border: none; background: transparent;">
  42 + 📦 模板库 ({{ templates.length }})
  43 + </button>
54 44 </div>
55   - <p class="detail-desc">{{ selectedCompany.description || '暂无描述' }}</p>
56 45 </div>
  46 + <p class="detail-desc" style="margin-left:8px;">{{ selectedCompany.description || '暂无描述' }}</p>
57 47  
58   - <!-- Tabs -->
59   - <div class="tabs">
60   - <button
61   - v-for="tab in tabs"
62   - :key="tab.key"
63   - :class="{ active: activeTab === tab.key }"
64   - @click="switchTab(tab.key)"
65   - >
66   - {{ tab.icon }} {{ tab.label }}
67   - </button>
68   - </div>
  48 +<!-- Tabs moved to AppNav -->
69 49  
70 50 <!-- Tab: Agents -->
71 51 <div v-if="activeTab === 'agents'" class="tab-content">
... ... @@ -162,48 +142,6 @@
162 142 </div>
163 143 </section>
164 144  
165   - <!-- ═══ Create Company Modal ═══ -->
166   - <div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
167   - <div class="modal">
168   - <h3>🏢 创建新公司</h3>
169   - <label>公司 ID (英文)</label>
170   - <input v-model="newCompany.id" placeholder="media-co" />
171   - <label>公司名称</label>
172   - <input v-model="newCompany.name" placeholder="柠檬自媒体" />
173   - <label>公司使命</label>
174   - <textarea v-model="newCompany.description" placeholder="描述公司的使命..." rows="3"></textarea>
175   - <div class="modal-actions">
176   - <button class="btn-cancel" @click="showCreateModal = false">取消</button>
177   - <button class="btn-create" @click="createCompany" :disabled="!newCompany.id || !newCompany.name">创建</button>
178   - </div>
179   - </div>
180   - </div>
181   -
182   - <!-- ═══ Hire Modal ═══ -->
183   - <div v-if="showHireModal" class="modal-overlay" @click.self="showHireModal = false">
184   - <div class="modal">
185   - <h3>👔 招聘 Agent</h3>
186   - <label>角色 ID</label>
187   - <input v-model="hireForm.role" placeholder="content-writer" />
188   - <label>角色名称</label>
189   - <input v-model="hireForm.name" placeholder="内容写手" />
190   - <label>类型</label>
191   - <select v-model="hireForm.type">
192   - <option value="skill">skill (执行层)</option>
193   - <option value="cxo">cxo (管理层)</option>
194   - <option value="role">role (职能层)</option>
195   - </select>
196   - <div class="modal-actions">
197   - <button class="btn-cancel" @click="showHireModal = false">取消</button>
198   - <button class="btn-create" @click="hireAgent" :disabled="!hireForm.role">招聘</button>
199   - </div>
200   - <div v-if="hireResult" class="hire-result" :class="hireResult.status">
201   - {{ hireResult.status === 'hired_from_template' ? '✅ 从模板招聘成功' :
202   - hireResult.status === 'training' ? '🎓 骨架已创建,需要训练' :
203   - hireResult.status === 'already_exists' ? '⚠️ 已存在' : hireResult.status }}
204   - </div>
205   - </div>
206   - </div>
207 145  
208 146 <!-- ═══ Templates Panel ═══ -->
209 147 <div v-if="showTemplates" class="templates-panel">
... ... @@ -226,10 +164,17 @@
226 164 </template>
227 165  
228 166 <script setup>
229   -import { ref, onMounted } from 'vue'
  167 +import { ref, onMounted, watch, onUnmounted } from 'vue'
  168 +import { useRouter, useRoute } from 'vue-router'
230 169 import axios from 'axios'
  170 +import AppNav from '@/components/AppNav.vue'
  171 +import { useCompanies } from '@/composables/useCompanies'
  172 +
  173 +const router = useRouter()
  174 +const route = useRoute()
  175 +
  176 +const { companies, loadCompanies, activeCompanyId } = useCompanies()
231 177  
232   -const companies = ref([])
233 178 const selectedCompany = ref(null)
234 179 const companyAgents = ref([])
235 180 const pipelines = ref([])
... ... @@ -238,19 +183,8 @@ const selectedPipeline = ref(null)
238 183 const goals = ref([])
239 184 const templates = ref([])
240 185 const activeTab = ref('agents')
241   -const showCreateModal = ref(false)
242   -const showHireModal = ref(false)
243 186 const showTemplates = ref(false)
244   -
245   -const tabs = [
246   - { key: 'agents', icon: '👥', label: '团队' },
247   - { key: 'pipelines', icon: '⚙️', label: '流水线' },
248   - { key: 'goals', icon: '🎯', label: 'OKR' },
249   -]
250   -
251   -const newCompany = ref({ id: '', name: '', description: '' })
252   -const hireForm = ref({ role: '', name: '', type: 'skill' })
253   -const hireResult = ref(null)
  187 +const showDropdown = ref(false)
254 188  
255 189 function statusLabel(status) {
256 190 const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' }
... ... @@ -262,11 +196,12 @@ function agentEmoji(type) {
262 196 return map[type] || '🤖'
263 197 }
264 198  
265   -async function loadCompanies() {
266   - try {
267   - const { data } = await axios.get('/api/company/list')
268   - companies.value = data
269   - } catch (e) { console.error(e) }
  199 +async function initPage() {
  200 + await loadCompanies()
  201 + if (route.path === '/company' && companies.value.length > 0) {
  202 + const target = activeCompanyId.value || companies.value[0].id
  203 + router.replace(`/${target}/agents`)
  204 + }
270 205 }
271 206  
272 207 async function loadTemplates() {
... ... @@ -276,15 +211,44 @@ async function loadTemplates() {
276 211 } catch (e) { console.error(e) }
277 212 }
278 213  
279   -async function selectCompany(id) {
  214 +async function loadCompanyData(id, tab) {
280 215 try {
281   - const { data } = await axios.get(`/api/company/${id}`)
282   - selectedCompany.value = data
283   - activeTab.value = 'agents'
284   - await loadCompanyAgents(id)
  216 + if (!selectedCompany.value || selectedCompany.value.id !== id) {
  217 + const { data } = await axios.get(`/api/company/${id}`)
  218 + selectedCompany.value = data
  219 + }
  220 + activeTab.value = tab || 'agents'
  221 +
  222 + if (activeTab.value === 'agents') await loadCompanyAgents(id)
  223 + if (activeTab.value === 'pipelines') await loadPipelines(id)
  224 + if (activeTab.value === 'goals') await loadGoals(id)
285 225 } catch (e) { console.error(e) }
286 226 }
287 227  
  228 +function goToCompany(id) {
  229 + router.push(`/${id}/agents`)
  230 +}
  231 +
  232 +// goToList is no longer used, dropdown replaces it
  233 +
  234 +function goToTab(key) {
  235 + if (!selectedCompany.value) return
  236 + router.push(`/${selectedCompany.value.id}/${key}`)
  237 +}
  238 +
  239 +watch(() => route.params, async (params) => {
  240 + if (route.name === 'CompanyDetail' && params.id) {
  241 + await loadCompanyData(params.id, params.tab)
  242 + } else if (route.path === '/company') {
  243 + if (companies.value.length > 0) {
  244 + const target = activeCompanyId.value || companies.value[0].id
  245 + router.replace(`/${target}/agents`)
  246 + } else {
  247 + selectedCompany.value = null
  248 + }
  249 + }
  250 +}, { immediate: true })
  251 +
288 252 async function loadCompanyAgents(id) {
289 253 try {
290 254 const { data } = await axios.get(`/api/company/${id}/agents`)
... ... @@ -317,37 +281,21 @@ async function loadGoals(id) {
317 281 } catch (e) { console.error(e) }
318 282 }
319 283  
320   -function switchTab(key) {
321   - activeTab.value = key
322   - const id = selectedCompany.value?.id
323   - if (!id) return
324   - if (key === 'agents') loadCompanyAgents(id)
325   - if (key === 'pipelines') loadPipelines(id)
326   - if (key === 'goals') loadGoals(id)
327   -}
328   -
329   -async function createCompany() {
330   - try {
331   - await axios.post('/api/company/create', newCompany.value)
332   - showCreateModal.value = false
333   - newCompany.value = { id: '', name: '', description: '' }
334   - await loadCompanies()
335   - } catch (e) { alert(e.response?.data?.error || e.message) }
336   -}
337   -
338   -async function hireAgent() {
339   - try {
340   - const { data } = await axios.post(`/api/company/${selectedCompany.value.id}/hire`, hireForm.value)
341   - hireResult.value = data
342   - await loadCompanyAgents(selectedCompany.value.id)
343   - setTimeout(() => { hireResult.value = null }, 3000)
344   - } catch (e) { alert(e.response?.data?.error || e.message) }
345   -}
  284 +// switchTab removed, handled by goToTab and router watcher
346 285  
347 286 onMounted(() => {
348 287 loadCompanies()
349 288 loadTemplates()
  289 + document.addEventListener('click', closeDropdown)
350 290 })
  291 +
  292 +onUnmounted(() => {
  293 + document.removeEventListener('click', closeDropdown)
  294 +})
  295 +
  296 +function closeDropdown() {
  297 + showDropdown.value = false
  298 +}
351 299 </script>
352 300  
353 301 <style scoped>
... ... @@ -357,23 +305,7 @@ onMounted(() =&gt; {
357 305 background: var(--bg-primary);
358 306 }
359 307  
360   -/* ═══ Top Bar ═══ */
361   -.top-bar {
362   - display: flex;
363   - justify-content: space-between;
364   - align-items: center;
365   - padding: 16px 32px;
366   - border-bottom: 1px solid var(--border-primary);
367   - background: var(--bg-secondary);
368   - backdrop-filter: blur(20px);
369   - position: sticky; top: 0; z-index: 100;
370   -}
371   -.top-bar-left { display: flex; align-items: center; gap: 12px; }
372   -.logo { font-size: 28px; }
373   -.title { font-size: 20px; font-weight: 700; color: var(--text-primary); }
374   -.top-bar-right { display: flex; gap: 12px; }
375   -
376   -/* ═══ Buttons ═══ */
  308 +/* Removed separate Action Bar classes, moved to detail-header */
377 309 .btn-create {
378 310 background: linear-gradient(135deg, var(--accent), #059669);
379 311 color: #fff; border: none; padding: 8px 20px;
... ... @@ -382,15 +314,13 @@ onMounted(() =&gt; {
382 314 display: flex; align-items: center; gap: 6px;
383 315 }
384 316 .btn-create:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(16,185,129,0.3); }
385   -.btn-create:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
386 317  
387 318 .btn-templates {
388   - background: var(--bg-surface); color: var(--text-secondary);
389   - border: 1px solid var(--border-primary); padding: 8px 16px;
  319 + color: var(--text-secondary); padding: 8px 16px;
390 320 border-radius: 10px; font-size: 13px; cursor: pointer;
391 321 transition: all 0.2s;
392 322 }
393   -.btn-templates:hover { background: var(--bg-surface-hover); }
  323 +.btn-templates:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
394 324  
395 325 .btn-sm {
396 326 background: var(--accent-bg); color: var(--accent);
... ... @@ -471,22 +401,6 @@ onMounted(() =&gt; {
471 401 .detail-title-row h2 { font-size: 24px; font-weight: 800; }
472 402 .detail-desc { color: var(--text-tertiary); font-size: 14px; }
473 403  
474   -/* ═══ Tabs ═══ */
475   -.tabs {
476   - display: flex; gap: 4px;
477   - border-bottom: 1px solid var(--border-primary);
478   - margin-bottom: 24px;
479   -}
480   -.tabs button {
481   - background: none; border: none; padding: 12px 20px;
482   - font-size: 14px; font-weight: 500; cursor: pointer;
483   - color: var(--text-tertiary); border-bottom: 2px solid transparent;
484   - transition: all 0.2s;
485   -}
486   -.tabs button:hover { color: var(--text-primary); }
487   -.tabs button.active {
488   - color: var(--accent); border-bottom-color: var(--accent);
489   -}
490 404  
491 405 .tab-content { animation: fadeIn 0.3s ease; }
492 406 @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
... ... @@ -639,4 +553,60 @@ onMounted(() =&gt; {
639 553 .tpl-meta { display: flex; gap: 10px; font-size: 11px; color: var(--text-muted); }
640 554 .tpl-type { background: var(--bg-surface-hover); padding: 2px 8px; border-radius: 6px; text-transform: uppercase; }
641 555 .tpl-score { color: var(--yellow-text); }
  556 +
  557 +/* ═══ Company Switcher Header ═══ */
  558 +.detail-header {
  559 + padding: 16px 20px;
  560 + border-bottom: 1px solid var(--border-primary);
  561 + display: flex;
  562 + align-items: center;
  563 + position: relative;
  564 + height: 64px;
  565 +}
  566 +.company-switcher-wrapper {
  567 + position: relative;
  568 +}
  569 +.company-switcher {
  570 + display: flex; align-items: center; gap: 8px;
  571 + cursor: pointer; padding: 6px 12px; border-radius: 8px;
  572 + transition: all 0.2s; user-select: none; margin-left: -12px;
  573 +}
  574 +.company-switcher:hover { background: var(--bg-surface-hover); }
  575 +.switcher-icon { font-size: 20px; }
  576 +.switcher-name { font-size: 18px; font-weight: 700; margin: 0; color: var(--text-primary); }
  577 +.switcher-arrow { font-size: 10px; color: var(--text-muted); margin-left: 4px; }
  578 +
  579 +.switcher-dropdown {
  580 + position: absolute; top: calc(100% + 4px); left: 0; width: 220px;
  581 + background: var(--bg-secondary); border: 1px solid var(--border-primary);
  582 + border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);
  583 + z-index: 100;
  584 + display: flex; flex-direction: column; overflow: hidden;
  585 + animation: dropIn 0.2s cubic-bezier(0.1, 0.9, 0.2, 1);
  586 +}
  587 +@keyframes dropIn { from{opacity:0;transform:translateY(-10px) scale(0.95)} to{opacity:1;transform:translateY(0) scale(1)} }
  588 +.dropdown-scroll { max-height: 240px; overflow-y: auto; padding: 6px; }
  589 +.switcher-item {
  590 + display: flex; align-items: center; gap: 8px;
  591 + padding: 10px 12px; font-size: 13px; font-weight: 600; color: var(--text-secondary);
  592 + border-radius: 6px; cursor: pointer; transition: all 0.15s;
  593 +}
  594 +.switcher-item:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
  595 +.switcher-item.active { background: var(--accent-bg); color: var(--accent-light); margin-bottom: 2px;}
  596 +.item-icon { font-size: 14px; opacity: 0.8; }
  597 +.switcher-divider { height: 1px; background: var(--border-primary); margin: 0; }
  598 +.switcher-item.create-btn { padding: 12px; color: var(--accent-light); margin: 4px; border: 1px dashed var(--border-active); justify-content: center;}
  599 +.switcher-item.create-btn:hover { background: var(--accent-bg); color: var(--accent-light); }
  600 +
  601 +.detail-desc {
  602 + font-size: 13px; color: var(--text-secondary); padding: 0 20px 10px;
  603 + border-bottom: 1px solid var(--border-primary);
  604 +}
  605 +
  606 +/* ═══ Empty State ═══ */
  607 +.company-empty { display: flex; align-items: center; justify-content: center; min-height: calc(100vh - 100px); width: 100%;}
  608 +.empty-state-card { background: var(--bg-surface); padding: 40px; border-radius: 16px; border: 1px dashed var(--border-active); text-align: center; max-width: 400px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
  609 +.empty-state-card h2 { font-size: 20px; font-weight: 700; margin: 16px 0 8px; color: var(--text-primary); }
  610 +.empty-state-card p { font-size: 13px; color: var(--text-muted); margin-bottom: 24px; line-height: 1.6; }
  611 +.empty-icon { font-size: 54px; opacity: 0.9; animation: float 3s infinite ease-in-out; display: inline-block; filter: drop-shadow(0 4px 8px rgba(16,185,129,0.3)); }
642 612 </style>
... ...