Commit 7a5fb5f349c417a0e23d2cff085549a1b55e105a
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
Showing
18 changed files
with
1222 additions
and
352 deletions
CLAUDE.md
| @@ -87,3 +87,12 @@ npm run skills:sync # 同步 Skill 到 CLI 配置目录 | @@ -87,3 +87,12 @@ npm run skills:sync # 同步 Skill 到 CLI 配置目录 | ||
| 87 | - `start.sh` 一键启动: 同步 Skill → 编译前端 → 启动 Server → 启动 Bot | 87 | - `start.sh` 一键启动: 同步 Skill → 编译前端 → 启动 Server → 启动 Bot |
| 88 | - Claude CLI 使用 `--dangerously-skip-permissions` 自动批准模式 | 88 | - Claude CLI 使用 `--dangerously-skip-permissions` 自动批准模式 |
| 89 | - Skill 目录在 `data/skills/`(代码仓库外部),通过 `config.skillsDataDir` 配置 | 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,8 +5,8 @@ | ||
| 5 | 5 | ||
| 6 | ## 职责 | 6 | ## 职责 |
| 7 | 1. **全局统筹**:接收用户指令,理解意图,将大任务通过 `dispatch_task` 分解给 `ceo`, `agent-creator` 等下级进行执行。 | 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 | - **CEO Agent** (👔): 负责业务梳理、委派、战略目标监控。这是你的左膀右臂。 | 12 | - **CEO Agent** (👔): 负责业务梳理、委派、战略目标监控。这是你的左膀右臂。 |
| @@ -14,30 +14,20 @@ | @@ -14,30 +14,20 @@ | ||
| 14 | - **innovator-001** (🧬): 负责 FDE 流水线进化和重训任何质量不及格的 Agent。 | 14 | - **innovator-001** (🧬): 负责 FDE 流水线进化和重训任何质量不及格的 Agent。 |
| 15 | - **系统代码与环境** (💻): 你有读取和修改 `LemonBoss` 整个代码工程(Node.js, Vue)的全部系统权限。 | 15 | - **系统代码与环境** (💻): 你有读取和修改 `LemonBoss` 整个代码工程(Node.js, Vue)的全部系统权限。 |
| 16 | 16 | ||
| 17 | -## 如何分发任务 | ||
| 18 | -当你需要让下级 Agent 执行任务时,使用 curl 调用 dispatch API: | 17 | +当你需要让下级 Agent 执行任务时,优先并在 `<thought>` 后使用 `lemon.js` CLI 调用: |
| 19 | 18 | ||
| 20 | ```bash | 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 | - 读取你自己的 plan.md: `cat plan.md` — 查看所有子任务状态 | 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 | ## Never Rules(禁止行为) | 28 | ## Never Rules(禁止行为) |
| 39 | - **不要逃避系统级 Debug**:如果用户告诉你"面板报错"或"状态读不出来",严禁回复"我无法访问底层",你必须使用 bash 命令去探索 `server/` 系统代码,直接查找、排障并提供解决方案。 | 29 | - **不要逃避系统级 Debug**:如果用户告诉你"面板报错"或"状态读不出来",严禁回复"我无法访问底层",你必须使用 bash 命令去探索 `server/` 系统代码,直接查找、排障并提供解决方案。 |
| 40 | -- 对于纯粹的人事招聘和子业务,不要越过 `ceo` 和 `agent-creator` 去手工作业,保持宏观委派。 | 30 | +- 对于纯粹的人事招聘和子业务,不要越过对应公司的 `ceo` 和全系统的 `agent-creator` 去手工作业,保持宏观委派。**下达命令时切记加上前缀,不要写错,例如必定是 `to: "lemonv:ceo"`。** |
| 41 | - **🚫 绝对禁止「请示式回复」** — 这是你最容易犯的错误!以下是禁止的回复模式: | 31 | - **🚫 绝对禁止「请示式回复」** — 这是你最容易犯的错误!以下是禁止的回复模式: |
| 42 | - ❌ "需要我立即分发这些催促任务吗?" | 32 | - ❌ "需要我立即分发这些催促任务吗?" |
| 43 | - ❌ "下一步行动:催促 innovator 启动 xxx,需要我执行吗?" | 33 | - ❌ "下一步行动:催促 innovator 启动 xxx,需要我执行吗?" |
| @@ -46,6 +36,22 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ | @@ -46,6 +36,22 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ | ||
| 46 | - ❌ 列出一堆行动项然后等用户说"好的去做吧" | 36 | - ❌ 列出一堆行动项然后等用户说"好的去做吧" |
| 47 | - **🚫 绝对禁止「分析完不动手」** — 禁止只做分析报告而不执行 dispatch | 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 | ## Always Rules(强制行为) | 55 | ## Always Rules(强制行为) |
| 50 | - **⚡ 收到指令 = 立即执行,不请示**。老板的话就是最终批准,你直接 `curl dispatch` 然后告诉老板"已办"。 | 56 | - **⚡ 收到指令 = 立即执行,不请示**。老板的话就是最终批准,你直接 `curl dispatch` 然后告诉老板"已办"。 |
| 51 | - 业务任务 → 拆解 → **立即** dispatch 给 `ceo` 或 `agent-creator` → 汇报 "✅ 已分发 taskId: xxx"。 | 57 | - 业务任务 → 拆解 → **立即** dispatch 给 `ceo` 或 `agent-creator` → 汇报 "✅ 已分发 taskId: xxx"。 |
| @@ -63,3 +69,29 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ | @@ -63,3 +69,29 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ | ||
| 63 | 3. ✅ 已 dispatch 协调 innovator 执行 CEO 核心能力训练 (taskId: T-xxx) | 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 | ```bash | 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 | ```bash | 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 | ```bash | 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 | ```bash | 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 | ```bash | 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 | ```bash | 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 | ```bash | 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 | ## 底层系统开发工具 (System Development Tools) | 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) => { | @@ -66,6 +66,18 @@ app.use((req, res, next) => { | ||
| 66 | express.json()(req, res, next); | 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 | // ─── API 路由 ─── | 81 | // ─── API 路由 ─── |
| 70 | app.use('/api/claude-chat', require('./routes/claude-chat')); | 82 | app.use('/api/claude-chat', require('./routes/claude-chat')); |
| 71 | app.use('/api/config', require('./routes/config')); | 83 | app.use('/api/config', require('./routes/config')); |
server/routes/agent-chat.js
| @@ -25,6 +25,14 @@ const systemConfig = require('../config'); | @@ -25,6 +25,14 @@ const systemConfig = require('../config'); | ||
| 25 | const router = express.Router(); | 25 | const router = express.Router(); |
| 26 | 26 | ||
| 27 | const DATA_DIR = path.resolve(__dirname, '../../data/agents'); | 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 | const goalsService = new GoalsService(); | 36 | const goalsService = new GoalsService(); |
| 29 | // CONV_ROOT is now dynamically evaluated per-agent based on DATA_DIR. | 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,7 +110,7 @@ async function executeMetaCreator(task, parentAgentId, convId, sendSSE, callId) | ||
| 102 | sendSSE('sub_agent', { event: 'chunk', callId, agent: 'meta_creator', chunk: `📝 Agent ID: ${agentName}\n📝 显示名称: ${displayName}\n` }); | 110 | sendSSE('sub_agent', { event: 'chunk', callId, agent: 'meta_creator', chunk: `📝 Agent ID: ${agentName}\n📝 显示名称: ${displayName}\n` }); |
| 103 | 111 | ||
| 104 | // Step 2: 读取 knowhow 和 benchmark(如果有) | 112 | // Step 2: 读取 knowhow 和 benchmark(如果有) |
| 105 | - const parentDir = path.join(DATA_DIR, parentAgentId); | 113 | + const parentDir = getAgentDir(parentAgentId); |
| 106 | const workspaceDir = path.join(parentDir, 'workspace', agentName); | 114 | const workspaceDir = path.join(parentDir, 'workspace', agentName); |
| 107 | let knowhow = ''; | 115 | let knowhow = ''; |
| 108 | let benchmark = ''; | 116 | let benchmark = ''; |
| @@ -432,7 +440,7 @@ function setupSSE(res) { | @@ -432,7 +440,7 @@ function setupSSE(res) { | ||
| 432 | // ═══════════════════════════════════════ | 440 | // ═══════════════════════════════════════ |
| 433 | 441 | ||
| 434 | function getConvDir(agentId, convId) { | 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 | if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | 444 | if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); |
| 437 | return dir; | 445 | return dir; |
| 438 | } | 446 | } |
| @@ -440,7 +448,7 @@ function getConvDir(agentId, convId) { | @@ -440,7 +448,7 @@ function getConvDir(agentId, convId) { | ||
| 440 | /** 读写 meta.json */ | 448 | /** 读写 meta.json */ |
| 441 | function loadMeta(agentId, convId) { | 449 | function loadMeta(agentId, convId) { |
| 442 | try { | 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 | } catch { return null; } | 452 | } catch { return null; } |
| 445 | } | 453 | } |
| 446 | 454 | ||
| @@ -453,7 +461,7 @@ function saveMeta(agentId, convId, meta) { | @@ -453,7 +461,7 @@ function saveMeta(agentId, convId, meta) { | ||
| 453 | /** 读写 history.json — 有序消息列表 */ | 461 | /** 读写 history.json — 有序消息列表 */ |
| 454 | function loadHistory(agentId, convId) { | 462 | function loadHistory(agentId, convId) { |
| 455 | try { | 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 | } catch { return []; } | 465 | } catch { return []; } |
| 458 | } | 466 | } |
| 459 | 467 | ||
| @@ -479,7 +487,7 @@ function logMessage(agentId, convId, role, content) { | @@ -479,7 +487,7 @@ function logMessage(agentId, convId, role, content) { | ||
| 479 | } | 487 | } |
| 480 | 488 | ||
| 481 | function listConversations(agentId) { | 489 | function listConversations(agentId) { |
| 482 | - const agentConvDir = path.join(DATA_DIR, agentId, 'conversations'); | 490 | + const agentConvDir = path.join(getAgentDir(agentId), 'conversations'); |
| 483 | if (!fs.existsSync(agentConvDir)) return []; | 491 | if (!fs.existsSync(agentConvDir)) return []; |
| 484 | try { | 492 | try { |
| 485 | return fs.readdirSync(agentConvDir, { withFileTypes: true }) | 493 | return fs.readdirSync(agentConvDir, { withFileTypes: true }) |
| @@ -514,7 +522,7 @@ async function getOrCreateProtocol(agentId, convId) { | @@ -514,7 +522,7 @@ async function getOrCreateProtocol(agentId, convId) { | ||
| 514 | try { entry.protocol.disconnect(); } catch {} | 522 | try { entry.protocol.disconnect(); } catch {} |
| 515 | } | 523 | } |
| 516 | 524 | ||
| 517 | - const agentDir = path.join(DATA_DIR, agentId); | 525 | + const agentDir = getAgentDir(agentId); |
| 518 | const identity = loadIdentity(agentDir); | 526 | const identity = loadIdentity(agentDir); |
| 519 | const agentConfig = loadAgentConfig(agentDir); | 527 | const agentConfig = loadAgentConfig(agentDir); |
| 520 | 528 | ||
| @@ -588,56 +596,83 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { | @@ -588,56 +596,83 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { | ||
| 588 | 596 | ||
| 589 | router.get('/list', (req, res) => { | 597 | router.get('/list', (req, res) => { |
| 590 | try { | 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 | // ── Phase 2: 小脑方案固化 API ── | 678 | // ── Phase 2: 小脑方案固化 API ── |
| @@ -669,7 +704,7 @@ router.post('/:agentId/memory/daily-report', (req, res) => { | @@ -669,7 +704,7 @@ router.post('/:agentId/memory/daily-report', (req, res) => { | ||
| 669 | }); | 704 | }); |
| 670 | 705 | ||
| 671 | router.get('/:agentId/info', (req, res) => { | 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 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); | 708 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 674 | const config = loadAgentConfig(agentDir); const identity = loadIdentity(agentDir); | 709 | const config = loadAgentConfig(agentDir); const identity = loadIdentity(agentDir); |
| 675 | res.json({ id: config?.id || req.params.agentId, ...identity, type: config?.type, status: config?.status, | 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,7 +770,7 @@ function listSubAgentLogs(agentId, convId) { | ||
| 735 | router.delete('/:agentId/conversations/:convId', (req, res) => { | 770 | router.delete('/:agentId/conversations/:convId', (req, res) => { |
| 736 | const { agentId, convId } = req.params; | 771 | const { agentId, convId } = req.params; |
| 737 | try { | 772 | try { |
| 738 | - const dir = path.join(DATA_DIR, agentId, 'conversations', convId); | 773 | + const dir = path.join(getAgentDir(agentId), 'conversations', convId); |
| 739 | if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); | 774 | if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); |
| 740 | const key = `${agentId}:${convId}`; | 775 | const key = `${agentId}:${convId}`; |
| 741 | const entry = sessions.get(key); | 776 | const entry = sessions.get(key); |
| @@ -762,7 +797,7 @@ router.post('/:agentId/chat/:convId/stop', (req, res) => { | @@ -762,7 +797,7 @@ router.post('/:agentId/chat/:convId/stop', (req, res) => { | ||
| 762 | router.get('/:agentId/bootstrap/:filename', (req, res) => { | 797 | router.get('/:agentId/bootstrap/:filename', (req, res) => { |
| 763 | const allowed = ['agent.md','soul.md','identity.md','user.md','tools.md','heartbeat.md']; | 798 | const allowed = ['agent.md','soul.md','identity.md','user.md','tools.md','heartbeat.md']; |
| 764 | if (!allowed.includes(req.params.filename)) return res.status(400).json({ error: 'Invalid' }); | 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('/:agentId/bootstrap/:filename', (req, res) => { | @@ -771,7 +806,7 @@ router.get('/:agentId/bootstrap/:filename', (req, res) => { | ||
| 771 | 806 | ||
| 772 | // GET /api/agent/:agentId/files — 递归列出 Agent 目录文件树 | 807 | // GET /api/agent/:agentId/files — 递归列出 Agent 目录文件树 |
| 773 | router.get('/:agentId/files', (req, res) => { | 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 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); | 810 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 776 | 811 | ||
| 777 | const scanDir = (dir, relativePath = '') => { | 812 | const scanDir = (dir, relativePath = '') => { |
| @@ -810,8 +845,8 @@ router.get('/:agentId/file/*', (req, res) => { | @@ -810,8 +845,8 @@ router.get('/:agentId/file/*', (req, res) => { | ||
| 810 | const filePath = req.params[0]; | 845 | const filePath = req.params[0]; |
| 811 | if (!filePath) return res.status(400).json({ error: 'file path required' }); | 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 | return res.status(403).json({ error: 'Access denied' }); | 850 | return res.status(403).json({ error: 'Access denied' }); |
| 816 | } | 851 | } |
| 817 | if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'File not found' }); | 852 | if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'File not found' }); |
| @@ -831,12 +866,12 @@ router.get('/:agentId/file/*', (req, res) => { | @@ -831,12 +866,12 @@ router.get('/:agentId/file/*', (req, res) => { | ||
| 831 | 866 | ||
| 832 | // GET/PUT /api/agent/:agentId/todo | 867 | // GET/PUT /api/agent/:agentId/todo |
| 833 | router.get('/:agentId/todo', (req, res) => { | 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 | if (!fs.existsSync(fp)) return res.json({ content: '' }); | 870 | if (!fs.existsSync(fp)) return res.json({ content: '' }); |
| 836 | res.json({ content: fs.readFileSync(fp, 'utf-8') }); | 871 | res.json({ content: fs.readFileSync(fp, 'utf-8') }); |
| 837 | }); | 872 | }); |
| 838 | router.put('/:agentId/todo', (req, res) => { | 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 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); | 875 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 841 | fs.writeFileSync(path.join(agentDir, 'todo.md'), req.body.content || '', 'utf-8'); | 876 | fs.writeFileSync(path.join(agentDir, 'todo.md'), req.body.content || '', 'utf-8'); |
| 842 | res.json({ success: true }); | 877 | res.json({ success: true }); |
| @@ -844,12 +879,12 @@ router.put('/:agentId/todo', (req, res) => { | @@ -844,12 +879,12 @@ router.put('/:agentId/todo', (req, res) => { | ||
| 844 | 879 | ||
| 845 | // GET/PUT /api/agent/:agentId/plan | 880 | // GET/PUT /api/agent/:agentId/plan |
| 846 | router.get('/:agentId/plan', (req, res) => { | 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 | if (!fs.existsSync(fp)) return res.json({ content: '' }); | 883 | if (!fs.existsSync(fp)) return res.json({ content: '' }); |
| 849 | res.json({ content: fs.readFileSync(fp, 'utf-8') }); | 884 | res.json({ content: fs.readFileSync(fp, 'utf-8') }); |
| 850 | }); | 885 | }); |
| 851 | router.put('/:agentId/plan', (req, res) => { | 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 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); | 888 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 854 | fs.writeFileSync(path.join(agentDir, 'plan.md'), req.body.content || '', 'utf-8'); | 889 | fs.writeFileSync(path.join(agentDir, 'plan.md'), req.body.content || '', 'utf-8'); |
| 855 | res.json({ success: true }); | 890 | res.json({ success: true }); |
| @@ -857,14 +892,14 @@ router.put('/:agentId/plan', (req, res) => { | @@ -857,14 +892,14 @@ router.put('/:agentId/plan', (req, res) => { | ||
| 857 | 892 | ||
| 858 | // GET/PUT /api/agent/:agentId/cron — 定时任务声明 (cron.json) | 893 | // GET/PUT /api/agent/:agentId/cron — 定时任务声明 (cron.json) |
| 859 | router.get('/:agentId/cron', (req, res) => { | 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 | if (!fs.existsSync(fp)) return res.json([]); | 896 | if (!fs.existsSync(fp)) return res.json([]); |
| 862 | try { | 897 | try { |
| 863 | res.json(JSON.parse(fs.readFileSync(fp, 'utf-8'))); | 898 | res.json(JSON.parse(fs.readFileSync(fp, 'utf-8'))); |
| 864 | } catch { res.json([]); } | 899 | } catch { res.json([]); } |
| 865 | }); | 900 | }); |
| 866 | router.put('/:agentId/cron', (req, res) => { | 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 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); | 903 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 869 | const crons = req.body; | 904 | const crons = req.body; |
| 870 | if (!Array.isArray(crons)) return res.status(400).json({ error: 'Body must be an array of cron objects' }); | 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('/task/dispatch', async (req, res) => { | @@ -934,8 +969,8 @@ router.post('/task/dispatch', async (req, res) => { | ||
| 934 | const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true } = req.body; | 969 | const { from, to, task, priority = 'P1', planId, goalId, nodeId, autoExecute = true } = req.body; |
| 935 | if (!from || !to || !task) return res.status(400).json({ error: 'from, to, task are required' }); | 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 | if (!fs.existsSync(targetDir)) return res.status(404).json({ error: `Target agent "${to}" not found` }); | 974 | if (!fs.existsSync(targetDir)) return res.status(404).json({ error: `Target agent "${to}" not found` }); |
| 940 | if (!fs.existsSync(senderDir)) return res.status(404).json({ error: `Sender agent "${from}" not found` }); | 975 | if (!fs.existsSync(senderDir)) return res.status(404).json({ error: `Sender agent "${from}" not found` }); |
| 941 | 976 | ||
| @@ -1055,7 +1090,7 @@ router.post('/task/report', (req, res) => { | @@ -1055,7 +1090,7 @@ router.post('/task/report', (req, res) => { | ||
| 1055 | const safeSummary = (summary || '').replace(/\n/g, '<br>').replace(/\|/g, '|'); | 1090 | const safeSummary = (summary || '').replace(/\n/g, '<br>').replace(/\|/g, '|'); |
| 1056 | 1091 | ||
| 1057 | // 1. 更新执行者的 todo.md (标记完成) | 1092 | // 1. 更新执行者的 todo.md (标记完成) |
| 1058 | - const executorDir = path.join(DATA_DIR, from); | 1093 | + const executorDir = getAgentDir(from); |
| 1059 | if (fs.existsSync(executorDir)) { | 1094 | if (fs.existsSync(executorDir)) { |
| 1060 | const todoPath = path.join(executorDir, 'todo.md'); | 1095 | const todoPath = path.join(executorDir, 'todo.md'); |
| 1061 | if (fs.existsSync(todoPath)) { | 1096 | if (fs.existsSync(todoPath)) { |
| @@ -1070,7 +1105,7 @@ router.post('/task/report', (req, res) => { | @@ -1070,7 +1105,7 @@ router.post('/task/report', (req, res) => { | ||
| 1070 | } | 1105 | } |
| 1071 | 1106 | ||
| 1072 | // 2. 更新上级的 plan.md (标记完成) — 使用行级匹配,兼容各种格式 | 1107 | // 2. 更新上级的 plan.md (标记完成) — 使用行级匹配,兼容各种格式 |
| 1073 | - const senderDir = path.join(DATA_DIR, to); | 1108 | + const senderDir = getAgentDir(to); |
| 1074 | if (fs.existsSync(senderDir)) { | 1109 | if (fs.existsSync(senderDir)) { |
| 1075 | const planPath = path.join(senderDir, 'plan.md'); | 1110 | const planPath = path.join(senderDir, 'plan.md'); |
| 1076 | if (fs.existsSync(planPath)) { | 1111 | if (fs.existsSync(planPath)) { |
| @@ -1136,7 +1171,7 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -1136,7 +1171,7 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 1136 | if (!message) return res.status(400).json({ error: 'message is required' }); | 1171 | if (!message) return res.status(400).json({ error: 'message is required' }); |
| 1137 | 1172 | ||
| 1138 | const agentId = req.params.agentId; | 1173 | const agentId = req.params.agentId; |
| 1139 | - const agentDir = path.join(DATA_DIR, agentId); | 1174 | + const agentDir = getAgentDir(agentId); |
| 1140 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); | 1175 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 1141 | 1176 | ||
| 1142 | const convId = reqConvId || `conv-${Date.now().toString(36)}`; | 1177 | const convId = reqConvId || `conv-${Date.now().toString(36)}`; |
server/services/heartbeat-service.js
| @@ -4,6 +4,13 @@ const http = require('http'); | @@ -4,6 +4,13 @@ const http = require('http'); | ||
| 4 | const config = require('../config'); | 4 | const config = require('../config'); |
| 5 | 5 | ||
| 6 | const DATA_DIR = path.resolve(__dirname, '../../data/agents'); | 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 | class HeartbeatService { | 15 | class HeartbeatService { |
| 9 | constructor() { | 16 | constructor() { |
| @@ -44,7 +51,7 @@ class HeartbeatService { | @@ -44,7 +51,7 @@ class HeartbeatService { | ||
| 44 | */ | 51 | */ |
| 45 | _setupAgentHeartbeat(agentId) { | 52 | _setupAgentHeartbeat(agentId) { |
| 46 | try { | 53 | try { |
| 47 | - const configPath = path.join(DATA_DIR, agentId, 'config.json'); | 54 | + const configPath = path.join(getAgentDir(agentId), 'config.json'); |
| 48 | const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); | 55 | const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); |
| 49 | 56 | ||
| 50 | if (cfg.heartbeat && cfg.heartbeat.enabled === true) { | 57 | if (cfg.heartbeat && cfg.heartbeat.enabled === true) { |
server/services/memory-service.js
| @@ -16,6 +16,13 @@ const fs = require('fs'); | @@ -16,6 +16,13 @@ const fs = require('fs'); | ||
| 16 | const path = require('path'); | 16 | const path = require('path'); |
| 17 | 17 | ||
| 18 | const DATA_DIR = path.resolve(__dirname, '../../data/agents'); | 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 | const MAX_SHORT_TERM = 10; // 最多保留最近 N 个 session 摘要 | 26 | const MAX_SHORT_TERM = 10; // 最多保留最近 N 个 session 摘要 |
| 20 | const MAX_MEMORY_MD_SIZE = 3000; // memory.md 最大字符数 | 27 | const MAX_MEMORY_MD_SIZE = 3000; // memory.md 最大字符数 |
| 21 | const INJECT_SESSION_COUNT = 5; // prompt 注入最近 N 个 session | 28 | const INJECT_SESSION_COUNT = 5; // prompt 注入最近 N 个 session |
| @@ -36,7 +43,7 @@ const MAX_CEREBELLUM_INJECT = 2; // 最多注入 N 个小脑方案 | @@ -36,7 +43,7 @@ const MAX_CEREBELLUM_INJECT = 2; // 最多注入 N 个小脑方案 | ||
| 36 | */ | 43 | */ |
| 37 | function saveSessionSummary(agentId, convId, meta, lastOutput) { | 44 | function saveSessionSummary(agentId, convId, meta, lastOutput) { |
| 38 | try { | 45 | try { |
| 39 | - const agentDir = path.join(DATA_DIR, agentId); | 46 | + const agentDir = getAgentDir(agentId); |
| 40 | const shortTermDir = path.join(agentDir, 'memory', 'short-term'); | 47 | const shortTermDir = path.join(agentDir, 'memory', 'short-term'); |
| 41 | 48 | ||
| 42 | if (!fs.existsSync(shortTermDir)) { | 49 | if (!fs.existsSync(shortTermDir)) { |
| @@ -115,7 +122,7 @@ function saveSessionSummary(agentId, convId, meta, lastOutput) { | @@ -115,7 +122,7 @@ function saveSessionSummary(agentId, convId, meta, lastOutput) { | ||
| 115 | */ | 122 | */ |
| 116 | function loadRecentMemory(agentId) { | 123 | function loadRecentMemory(agentId) { |
| 117 | try { | 124 | try { |
| 118 | - const shortTermDir = path.join(DATA_DIR, agentId, 'memory', 'short-term'); | 125 | + const shortTermDir = path.join(getAgentDir(agentId), 'memory', 'short-term'); |
| 119 | if (!fs.existsSync(shortTermDir)) return ''; | 126 | if (!fs.existsSync(shortTermDir)) return ''; |
| 120 | 127 | ||
| 121 | // 按文件名倒序(时间戳降序)取最近 N 个 | 128 | // 按文件名倒序(时间戳降序)取最近 N 个 |
| @@ -154,7 +161,7 @@ function loadRecentMemory(agentId) { | @@ -154,7 +161,7 @@ function loadRecentMemory(agentId) { | ||
| 154 | function appendToMemoryMd(agentId, task, result, meta) { | 161 | function appendToMemoryMd(agentId, task, result, meta) { |
| 155 | if (!meta.stats?.toolCallCount || meta.stats.toolCallCount === 0) return; | 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 | let content = ''; | 165 | let content = ''; |
| 159 | try { | 166 | try { |
| 160 | content = fs.readFileSync(memoryPath, 'utf-8'); | 167 | content = fs.readFileSync(memoryPath, 'utf-8'); |
| @@ -205,7 +212,7 @@ function appendToMemoryMd(agentId, task, result, meta) { | @@ -205,7 +212,7 @@ function appendToMemoryMd(agentId, task, result, meta) { | ||
| 205 | */ | 212 | */ |
| 206 | function retrieveRelevantPlans(agentId, taskText) { | 213 | function retrieveRelevantPlans(agentId, taskText) { |
| 207 | try { | 214 | try { |
| 208 | - const agentDir = path.join(DATA_DIR, agentId); | 215 | + const agentDir = getAgentDir(agentId); |
| 209 | const index = loadHippocampusIndex(agentDir); | 216 | const index = loadHippocampusIndex(agentDir); |
| 210 | if (index.length === 0) return ''; | 217 | if (index.length === 0) return ''; |
| 211 | 218 | ||
| @@ -292,7 +299,7 @@ function retrieveRelevantPlans(agentId, taskText) { | @@ -292,7 +299,7 @@ function retrieveRelevantPlans(agentId, taskText) { | ||
| 292 | */ | 299 | */ |
| 293 | function saveCerebellumPlan(agentId, plan) { | 300 | function saveCerebellumPlan(agentId, plan) { |
| 294 | try { | 301 | try { |
| 295 | - const agentDir = path.join(DATA_DIR, agentId); | 302 | + const agentDir = getAgentDir(agentId); |
| 296 | const cerebellumDir = path.join(agentDir, 'memory', 'cerebellum'); | 303 | const cerebellumDir = path.join(agentDir, 'memory', 'cerebellum'); |
| 297 | 304 | ||
| 298 | if (!fs.existsSync(cerebellumDir)) { | 305 | if (!fs.existsSync(cerebellumDir)) { |
| @@ -490,7 +497,7 @@ function extractTask(title, agentId, convId) { | @@ -490,7 +497,7 @@ function extractTask(title, agentId, convId) { | ||
| 490 | // 如果 title 太短,尝试从 messages.log 读取 | 497 | // 如果 title 太短,尝试从 messages.log 读取 |
| 491 | if (task.length < 10) { | 498 | if (task.length < 10) { |
| 492 | try { | 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 | const log = fs.readFileSync(logPath, 'utf-8'); | 501 | const log = fs.readFileSync(logPath, 'utf-8'); |
| 495 | const userMsg = log.match(/\[USER\]\n([\s\S]*?)(?:\n\[AGENT\]|$)/); | 502 | const userMsg = log.match(/\[USER\]\n([\s\S]*?)(?:\n\[AGENT\]|$)/); |
| 496 | if (userMsg) task = userMsg[1].trim().substring(0, 300); | 503 | if (userMsg) task = userMsg[1].trim().substring(0, 300); |
| @@ -629,7 +636,7 @@ function autoReflect(agentId, convId, meta, output) { | @@ -629,7 +636,7 @@ function autoReflect(agentId, convId, meta, output) { | ||
| 629 | return null; // 太简单的对话不反思 | 636 | return null; // 太简单的对话不反思 |
| 630 | } | 637 | } |
| 631 | 638 | ||
| 632 | - const agentDir = path.join(DATA_DIR, agentId); | 639 | + const agentDir = getAgentDir(agentId); |
| 633 | const task = extractTask(meta.title || '', agentId, convId); | 640 | const task = extractTask(meta.title || '', agentId, convId); |
| 634 | const isSuccess = meta.status === 'completed'; | 641 | const isSuccess = meta.status === 'completed'; |
| 635 | const isFailed = meta.status === 'failed'; | 642 | const isFailed = meta.status === 'failed'; |
| @@ -749,7 +756,7 @@ function writeReflectionLog(agentDir, reflection) { | @@ -749,7 +756,7 @@ function writeReflectionLog(agentDir, reflection) { | ||
| 749 | 756 | ||
| 750 | /** 更新 memory.md 的 "已验证模式" 和 "失败教训" 段 */ | 757 | /** 更新 memory.md 的 "已验证模式" 和 "失败教训" 段 */ |
| 751 | function updateMemoryMdSections(agentId, reflection) { | 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 | let content = ''; | 760 | let content = ''; |
| 754 | try { content = fs.readFileSync(memoryPath, 'utf-8'); } catch { return; } | 761 | try { content = fs.readFileSync(memoryPath, 'utf-8'); } catch { return; } |
| 755 | 762 | ||
| @@ -806,7 +813,7 @@ function updateMemoryMdSections(agentId, reflection) { | @@ -806,7 +813,7 @@ function updateMemoryMdSections(agentId, reflection) { | ||
| 806 | */ | 813 | */ |
| 807 | function consolidateMemory(agentId) { | 814 | function consolidateMemory(agentId) { |
| 808 | try { | 815 | try { |
| 809 | - const agentDir = path.join(DATA_DIR, agentId); | 816 | + const agentDir = getAgentDir(agentId); |
| 810 | const shortTermDir = path.join(agentDir, 'memory', 'short-term'); | 817 | const shortTermDir = path.join(agentDir, 'memory', 'short-term'); |
| 811 | const longTermDir = path.join(agentDir, 'memory', 'long-term'); | 818 | const longTermDir = path.join(agentDir, 'memory', 'long-term'); |
| 812 | 819 | ||
| @@ -899,7 +906,7 @@ function consolidateMemory(agentId) { | @@ -899,7 +906,7 @@ function consolidateMemory(agentId) { | ||
| 899 | */ | 906 | */ |
| 900 | function generateDailyReport(agentId) { | 907 | function generateDailyReport(agentId) { |
| 901 | try { | 908 | try { |
| 902 | - const agentDir = path.join(DATA_DIR, agentId); | 909 | + const agentDir = getAgentDir(agentId); |
| 903 | const shortTermDir = path.join(agentDir, 'memory', 'short-term'); | 910 | const shortTermDir = path.join(agentDir, 'memory', 'short-term'); |
| 904 | const dailyDir = path.join(agentDir, 'memory', 'daily'); | 911 | const dailyDir = path.join(agentDir, 'memory', 'daily'); |
| 905 | const reflectDir = path.join(agentDir, 'memory', 'reflections'); | 912 | const reflectDir = path.join(agentDir, 'memory', 'reflections'); |
server/src/core/agent-manager.js
| @@ -130,9 +130,18 @@ class AgentManager extends EventEmitter { | @@ -130,9 +130,18 @@ class AgentManager extends EventEmitter { | ||
| 130 | 130 | ||
| 131 | // Dynamic Hot-Reload Fallback (Local File-System First) | 131 | // Dynamic Hot-Reload Fallback (Local File-System First) |
| 132 | if (!agentConfig) { | 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 | // 1. Check if it's an independent Agent | 144 | // 1. Check if it's an independent Agent |
| 135 | - const agentDir = path.join(agentsRoot, agentName); | ||
| 136 | if (fs.existsSync(agentDir)) { | 145 | if (fs.existsSync(agentDir)) { |
| 137 | agentConfig = { | 146 | agentConfig = { |
| 138 | type: 'orchestrator', | 147 | type: 'orchestrator', |
server/src/core/prompt-builder.js
| @@ -13,6 +13,59 @@ class PromptBuilder { | @@ -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 | loadFile(filePath) { | 71 | loadFile(filePath) { |
| @@ -110,6 +163,9 @@ class PromptBuilder { | @@ -110,6 +163,9 @@ class PromptBuilder { | ||
| 110 | parts.push(`---\n\n## 当前任务\n\n${userTask}`); | 163 | parts.push(`---\n\n## 当前任务\n\n${userTask}`); |
| 111 | } | 164 | } |
| 112 | 165 | ||
| 166 | + // 在最终拼接前,注入上帝视角的环境感知 | ||
| 167 | + parts.unshift(this.getSystemStateXML()); | ||
| 168 | + | ||
| 113 | let result = parts.join('\n\n---\n\n'); | 169 | let result = parts.join('\n\n---\n\n'); |
| 114 | 170 | ||
| 115 | // 替换 {{AVAILABLE_AGENTS}} 占位符(可能在 SKILL.md 或 protocol 中) | 171 | // 替换 {{AVAILABLE_AGENTS}} 占位符(可能在 SKILL.md 或 protocol 中) |
web/src/App.vue
| 1 | <template> | 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 | </template> | 8 | </template> |
| 4 | 9 | ||
| 5 | <script setup> | 10 | <script setup> |
| 6 | import { useTheme } from '@/composables/useTheme' | 11 | import { useTheme } from '@/composables/useTheme' |
| 12 | +import MasterChatDrawer from '@/components/MasterChatDrawer.vue' | ||
| 7 | useTheme() | 13 | useTheme() |
| 8 | </script> | 14 | </script> |
| 9 | 15 | ||
| @@ -112,6 +118,18 @@ body { | @@ -112,6 +118,18 @@ body { | ||
| 112 | background: var(--bg-primary); | 118 | background: var(--bg-primary); |
| 113 | color: var(--text-primary); | 119 | color: var(--text-primary); |
| 114 | transition: background 0.3s, color 0.3s; | 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 | ::-webkit-scrollbar { width: 6px; } | 135 | ::-webkit-scrollbar { width: 6px; } |
web/src/components/AppNav.vue
| @@ -6,12 +6,49 @@ | @@ -6,12 +6,49 @@ | ||
| 6 | </div> | 6 | </div> |
| 7 | <div class="nav-links"> | 7 | <div class="nav-links"> |
| 8 | <router-link to="/agent/agent-creator" class="nav-link" :class="{ active: $route.path.startsWith('/agent') }"> | 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 | </router-link> | 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 | </div> | 50 | </div> |
| 51 | + | ||
| 15 | <div class="nav-right"> | 52 | <div class="nav-right"> |
| 16 | <button class="theme-btn" @click="cycleTheme" :title="`主题: ${themeLabel}`"> | 53 | <button class="theme-btn" @click="cycleTheme" :title="`主题: ${themeLabel}`"> |
| 17 | {{ themeIcon }} | 54 | {{ themeIcon }} |
| @@ -22,10 +59,17 @@ | @@ -22,10 +59,17 @@ | ||
| 22 | </template> | 59 | </template> |
| 23 | 60 | ||
| 24 | <script setup> | 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 | import { useTheme } from '@/composables/useTheme' | 64 | import { useTheme } from '@/composables/useTheme' |
| 65 | +import { useCompanies } from '@/composables/useCompanies' | ||
| 27 | 66 | ||
| 67 | +const route = useRoute() | ||
| 68 | +const router = useRouter() | ||
| 28 | const { themeMode, cycleTheme } = useTheme() | 69 | const { themeMode, cycleTheme } = useTheme() |
| 70 | +const { companies, loadCompanies, activeCompanyId, setActiveCompany } = useCompanies() | ||
| 71 | + | ||
| 72 | +const showDropdown = ref(false) | ||
| 29 | 73 | ||
| 30 | const themeIcon = computed(() => { | 74 | const themeIcon = computed(() => { |
| 31 | if (themeMode.value === 'light') return '☀️' | 75 | if (themeMode.value === 'light') return '☀️' |
| @@ -37,41 +81,97 @@ const themeLabel = computed(() => { | @@ -37,41 +81,97 @@ const themeLabel = computed(() => { | ||
| 37 | if (themeMode.value === 'dark') return '深色' | 81 | if (themeMode.value === 'dark') return '深色' |
| 38 | return '跟随系统' | 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 | </script> | 114 | </script> |
| 41 | 115 | ||
| 42 | <style scoped> | 116 | <style scoped> |
| 43 | .app-nav { | 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 | background: var(--bg-secondary); backdrop-filter: blur(20px); | 119 | background: var(--bg-secondary); backdrop-filter: blur(20px); |
| 46 | border-bottom: 1px solid var(--border-primary); | 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 | transition: background 0.3s; | 122 | transition: background 0.3s; |
| 49 | } | 123 | } |
| 50 | .nav-brand { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; } | 124 | .nav-brand { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; } |
| 51 | .nav-logo { font-size: 16px; } | 125 | .nav-logo { font-size: 16px; } |
| 52 | .nav-title { | 126 | .nav-title { |
| 53 | - font-size: 13px; font-weight: 700; | 127 | + font-size: 14px; font-weight: 800; |
| 54 | background: linear-gradient(135deg, var(--accent), var(--accent-light)); | 128 | background: linear-gradient(135deg, var(--accent), var(--accent-light)); |
| 55 | -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; | 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 | .nav-link { | 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 | .nav-link:hover { background: var(--bg-surface-hover); color: var(--text-secondary); } | 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 | .theme-btn { | 170 | .theme-btn { |
| 67 | width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border-primary); | 171 | width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border-primary); |
| 68 | background: var(--bg-surface); cursor: pointer; font-size: 14px; | 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 | .theme-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); } | 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 | </style> | 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, '<').replace(/>/g, '>').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 'vue-router' | @@ -3,7 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router' | ||
| 3 | const routes = [ | 3 | const routes = [ |
| 4 | { | 4 | { |
| 5 | path: '/', | 5 | path: '/', |
| 6 | - redirect: '/agent/agent-creator' | 6 | + redirect: '/company' |
| 7 | }, | 7 | }, |
| 8 | { | 8 | { |
| 9 | path: '/agents/:agentId?', | 9 | path: '/agents/:agentId?', |
| @@ -16,9 +16,21 @@ const routes = [ | @@ -16,9 +16,21 @@ const routes = [ | ||
| 16 | props: true, | 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 | path: '/company', | 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 | component: () => import('@/views/CompanyDashboard.vue'), | 32 | component: () => import('@/views/CompanyDashboard.vue'), |
| 33 | + props: true | ||
| 22 | }, | 34 | }, |
| 23 | { | 35 | { |
| 24 | path: '/okr', | 36 | path: '/okr', |
web/src/views/AgentChat.vue
| @@ -7,17 +7,20 @@ | @@ -7,17 +7,20 @@ | ||
| 7 | <aside class="agent-sidebar"> | 7 | <aside class="agent-sidebar"> |
| 8 | <div class="col-header">🏢 Agents</div> | 8 | <div class="col-header">🏢 Agents</div> |
| 9 | <div class="agent-list"> | 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 | </div> | 22 | </div> |
| 20 | - </div> | 23 | + </template> |
| 21 | </div> | 24 | </div> |
| 22 | </aside> | 25 | </aside> |
| 23 | 26 | ||
| @@ -226,7 +229,7 @@ | @@ -226,7 +229,7 @@ | ||
| 226 | </template> | 229 | </template> |
| 227 | 230 | ||
| 228 | <script setup> | 231 | <script setup> |
| 229 | -import { ref, nextTick, watch, onMounted } from 'vue' | 232 | +import { ref, computed, nextTick, watch, onMounted } from 'vue' |
| 230 | import { useRoute, useRouter } from 'vue-router' | 233 | import { useRoute, useRouter } from 'vue-router' |
| 231 | import AppNav from '@/components/AppNav.vue' | 234 | import AppNav from '@/components/AppNav.vue' |
| 232 | import FileNode from '@/components/FileNode.vue' | 235 | import FileNode from '@/components/FileNode.vue' |
| @@ -234,8 +237,31 @@ import { marked } from 'marked' | @@ -234,8 +237,31 @@ import { marked } from 'marked' | ||
| 234 | 237 | ||
| 235 | const route = useRoute() | 238 | const route = useRoute() |
| 236 | const router = useRouter() | 239 | const router = useRouter() |
| 237 | -const agentId = ref(route.params.agentId || 'agent-creator') | 240 | +const agentId = ref(route.params.agentId || '') |
| 238 | const agentList = ref([]) | 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 | const activeTab = ref('chat') // 'chat' | 'detail' | 265 | const activeTab = ref('chat') // 'chat' | 'detail' |
| 240 | 266 | ||
| 241 | const agentInfo = ref({ name: 'Agent Creator', emoji: '🏗️', type: 'meta', status: 'ready', greeting: '', bootstrapFiles: [] }) | 267 | const agentInfo = ref({ name: 'Agent Creator', emoji: '🏗️', type: 'meta', status: 'ready', greeting: '', bootstrapFiles: [] }) |
| @@ -283,11 +309,32 @@ const previewContent = ref(null) | @@ -283,11 +309,32 @@ const previewContent = ref(null) | ||
| 283 | async function loadAgentList() { | 309 | async function loadAgentList() { |
| 284 | try { | 310 | try { |
| 285 | const r = await fetch('/api/agent/list') | 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 | } catch (e) { console.error('Failed to load agent list', e) } | 333 | } catch (e) { console.error('Failed to load agent list', e) } |
| 288 | } | 334 | } |
| 289 | 335 | ||
| 290 | async function loadAgentInfo() { | 336 | async function loadAgentInfo() { |
| 337 | + if (!agentId.value) return | ||
| 291 | try { | 338 | try { |
| 292 | const r = await fetch(`/api/agent/${agentId.value}/info`) | 339 | const r = await fetch(`/api/agent/${agentId.value}/info`) |
| 293 | if (r.ok) agentInfo.value = await r.json() | 340 | if (r.ok) agentInfo.value = await r.json() |
| @@ -295,6 +342,7 @@ async function loadAgentInfo() { | @@ -295,6 +342,7 @@ async function loadAgentInfo() { | ||
| 295 | } | 342 | } |
| 296 | 343 | ||
| 297 | async function loadConversations() { | 344 | async function loadConversations() { |
| 345 | + if (!agentId.value) return | ||
| 298 | try { | 346 | try { |
| 299 | const r = await fetch(`/api/agent/${agentId.value}/conversations`) | 347 | const r = await fetch(`/api/agent/${agentId.value}/conversations`) |
| 300 | if (r.ok) conversations.value = await r.json() | 348 | if (r.ok) conversations.value = await r.json() |
| @@ -302,6 +350,7 @@ async function loadConversations() { | @@ -302,6 +350,7 @@ async function loadConversations() { | ||
| 302 | } | 350 | } |
| 303 | 351 | ||
| 304 | async function loadConversation(convId) { | 352 | async function loadConversation(convId) { |
| 353 | + if (!agentId.value) return | ||
| 305 | try { | 354 | try { |
| 306 | const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`) | 355 | const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`) |
| 307 | if (r.ok) { | 356 | if (r.ok) { |
| @@ -315,6 +364,7 @@ async function loadConversation(convId) { | @@ -315,6 +364,7 @@ async function loadConversation(convId) { | ||
| 315 | } | 364 | } |
| 316 | 365 | ||
| 317 | async function loadFileTree() { | 366 | async function loadFileTree() { |
| 367 | + if (!agentId.value) return | ||
| 318 | try { | 368 | try { |
| 319 | const r = await fetch(`/api/agent/${agentId.value}/files`) | 369 | const r = await fetch(`/api/agent/${agentId.value}/files`) |
| 320 | if (r.ok) fileTree.value = await r.json() | 370 | if (r.ok) fileTree.value = await r.json() |
| @@ -322,6 +372,7 @@ async function loadFileTree() { | @@ -322,6 +372,7 @@ async function loadFileTree() { | ||
| 322 | } | 372 | } |
| 323 | 373 | ||
| 324 | async function previewFile(filePath) { | 374 | async function previewFile(filePath) { |
| 375 | + if (!agentId.value) return | ||
| 325 | selectedFile.value = filePath | 376 | selectedFile.value = filePath |
| 326 | // If in chat mode, switch to detail to show preview | 377 | // If in chat mode, switch to detail to show preview |
| 327 | if (activeTab.value === 'chat') activeTab.value = 'detail' | 378 | if (activeTab.value === 'chat') activeTab.value = 'detail' |
| @@ -341,7 +392,12 @@ function newConversation() { | @@ -341,7 +392,12 @@ function newConversation() { | ||
| 341 | subAgents.value = {} | 392 | subAgents.value = {} |
| 342 | subLogList.value = [] | 393 | subLogList.value = [] |
| 343 | acpLog.value = [] | 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 | async function switchAgent() { | 403 | async function switchAgent() { |
| @@ -352,12 +408,15 @@ async function switchAgent() { | @@ -352,12 +408,15 @@ async function switchAgent() { | ||
| 352 | await loadAgentInfo() | 408 | await loadAgentInfo() |
| 353 | await loadConversations() | 409 | await loadConversations() |
| 354 | await loadFileTree() | 410 | await loadFileTree() |
| 355 | - router.replace({ params: { agentId: agentId.value, convId: undefined }}) | ||
| 356 | } | 411 | } |
| 357 | 412 | ||
| 358 | async function switchConversation(convId) { | 413 | async function switchConversation(convId) { |
| 359 | activeConvId.value = convId | 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 | await loadConversation(convId) | 420 | await loadConversation(convId) |
| 362 | scrollToBottom() | 421 | scrollToBottom() |
| 363 | } | 422 | } |
| @@ -368,7 +427,7 @@ function quickSend(text) { inputText.value = text; handleSend() } | @@ -368,7 +427,7 @@ function quickSend(text) { inputText.value = text; handleSend() } | ||
| 368 | 427 | ||
| 369 | async function handleSend() { | 428 | async function handleSend() { |
| 370 | const text = inputText.value.trim() | 429 | const text = inputText.value.trim() |
| 371 | - if (!text || sending.value) return | 430 | + if (!text || sending.value || !agentId.value) return |
| 372 | messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() }) | 431 | messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() }) |
| 373 | inputText.value = '' | 432 | inputText.value = '' |
| 374 | if (inputRef.value) inputRef.value.style.height = 'auto' | 433 | if (inputRef.value) inputRef.value.style.height = 'auto' |
| @@ -439,7 +498,11 @@ function handleEvent(type, data) { | @@ -439,7 +498,11 @@ function handleEvent(type, data) { | ||
| 439 | case 'session': | 498 | case 'session': |
| 440 | if (data.conversationId && !activeConvId.value) { | 499 | if (data.conversationId && !activeConvId.value) { |
| 441 | activeConvId.value = data.conversationId | 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 | const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId) | 506 | const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId) |
| 444 | if (!existsInList) { | 507 | if (!existsInList) { |
| 445 | 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' }) | 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], () => scrollToBottom(), { deep: true }) | @@ -502,6 +565,15 @@ watch([messages, streamContent], () => scrollToBottom(), { deep: true }) | ||
| 502 | // Load file tree when switching to detail tab | 565 | // Load file tree when switching to detail tab |
| 503 | watch(activeTab, async (tab) => { if (tab === 'detail' && !fileTree.value) await loadFileTree() }) | 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 | onMounted(async () => { | 577 | onMounted(async () => { |
| 506 | await loadAgentList() | 578 | await loadAgentList() |
| 507 | await loadAgentInfo() | 579 | await loadAgentInfo() |
| @@ -548,6 +620,7 @@ onMounted(async () => { | @@ -548,6 +620,7 @@ onMounted(async () => { | ||
| 548 | background: var(--bg-overlay); display: flex; flex-direction: column; | 620 | background: var(--bg-overlay); display: flex; flex-direction: column; |
| 549 | } | 621 | } |
| 550 | .agent-list { flex: 1; overflow-y: auto; padding: 6px; } | 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 | .agent-card { | 624 | .agent-card { |
| 552 | display: flex; align-items: center; gap: 8px; | 625 | display: flex; align-items: center; gap: 8px; |
| 553 | padding: 7px 10px; border-radius: 8px; cursor: pointer; | 626 | padding: 7px 10px; border-radius: 8px; cursor: pointer; |
web/src/views/CompanyDashboard.vue
| 1 | <template> | 1 | <template> |
| 2 | <div class="company-dashboard"> | 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 | <span class="empty-icon">🏗️</span> | 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 | </div> | 12 | </div> |
| 45 | </section> | 13 | </section> |
| 46 | 14 | ||
| 47 | <!-- ═══ Company Detail ═══ --> | 15 | <!-- ═══ Company Detail ═══ --> |
| 48 | <section class="company-detail" v-if="selectedCompany"> | 16 | <section class="company-detail" v-if="selectedCompany"> |
| 49 | <div class="detail-header"> | 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 | </div> | 44 | </div> |
| 55 | - <p class="detail-desc">{{ selectedCompany.description || '暂无描述' }}</p> | ||
| 56 | </div> | 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 | <!-- Tab: Agents --> | 50 | <!-- Tab: Agents --> |
| 71 | <div v-if="activeTab === 'agents'" class="tab-content"> | 51 | <div v-if="activeTab === 'agents'" class="tab-content"> |
| @@ -162,48 +142,6 @@ | @@ -162,48 +142,6 @@ | ||
| 162 | </div> | 142 | </div> |
| 163 | </section> | 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 | <!-- ═══ Templates Panel ═══ --> | 146 | <!-- ═══ Templates Panel ═══ --> |
| 209 | <div v-if="showTemplates" class="templates-panel"> | 147 | <div v-if="showTemplates" class="templates-panel"> |
| @@ -226,10 +164,17 @@ | @@ -226,10 +164,17 @@ | ||
| 226 | </template> | 164 | </template> |
| 227 | 165 | ||
| 228 | <script setup> | 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 | import axios from 'axios' | 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 | const selectedCompany = ref(null) | 178 | const selectedCompany = ref(null) |
| 234 | const companyAgents = ref([]) | 179 | const companyAgents = ref([]) |
| 235 | const pipelines = ref([]) | 180 | const pipelines = ref([]) |
| @@ -238,19 +183,8 @@ const selectedPipeline = ref(null) | @@ -238,19 +183,8 @@ const selectedPipeline = ref(null) | ||
| 238 | const goals = ref([]) | 183 | const goals = ref([]) |
| 239 | const templates = ref([]) | 184 | const templates = ref([]) |
| 240 | const activeTab = ref('agents') | 185 | const activeTab = ref('agents') |
| 241 | -const showCreateModal = ref(false) | ||
| 242 | -const showHireModal = ref(false) | ||
| 243 | const showTemplates = ref(false) | 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 | function statusLabel(status) { | 189 | function statusLabel(status) { |
| 256 | const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' } | 190 | const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' } |
| @@ -262,11 +196,12 @@ function agentEmoji(type) { | @@ -262,11 +196,12 @@ function agentEmoji(type) { | ||
| 262 | return map[type] || '🤖' | 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 | async function loadTemplates() { | 207 | async function loadTemplates() { |
| @@ -276,15 +211,44 @@ async function loadTemplates() { | @@ -276,15 +211,44 @@ async function loadTemplates() { | ||
| 276 | } catch (e) { console.error(e) } | 211 | } catch (e) { console.error(e) } |
| 277 | } | 212 | } |
| 278 | 213 | ||
| 279 | -async function selectCompany(id) { | 214 | +async function loadCompanyData(id, tab) { |
| 280 | try { | 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 | } catch (e) { console.error(e) } | 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 | async function loadCompanyAgents(id) { | 252 | async function loadCompanyAgents(id) { |
| 289 | try { | 253 | try { |
| 290 | const { data } = await axios.get(`/api/company/${id}/agents`) | 254 | const { data } = await axios.get(`/api/company/${id}/agents`) |
| @@ -317,37 +281,21 @@ async function loadGoals(id) { | @@ -317,37 +281,21 @@ async function loadGoals(id) { | ||
| 317 | } catch (e) { console.error(e) } | 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 | onMounted(() => { | 286 | onMounted(() => { |
| 348 | loadCompanies() | 287 | loadCompanies() |
| 349 | loadTemplates() | 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 | </script> | 299 | </script> |
| 352 | 300 | ||
| 353 | <style scoped> | 301 | <style scoped> |
| @@ -357,23 +305,7 @@ onMounted(() => { | @@ -357,23 +305,7 @@ onMounted(() => { | ||
| 357 | background: var(--bg-primary); | 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 | .btn-create { | 309 | .btn-create { |
| 378 | background: linear-gradient(135deg, var(--accent), #059669); | 310 | background: linear-gradient(135deg, var(--accent), #059669); |
| 379 | color: #fff; border: none; padding: 8px 20px; | 311 | color: #fff; border: none; padding: 8px 20px; |
| @@ -382,15 +314,13 @@ onMounted(() => { | @@ -382,15 +314,13 @@ onMounted(() => { | ||
| 382 | display: flex; align-items: center; gap: 6px; | 314 | display: flex; align-items: center; gap: 6px; |
| 383 | } | 315 | } |
| 384 | .btn-create:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(16,185,129,0.3); } | 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 | .btn-templates { | 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 | border-radius: 10px; font-size: 13px; cursor: pointer; | 320 | border-radius: 10px; font-size: 13px; cursor: pointer; |
| 391 | transition: all 0.2s; | 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 | .btn-sm { | 325 | .btn-sm { |
| 396 | background: var(--accent-bg); color: var(--accent); | 326 | background: var(--accent-bg); color: var(--accent); |
| @@ -471,22 +401,6 @@ onMounted(() => { | @@ -471,22 +401,6 @@ onMounted(() => { | ||
| 471 | .detail-title-row h2 { font-size: 24px; font-weight: 800; } | 401 | .detail-title-row h2 { font-size: 24px; font-weight: 800; } |
| 472 | .detail-desc { color: var(--text-tertiary); font-size: 14px; } | 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 | .tab-content { animation: fadeIn 0.3s ease; } | 405 | .tab-content { animation: fadeIn 0.3s ease; } |
| 492 | @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } | 406 | @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } |
| @@ -639,4 +553,60 @@ onMounted(() => { | @@ -639,4 +553,60 @@ onMounted(() => { | ||
| 639 | .tpl-meta { display: flex; gap: 10px; font-size: 11px; color: var(--text-muted); } | 553 | .tpl-meta { display: flex; gap: 10px; font-size: 11px; color: var(--text-muted); } |
| 640 | .tpl-type { background: var(--bg-surface-hover); padding: 2px 8px; border-radius: 6px; text-transform: uppercase; } | 554 | .tpl-type { background: var(--bg-surface-hover); padding: 2px 8px; border-radius: 6px; text-transform: uppercase; } |
| 641 | .tpl-score { color: var(--yellow-text); } | 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 | </style> | 612 | </style> |