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