Commit 599af2aa881cba0d6f520d56fb71125752acabcc
1 parent
411af4e5
feat(dream): REST/控制台入口
只做了这一件事,没有跟着做「dream 改成 org/work graph 来跑」——两者被 单独评估过:org/work graph 是单公司作用域(org graph=公司级 workflow, work graph=公司内某任务的节点图),dream 却是跨公司扫描,套进去要么破坏 作用域假设要么得新开图类型;且 graph 引擎自带的验收机制(goal-contract benchmark/test-script)判的是「产物过没过验收脚本」,对「经验提炼得对 不对」这种自由文本判断没有对应机制——不管套不套 graph 都得单独设计 rubric,是同一件事。用户明确只要 REST/控制台这一半。 ## REST(server/routes/dream.js,挂 /api/dream) 路由本身没有新业务逻辑,全部委托给已测过的 dream-service.js: GET /agents(总览)、GET /agents/:id(详情,含定义文件可写性 + 经验 审计)、POST /agents/:id/run(手动跑一次,dryRun 只看有没有新原料不 起 LLM)、POST /sweep + GET /sweep/status(全量扫描后台跑 + 轮询,内存 态并发锁防止连点两下)。 ## 控制台(web/src/views/DreamConsole.vue,路由 /dream) 一个表格 + 两个弹窗:本体列表(详情/跑一次/dry-run)+ 全量扫描按钮 (15 秒轮询状态)。导航栏加 🌙 Dream 入口。 ## 验证 server/tests/unit/dream-routes.test.js 4 例,monkeypatch 掉 dream-service 导出对象上的六个函数(不走真文件系统,不误扫真仓库 76 个本体),只验路由层自己的逻辑:端点接线、sweep 并发锁状态机。 真起服务器打了真数据:GET /agents/innovator-evolver 正确报 SKILL.md 因没进 git 历史而不可写、经验审计吐出上次真跑沉淀的 4 条 经验及原文;dry-run 正确报 no-new-material,没多花一次 LLM 调用。 浏览器里走了一遍列表→详情→dry-run,与直接打 API 结果一致。 955 个单测全绿。 文档:29-dream-design.md §5.7 记录实现与「为什么不做 graph 化」的 理由;28-followups.md / docs/README.md 同步更新 dream 现状。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
10 changed files
with
608 additions
and
24 deletions
CLAUDE.md
| ... | ... | @@ -109,6 +109,7 @@ npm run okr:migrate # goals → okr 存量迁移 |
| 109 | 109 | | `/ws/claude` | Claude 实时对话(仅 `index.js`) | |
| 110 | 110 | | `/api/health` `/api/plugins` | 健康检查 / 插件状态 | |
| 111 | 111 | | `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) | |
| 112 | +| `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/todo/system-design/29-dream-design.md`](docs/todo/system-design/29-dream-design.md) | | |
| 112 | 113 | |
| 113 | 114 | ### DevLoop 子系统 |
| 114 | 115 | ... | ... |
docs/README.md
| ... | ... | @@ -45,7 +45,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d |
| 45 | 45 | | [27-p3b-real-run-runway.md](./todo/system-design/27-p3b-real-run-runway.md) | P3 收尾之真跑跑道(从 mock 走到真 Claude CLI)— 📝 **只剩验收 9**。2026-08-20 第四次真跑(§六ter)验掉 2 与 8,验收 4 由零注入链路验掉,7 早已修好。验收 9 的准确状态是「产出端通了、消费端没等到那个形状」—— 需要一次 **org 层回退边指向 subgraph 节点**。⚠️ 这份文档里 ⛔/🟡 的段落多为**当时的现场记录**,读之前先看头部状态行 | |
| 46 | 46 | | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) | |
| 47 | 47 | | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | |
| 48 | -| [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(只有口述、无设计)、任务态清理(源码树无限增长)、`source_kind`/`legacy_path` 删列、三项「已实施未验证」。§五 是**已立起来的守卫清单**(别重复造,也别删) | | |
| 48 | +| [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | | |
| 49 | +| [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | |
| 49 | 50 | |
| 50 | 51 | ## todo/future/(暂缓,非当前重心) |
| 51 | 52 | ... | ... |
docs/todo/system-design/28-followups.md
| ... | ... | @@ -20,23 +20,32 @@ |
| 20 | 20 | |
| 21 | 21 | ### 1.1 dream 机制 |
| 22 | 22 | |
| 23 | -**状态**:用户口述过要解决什么问题和大致形态,**设计尚未开始**。 | |
| 24 | - | |
| 25 | -已知的只有这些: | |
| 26 | -- 定时跑的另一套逻辑,整理**所有 agent 在所有公司**的 history 与 memory,**显式提炼**后沉淀到 skill memory | |
| 27 | -- 它是「经验汇流」的正解;错误版本是「每轮对话自动把原文写进共享 `SKILL.md`」—— | |
| 28 | - 方向对,但直接灌原始对话等于把 A 公司的机密喂给 B 公司 | |
| 29 | - (这个错误版本真的发生过,是 28 号第一轮验收挖出的最严重 blocker,已修) | |
| 30 | - | |
| 31 | -**尚未设计的**:触发时机与频率、提炼规则、去敏策略(什么算「不含公司特定信息」)、 | |
| 32 | -产物落点与格式、失败与重试、提炼质量怎么评。 | |
| 23 | +**状态(2026-08-20 更新)**:设计已成文并已实现,见 | |
| 24 | +[29-dream-design.md](./29-dream-design.md)。四条取舍(去敏判据 / 沉淀落点 / 触发时机 / | |
| 25 | +质量评估)均已拍板,五步推进顺序 + 定时挂载 + 定义文件写入均已落地, | |
| 26 | +`innovator-evolver` 上真 CLI 跑通一次(40 条原料 → 4 条抽象经验,8 条引用全部核对回原文)。 | |
| 27 | + | |
| 28 | +已实现的: | |
| 29 | +- 定时(每日一次,串行,挂在 `server/index.js` 的 `DISABLE_BACKGROUND_AGENTS` 早退之后) | |
| 30 | + 整理**所有 agent 在所有公司**的原始记录,**显式提炼**(`dream-distiller`)+ | |
| 31 | + 独立去敏判定(`dream-screener`)后沉淀 | |
| 32 | +- 落点四选四:本体 `memory/experience.md`(经验积累)+ `SKILL.md` / `soul.md` / `agent.md` | |
| 33 | + (受管区块,三道闸门缺一不写,见 29 号文档 §5.6) | |
| 34 | +- 引用真伪自动查(编造出处直接拒)+ `auditDistilled()` 把结论与原料原文并排列出, | |
| 35 | + 几秒内可回查 | |
| 36 | + | |
| 37 | +- REST(`/api/dream/*`)+ 控制台(`/dream`,导航栏 `🌙 Dream`):查总览/详情、手动跑单个本体(含 dry-run)、 | |
| 38 | + 触发并轮询全量扫描(2026-08-21) | |
| 39 | + | |
| 40 | +**仍未做的**:「结论对不对」的判据(机器判不了,只能靠人抽查,`auditDistilled` 的输出不是质量分)。 | |
| 41 | +「把 dream 套进 org/work graph 来跑」评估过、用户明确选了不做—— | |
| 42 | +org/work graph 是单公司作用域的,dream 是跨公司扫描,两者模型不匹配; | |
| 43 | +且 graph 引擎自带的验收机制(goal-contract benchmark/test-script)判的是「产物过没过验收脚本」, | |
| 44 | +对「经验提炼得对不对」这种自由文本判断没有对应机制,套不套 graph 都得单独设计 rubric。 | |
| 33 | 45 | |
| 34 | 46 | **已经生效的约束**(这条是确定的,不随 dream 的设计变化): |
| 35 | 47 | 公司态目录(`system/agents/{aid}/{cid}/` 下的 `memory/` / `conversations/`)是 dream 的唯一原料, |
| 36 | -**不许被任何清理策略当垃圾扫掉**。详见主文档 §5.6b。 | |
| 37 | - | |
| 38 | -> 真要做时另开设计文档。**不要**照主文档 §5.6b 的措辞去写代码 —— | |
| 39 | -> 那一节的目的是锁住上面那条约束,不是给 dream 下定义。 | |
| 48 | +**不许被任何清理策略当垃圾扫掉**。详见主文档 §5.6b,任务态清理已按此约束实现(见下 §3.2)。 | |
| 40 | 49 | |
| 41 | 50 | --- |
| 42 | 51 | ... | ... |
docs/todo/system-design/29-dream-design.md
| 1 | 1 | # 29-DREAM · 经验汇流(dream)—— 设计草案 |
| 2 | 2 | |
| 3 | 3 | > **所属**: [28 Agent 注册表统一](../../done/system-design/28-agent-registry-unification.md) §5.6b 锁住的那条约束的下游设计;未确认项来源 [28-followups §1.1](./28-followups.md)。 |
| 4 | -> **日期**: 2026-08-20 | |
| 5 | -> **状态**: 📝 **四条取舍已由用户拍板(2026-08-20,见 §三),实现未开始。** | |
| 4 | +> **日期**: 2026-08-20 / REST 与控制台入口 2026-08-21 | |
| 5 | +> **状态**: ✅ **四条取舍已拍板并已实现(五步 + 定时挂载 + 定义文件写入 + REST/控制台入口,均已落地并真跑验证,见 §四/§五/§5.7)。** | |
| 6 | +> 仍未做的只剩「结论对不对」的判据——机器判不了,只能靠人抽查(决策 D,见上)。 | |
| 6 | 7 | > 标 ✅ 的是已生效的约束或有实测证据的事实;标 🔒 的是本轮拍板的决定。 |
| 7 | 8 | |
| 8 | 9 | --- |
| ... | ... | @@ -319,13 +320,15 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md |
| 319 | 320 | |
| 320 | 321 | ### 5.4 还没做的(别当成 dream 上线了) |
| 321 | 322 | |
| 322 | -1. **定时挂载没做**(决策 C 说每日一次)。现在只有 `dreamOnce(agentId)` 这个可调用的单元, | |
| 323 | - 没有挂到任何调度上,也没有 REST / 控制台入口。空转保护已经在,挂之前先想清楚 | |
| 324 | - 「每天对哪些本体跑」——全量 76 个本体逐个跑,即使空转也要 76 次目录读。 | |
| 325 | -2. **还没写定义文件**。`writeDistilled` 目前只写本体根的 `memory/experience.md` | |
| 326 | - (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义 | |
| 327 | - 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」, | |
| 328 | - 闸门(§B.3)也已就位,缺的是「往里写什么、写成什么形状」这半边设计。 | |
| 323 | +1. ~~定时挂载没做~~ ✅ **已完成**(§5.6)—— `dream-service.startDaily()` 已挂在 | |
| 324 | + `server/index.js` 的 `DISABLE_BACKGROUND_AGENTS` 早退之后,每日一次、串行扫、 | |
| 325 | + 定时器 `unref`。~~仍然没有的是 REST / 控制台入口~~ ✅ **已完成**(§5.7)—— | |
| 326 | + `/api/dream/*` + `web/src/views/DreamConsole.vue`(导航栏 `🌙 Dream`)。 | |
| 327 | +2. ~~还没写定义文件~~ ✅ **已完成**(§5.6)—— `writeDefinitionFiles()` 已实现, | |
| 328 | + 落点是 `SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义文件, | |
| 329 | + 形状是受管区块(`<!-- dream:begin/end -->`,区块外不碰、整体重写幂等), | |
| 330 | + 写之前过三道闸门(definition-write-gate → agent.md 前 2000 字符自足性 → | |
| 331 | + 写完复核毕业闸门,不过立刻还原),任何一道不过就不写、原因逐文件返回。 | |
| 329 | 332 | 3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。 |
| 330 | 333 | |
| 331 | 334 | --- |
| ... | ... | @@ -386,3 +389,100 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md |
| 386 | 389 | ### 空转保护实跑 |
| 387 | 390 | |
| 388 | 391 | 第二次调用(原料没变):`{"skipped":"no-new-material"}`,**一次 agent 调用都没起**。 |
| 392 | + | |
| 393 | +--- | |
| 394 | + | |
| 395 | +## 5.6 定时挂载(决策 C)+ 定义文件写入(决策 B)(2026-08-20,`e9f380a`) | |
| 396 | + | |
| 397 | +### 定时:每日一次,串行 | |
| 398 | + | |
| 399 | +`dreamSweep` 逐个本体扫,**不并发** —— 每轮可能起两次 LLM 调用,几十个本体并发 | |
| 400 | += 同时拉起几十个 claude 子进程,这个代价本仓库付过(2h43m 漏出 82 个进程 / 17.5 GB, | |
| 401 | +[28-followups §五第五条](./28-followups.md))。慢一点没有代价,空转保护已经把 | |
| 402 | +「读一遍全量本体」的成本压到「N 次目录读」。 | |
| 403 | + | |
| 404 | +挂点:`server/index.js` 里 `DISABLE_BACKGROUND_AGENTS` 早退之后,调 | |
| 405 | +`dream-service.startDaily()`。定时器 `unref`;只挂定时**不立刻跑一轮** | |
| 406 | +(启动那一刻跑没有额外收益,反而让每次重启都烧一遍)。一个本体抛异常不拖垮整轮, | |
| 407 | +但必须留下是谁抛的 —— 否则现场只看到「今天没沉淀」,查不出是没原料还是一直在抛。 | |
| 408 | + | |
| 409 | +**没做的**:REST / 控制台入口。现在触发只有两条路:等每日定时,或代码 / 脚本直接调 | |
| 410 | +`dreamOnce(agentId)`。人想立刻看一次提炼结果,目前没有 API/页面。 | |
| 411 | + | |
| 412 | +### 定义文件:受管区块 + 三道闸门 | |
| 413 | + | |
| 414 | +落点是 `SKILL.md` / `soul.md` / `agent.md`(决策 B 的另外两个落点,本体根 | |
| 415 | +`memory/experience.md` 在步骤 3 已经在写)。形状是一对 | |
| 416 | +`<!-- dream:begin/end -->` 之间的**受管区块**,区块外一个字节不碰 —— 自由追加会让 | |
| 417 | +「哪些是人写的、哪些是 dream 写的」几轮之后再也分不开,分不开就没法分别回滚。 | |
| 418 | +区块整体重写还顺带**幂等**:同一批经验重复跑不会越写越长。 | |
| 419 | + | |
| 420 | +三道闸门,缺一不写: | |
| 421 | +1. **definition-write-gate**(§B.3):当前内容必须已在 git 历史里,且没有未提交改动; | |
| 422 | +2. **agent.md 的前 2000 字符必须仍然自足**:`graph-engine.js:915` 的「角色定义摘录」 | |
| 423 | + 只看这一段,改了等于悄悄换掉规划 agent 读到的画像; | |
| 424 | +3. **写完复核毕业闸门**(`missingBodyDefinition`),不过就**立刻还原** —— | |
| 425 | + `agent.md` 写坏会让本体退回「定义没填满」,雇不进任何公司。 | |
| 426 | + | |
| 427 | +任何一道不过:不写,原因逐文件返回(`definitions[]`)。**不写不该是静默的。** | |
| 428 | + | |
| 429 | +守卫:`dream-service.test.js` 新增 3 例,对真被 git 跟踪且干净的本体(`dream-screener`) | |
| 430 | +走真路径 —— 真写、验区块外字节不变 / 幂等 / 毕业闸门仍过,`finally` 还原并断言还原干净; | |
| 431 | +`agent.md` 那条验「不写 + 理由点名前 2000 字符 + 一个字节都没动」。`dreamSweep` 加了 | |
| 432 | +`agents` 参数,让错误隔离那条能真调而不是真扫全量本体。 | |
| 433 | + | |
| 434 | +全量:144 文件 / 1330 测试全绿。 | |
| 435 | + | |
| 436 | +--- | |
| 437 | + | |
| 438 | +## 5.7 REST / 控制台入口(2026-08-21) | |
| 439 | + | |
| 440 | +只做了这一件事,没有跟着做「dream 改成 org/work graph 来跑」—— | |
| 441 | +两者被单独评估过(见对话记录):org/work graph 的执行模型是**单公司作用域**的 | |
| 442 | +(org graph = 公司级 workflow,work graph = 公司内某任务的节点图),dream 却是 | |
| 443 | +**跨公司**扫描(按 agent 扫全部公司的记忆),套进去要么破坏这个作用域假设, | |
| 444 | +要么得新开一种「跨公司图」类型。而且 graph 引擎自带的「自动质量判断」 | |
| 445 | +(goal-contract 的 benchmark / test-script、triager 驳回重做)判的是「产物有没有 | |
| 446 | +过验收脚本」,对「这条提炼的经验对不对」这种自由文本判断没有对应机制—— | |
| 447 | +那道题不管包不包进 graph 都得单独设计一个 rubric,是同一件事,graph 化本身 | |
| 448 | +不会替你解决它。所以只做了用户点了头的这一半。 | |
| 449 | + | |
| 450 | +### REST(`server/routes/dream.js`,挂在 `/api/dream`) | |
| 451 | + | |
| 452 | +路由本身**没有新业务逻辑**,全部委托给已经测过的 `dream-service.js`: | |
| 453 | + | |
| 454 | +| 端点 | 做什么 | | |
| 455 | +|---|---| | |
| 456 | +| `GET /api/dream/agents` | 总览:每个可 dream 的本体,公司数、上次跑的时间、上次沉淀/拒绝了几条 | | |
| 457 | +| `GET /api/dream/agents/:agentId` | 详情:状态、定义文件(`agent.md`/`soul.md`/`SKILL.md`)现在能不能写及原因、`auditDistilled()` 的可回查列表 | | |
| 458 | +| `POST /api/dream/agents/:agentId/run` | 手动跑一次(`{dryRun}` 可选;`dryRun` 只看有没有新原料,不起 LLM 调用) | | |
| 459 | +| `POST /api/dream/sweep` | 手动触发全量扫描,202 立即返回,后台跑(串行扫全部本体可能要几分钟到几十分钟,不能塞进一次 HTTP 请求里等) | | |
| 460 | +| `GET /api/dream/sweep/status` | 轮询上面那个后台任务:`sweeping` / `results` / `error` | | |
| 461 | + | |
| 462 | +`sweep` 的并发锁是路由自己的状态(内存里一个 `sweepState`,不落库)—— | |
| 463 | +`dream-service.js` 没有「正在跑」这个概念,`dreamSweep()` 单纯是个 promise。 | |
| 464 | +两次并发 `POST /sweep` 第二次会拿到 409,防的是「人在控制台连点两下」而不是 | |
| 465 | +分布式场景(单进程部署,够用)。 | |
| 466 | + | |
| 467 | +守卫:`server/tests/unit/dream-routes.test.js`,monkeypatch 掉 | |
| 468 | +`dream-service` 导出对象上的六个函数(不走真文件系统,不会误扫真仓库的 | |
| 469 | +76 个本体),只验路由这一层独有的东西——六个端点接没接对、`sweep` 的并发锁 | |
| 470 | +状态机对不对。`dream-service.js` 自身的正确性由 `dream-service.test.js` 覆盖, | |
| 471 | +这里不重复验。 | |
| 472 | + | |
| 473 | +### 控制台(`web/src/views/DreamConsole.vue`,路由 `/dream`,导航栏 `🌙 Dream`) | |
| 474 | + | |
| 475 | +一个表格 + 两个弹窗,没有别的:本体列表(点「详情」看状态/定义文件可写性/ | |
| 476 | +经验审计,点「跑一次」或「dry-run」手动触发单个本体),页头一个「全量扫描」按钮 | |
| 477 | +(触发后台 sweep,15 秒轮询一次状态直到跑完)。 | |
| 478 | + | |
| 479 | +### 真跑验证(2026-08-21) | |
| 480 | + | |
| 481 | +起真服务器(`DISABLE_BACKGROUND_AGENTS=1 bash start.sh`),对真数据打过一轮: | |
| 482 | +`GET /api/dream/agents` 拿到全部 76 个本体真实的 `companies` / `lastRunAt`; | |
| 483 | +`GET /api/dream/agents/innovator-evolver` 的 `definitions` 如实报出 | |
| 484 | +`agent.md`/`soul.md` 可写、`SKILL.md` 不可写(没有 git 历史)及具体原因, | |
| 485 | +`experience` 把 §5.5 那次真跑沉淀的 4 条经验连同原料原文一起吐出来; | |
| 486 | +`dry-run` 对 `innovator-evolver` 正确报 `no-new-material`(材料已在上次真跑时被 | |
| 487 | +消费,本轮**没有起一次 LLM 调用**)。控制台页面在浏览器里过了一遍同样的路径 | |
| 488 | +(列表 → 详情弹窗 → dry-run 弹窗),数据与直接打 API 一致。 | ... | ... |
server/index.js
| ... | ... | @@ -96,6 +96,7 @@ app.use('/api/tasks', require('./routes/tasks')); |
| 96 | 96 | app.use('/api/devloop', require('./routes/devloop')); |
| 97 | 97 | app.use('/api/graph-runs', require('./routes/graph-run')); // P3 图执行(T6) |
| 98 | 98 | app.use('/api/company', require('./routes/graph-run').companyRouter); // GET /:cid/graph-runs |
| 99 | +app.use('/api/dream', require('./routes/dream')); // 29-DREAM 经验汇流 | |
| 99 | 100 | app.use('/public', require('express').static(require('path').join(__dirname, 'public'))); |
| 100 | 101 | |
| 101 | 102 | // ─── 系统端点 ─── | ... | ... |
server/routes/dream.js
0 → 100644
| 1 | +/** | |
| 2 | + * dream —— 经验汇流的 REST 入口([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。 | |
| 3 | + * | |
| 4 | + * 只做一件事:把 `dream-service.js` 已有的能力(`dreamOnce` / `dreamSweep` / | |
| 5 | + * `auditDistilled` / `definitionWritability`)从「只能靠脚本或定时器触发」 | |
| 6 | + * 变成「REST 可查可触发」。不新增业务逻辑。 | |
| 7 | + */ | |
| 8 | +const express = require('express'); | |
| 9 | +const router = express.Router(); | |
| 10 | +const dream = require('../services/dream-service'); | |
| 11 | + | |
| 12 | +// dreamSweep 串行跑几十个本体、每个可能起两次 LLM 调用,不能塞进一次 HTTP 请求里等。 | |
| 13 | +// 用最小的内存态跟踪「在跑没在跑」+「上一轮结果」,够控制台轮询用。 | |
| 14 | +let sweepState = { sweeping: false, startedAt: null, finishedAt: null, results: null, error: null }; | |
| 15 | + | |
| 16 | +// GET /api/dream/agents — 总览:每个可 dream 的本体,跑没跑过、上次沉淀了几条 | |
| 17 | +router.get('/agents', (req, res) => { | |
| 18 | + try { | |
| 19 | + const list = dream.listDreamableAgents().map((agentId) => { | |
| 20 | + const state = dream.readState(agentId); | |
| 21 | + return { | |
| 22 | + agentId, | |
| 23 | + companies: dream.companiesOf(agentId).length, | |
| 24 | + lastRunAt: state.lastRunAt || null, | |
| 25 | + lastAccepted: state.lastAccepted || 0, | |
| 26 | + lastRejected: state.lastRejected || 0, | |
| 27 | + }; | |
| 28 | + }); | |
| 29 | + res.json({ agents: list }); | |
| 30 | + } catch (err) { | |
| 31 | + res.status(500).json({ error: err.message }); | |
| 32 | + } | |
| 33 | +}); | |
| 34 | + | |
| 35 | +// GET /api/dream/agents/:agentId — 详情:状态 + 定义文件能不能写 + 已沉淀经验的可回查列表 | |
| 36 | +router.get('/agents/:agentId', (req, res) => { | |
| 37 | + try { | |
| 38 | + const { agentId } = req.params; | |
| 39 | + res.json({ | |
| 40 | + agentId, | |
| 41 | + companies: dream.companiesOf(agentId), | |
| 42 | + state: dream.readState(agentId), | |
| 43 | + definitions: dream.definitionWritability(agentId), | |
| 44 | + experience: dream.auditDistilled(agentId), // 每条结论并排列出引用的原料原文,回查成本降到几秒 | |
| 45 | + }); | |
| 46 | + } catch (err) { | |
| 47 | + res.status(500).json({ error: err.message }); | |
| 48 | + } | |
| 49 | +}); | |
| 50 | + | |
| 51 | +// POST /api/dream/agents/:agentId/run — 手动跑一次(真会起 LLM 调用,dryRun 只想看会不会有新原料时用) | |
| 52 | +router.post('/agents/:agentId/run', async (req, res) => { | |
| 53 | + try { | |
| 54 | + const dryRun = req.body && req.body.dryRun === true; | |
| 55 | + const result = await dream.dreamOnce(req.params.agentId, { dryRun }); | |
| 56 | + res.json(result); | |
| 57 | + } catch (err) { | |
| 58 | + res.status(500).json({ error: err.message }); | |
| 59 | + } | |
| 60 | +}); | |
| 61 | + | |
| 62 | +// POST /api/dream/sweep — 手动触发全量扫描(后台跑,别等;串行跑完可能是几分钟到几十分钟) | |
| 63 | +router.post('/sweep', (req, res) => { | |
| 64 | + if (sweepState.sweeping) return res.status(409).json({ error: '上一轮扫描还没跑完', ...sweepState }); | |
| 65 | + sweepState = { sweeping: true, startedAt: new Date().toISOString(), finishedAt: null, results: null, error: null }; | |
| 66 | + dream.dreamSweep() | |
| 67 | + .then((results) => { sweepState = { ...sweepState, sweeping: false, finishedAt: new Date().toISOString(), results }; }) | |
| 68 | + .catch((err) => { sweepState = { ...sweepState, sweeping: false, finishedAt: new Date().toISOString(), error: err.message }; }); | |
| 69 | + res.status(202).json({ started: true, startedAt: sweepState.startedAt }); | |
| 70 | +}); | |
| 71 | + | |
| 72 | +// GET /api/dream/sweep/status — 轮询上面那个后台任务 | |
| 73 | +router.get('/sweep/status', (req, res) => res.json(sweepState)); | |
| 74 | + | |
| 75 | +module.exports = router; | ... | ... |
server/tests/unit/dream-routes.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * dream 的 REST 入口(29-DREAM,见 docs/todo/system-design/29-dream-design.md)。 | |
| 3 | + * | |
| 4 | + * 路由本身没有业务逻辑 —— 全部委托给已经测过的 `dream-service.js`。 | |
| 5 | + * 这份只验两件路由层独有的东西: | |
| 6 | + * ① 六个端点确实接到了对应的 service 函数上(不是「实现在、零调用方」); | |
| 7 | + * ② `POST /sweep` 的并发锁 —— 上一轮没跑完时第二次触发要拒(409), | |
| 8 | + * 跑完之后 `GET /sweep/status` 要能看到结果。这段状态机全部写在 route 里, | |
| 9 | + * `dream-service.test.js` 覆盖不到。 | |
| 10 | + * | |
| 11 | + * `dream-service` 的六个被测函数在这里整体替身(monkeypatch 掉导出对象上的属性, | |
| 12 | + * 用完在 afterAll 里还原)—— 不走真文件系统,也不会误扫真仓库里的 76 个本体。 | |
| 13 | + */ | |
| 14 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | |
| 15 | + | |
| 16 | +const express = require('express'); | |
| 17 | +const dream = require('../../services/dream-service'); | |
| 18 | +const dreamRouter = require('../../routes/dream'); | |
| 19 | + | |
| 20 | +const AGENT = 'zz-route-agent'; | |
| 21 | +const orig = {}; | |
| 22 | +function patch(name, fn) { orig[name] = dream[name]; dream[name] = fn; } | |
| 23 | +function restore(name) { dream[name] = orig[name]; } | |
| 24 | + | |
| 25 | +let server; | |
| 26 | +let base; | |
| 27 | +let resolveSweep; | |
| 28 | + | |
| 29 | +beforeAll(() => { | |
| 30 | + patch('listDreamableAgents', () => [AGENT]); | |
| 31 | + patch('readState', () => ({ lastRunAt: '2026-08-20T00:00:00.000Z', lastAccepted: 2, lastRejected: 1 })); | |
| 32 | + patch('companiesOf', () => ['zz-route-co']); | |
| 33 | + patch('definitionWritability', () => [{ file: 'agent.md', allowed: true, reason: '' }]); | |
| 34 | + patch('auditDistilled', () => [{ title: 'X', sources: [{ source: 'zz-route-co:memory.md#1', text: '原文' }] }]); | |
| 35 | + patch('dreamOnce', async (agentId, opts) => ({ agentId, skipped: null, dryRun: !!(opts && opts.dryRun), accepted: 1 })); | |
| 36 | + patch('dreamSweep', () => new Promise((resolve) => { resolveSweep = resolve; })); | |
| 37 | + | |
| 38 | + const app = express(); | |
| 39 | + app.use(express.json()); | |
| 40 | + app.use('/api/dream', dreamRouter); | |
| 41 | + server = app.listen(0); | |
| 42 | + base = `http://127.0.0.1:${server.address().port}/api/dream`; | |
| 43 | +}); | |
| 44 | + | |
| 45 | +afterAll(() => { | |
| 46 | + ['listDreamableAgents', 'readState', 'companiesOf', 'definitionWritability', 'auditDistilled', 'dreamOnce', 'dreamSweep'] | |
| 47 | + .forEach(restore); | |
| 48 | + server.close(); | |
| 49 | +}); | |
| 50 | + | |
| 51 | +describe('dream REST 入口', () => { | |
| 52 | + it('GET /agents 用 listDreamableAgents + readState + companiesOf 拼总览', async () => { | |
| 53 | + const res = await fetch(`${base}/agents`); | |
| 54 | + expect(res.status).toBe(200); | |
| 55 | + const body = await res.json(); | |
| 56 | + expect(body.agents).toEqual([ | |
| 57 | + { agentId: AGENT, companies: 1, lastRunAt: '2026-08-20T00:00:00.000Z', lastAccepted: 2, lastRejected: 1 }, | |
| 58 | + ]); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it('GET /agents/:id 把状态、定义文件可写性、经验审计一起交出去', async () => { | |
| 62 | + const res = await fetch(`${base}/agents/${AGENT}`); | |
| 63 | + const body = await res.json(); | |
| 64 | + expect(body.agentId).toBe(AGENT); | |
| 65 | + expect(body.companies).toEqual(['zz-route-co']); | |
| 66 | + expect(body.definitions[0].file).toBe('agent.md'); | |
| 67 | + expect(body.experience[0].sources[0].text).toBe('原文'); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it('POST /agents/:id/run 把 body.dryRun 原样传给 dreamOnce', async () => { | |
| 71 | + const res = await fetch(`${base}/agents/${AGENT}/run`, { | |
| 72 | + method: 'POST', | |
| 73 | + headers: { 'Content-Type': 'application/json' }, | |
| 74 | + body: JSON.stringify({ dryRun: true }), | |
| 75 | + }); | |
| 76 | + const body = await res.json(); | |
| 77 | + expect(body.dryRun).toBe(true); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it('sweep 并发锁:跑着的时候第二次触发要 409,跑完 status 要能看到结果', async () => { | |
| 81 | + const start = await fetch(`${base}/sweep`, { method: 'POST' }); | |
| 82 | + expect(start.status).toBe(202); | |
| 83 | + | |
| 84 | + const mid = await fetch(`${base}/sweep/status`); | |
| 85 | + expect((await mid.json()).sweeping).toBe(true); | |
| 86 | + | |
| 87 | + const blocked = await fetch(`${base}/sweep`, { method: 'POST' }); | |
| 88 | + expect(blocked.status).toBe(409); | |
| 89 | + | |
| 90 | + resolveSweep([{ agentId: AGENT, accepted: 3 }]); | |
| 91 | + await new Promise((r) => setTimeout(r, 20)); // 让 .then() 回调跑完 | |
| 92 | + | |
| 93 | + const done = await fetch(`${base}/sweep/status`); | |
| 94 | + const doneBody = await done.json(); | |
| 95 | + expect(doneBody.sweeping).toBe(false); | |
| 96 | + expect(doneBody.results).toEqual([{ agentId: AGENT, accepted: 3 }]); | |
| 97 | + }); | |
| 98 | +}); | ... | ... |
web/src/components/AppNav.vue
web/src/router/index.js
| ... | ... | @@ -80,6 +80,11 @@ const routes = [ |
| 80 | 80 | name: 'QuotaMonitor', |
| 81 | 81 | component: () => import('@/views/QuotaMonitor.vue'), |
| 82 | 82 | }, |
| 83 | + { | |
| 84 | + path: '/dream', | |
| 85 | + name: 'DreamConsole', | |
| 86 | + component: () => import('@/views/DreamConsole.vue'), | |
| 87 | + }, | |
| 83 | 88 | // P5 · DevLoop Projects 列表 + Dashboard + Ops |
| 84 | 89 | { |
| 85 | 90 | path: '/devloop', | ... | ... |
web/src/views/DreamConsole.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="dream-page"> | |
| 3 | + <AppNav /> | |
| 4 | + <div class="dream-layout"> | |
| 5 | + <header class="page-header"> | |
| 6 | + <div> | |
| 7 | + <h1>🌙 Dream 经验汇流</h1> | |
| 8 | + <p class="subtitle">定时把所有 agent 在所有公司的记忆提炼成不含公司信息的经验,跨公司沉淀</p> | |
| 9 | + </div> | |
| 10 | + <div class="header-actions"> | |
| 11 | + <button class="btn-refresh" @click="loadAgents">🔄 刷新</button> | |
| 12 | + <button class="btn-sweep" :disabled="sweepStatus.sweeping" @click="triggerSweep"> | |
| 13 | + {{ sweepStatus.sweeping ? '⏳ 全量扫描中…' : '🌙 全量扫描' }} | |
| 14 | + </button> | |
| 15 | + </div> | |
| 16 | + </header> | |
| 17 | + | |
| 18 | + <div v-if="sweepStatus.startedAt" class="sweep-banner" :class="{ running: sweepStatus.sweeping }"> | |
| 19 | + <span v-if="sweepStatus.sweeping">上一轮 {{ formatTime(sweepStatus.startedAt) }} 起跑,串行扫描中,可能要几分钟到几十分钟——刷新本页不会打断它。</span> | |
| 20 | + <span v-else-if="sweepStatus.error">上一轮 {{ formatTime(sweepStatus.startedAt) }} 扫描失败:{{ sweepStatus.error }}</span> | |
| 21 | + <span v-else-if="sweepStatus.results"> | |
| 22 | + 上一轮 {{ formatTime(sweepStatus.finishedAt) }} 完成:{{ sweepStatus.results.length }} 个本体, | |
| 23 | + {{ sweepStatus.results.filter(r => !r.skipped && !r.error).length }} 个有新原料, | |
| 24 | + 沉淀 {{ sweepStatus.results.reduce((n, r) => n + (r.accepted || 0), 0) }} 条。 | |
| 25 | + </span> | |
| 26 | + </div> | |
| 27 | + | |
| 28 | + <div v-if="loading" class="loading-state">⏳ 加载中…</div> | |
| 29 | + <div v-else-if="agents.length === 0" class="empty-state">没有可 dream 的本体</div> | |
| 30 | + | |
| 31 | + <div v-else class="agents-table"> | |
| 32 | + <div class="table-header"> | |
| 33 | + <span>本体</span> | |
| 34 | + <span>公司数</span> | |
| 35 | + <span>上次跑</span> | |
| 36 | + <span>沉淀 / 拒绝</span> | |
| 37 | + <span>操作</span> | |
| 38 | + </div> | |
| 39 | + <div v-for="a in agents" :key="a.agentId" class="table-row"> | |
| 40 | + <span class="col-agent">{{ a.agentId }}</span> | |
| 41 | + <span>{{ a.companies }}</span> | |
| 42 | + <span class="col-time">{{ a.lastRunAt ? formatTime(a.lastRunAt) : '从未' }}</span> | |
| 43 | + <span> | |
| 44 | + <span class="badge accepted">{{ a.lastAccepted }}</span> | |
| 45 | + <span class="badge rejected">{{ a.lastRejected }}</span> | |
| 46 | + </span> | |
| 47 | + <span class="col-actions"> | |
| 48 | + <button class="btn-action" @click="viewDetail(a.agentId)">👁 详情</button> | |
| 49 | + <button class="btn-action" :disabled="runningAgent === a.agentId" @click="runOne(a.agentId, false)"> | |
| 50 | + {{ runningAgent === a.agentId ? '跑中…' : '▶ 跑一次' }} | |
| 51 | + </button> | |
| 52 | + <button class="btn-action" :disabled="runningAgent === a.agentId" @click="runOne(a.agentId, true)" title="只看有没有新原料,不起 LLM 调用"> | |
| 53 | + dry-run | |
| 54 | + </button> | |
| 55 | + </span> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <!-- 详情弹窗 --> | |
| 60 | + <div v-if="detail" class="modal-overlay" @click="detail = null"> | |
| 61 | + <div class="modal-content" @click.stop> | |
| 62 | + <div class="modal-header"> | |
| 63 | + <h2>{{ detail.agentId }}</h2> | |
| 64 | + <button class="btn-close" @click="detail = null">✕</button> | |
| 65 | + </div> | |
| 66 | + <div class="modal-body"> | |
| 67 | + <section class="detail-section"> | |
| 68 | + <h4>状态</h4> | |
| 69 | + <pre class="state-pre">{{ JSON.stringify(detail.state, null, 2) }}</pre> | |
| 70 | + </section> | |
| 71 | + | |
| 72 | + <section class="detail-section"> | |
| 73 | + <h4>定义文件能不能写</h4> | |
| 74 | + <div class="def-list"> | |
| 75 | + <div v-for="d in detail.definitions" :key="d.file" class="def-row"> | |
| 76 | + <span class="def-file">{{ d.file }}</span> | |
| 77 | + <span class="badge" :class="d.allowed ? 'accepted' : 'rejected'"> | |
| 78 | + {{ d.allowed ? '可写' : '不可写' }} | |
| 79 | + </span> | |
| 80 | + <span class="def-reason">{{ d.reason || '' }}</span> | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + </section> | |
| 84 | + | |
| 85 | + <section class="detail-section"> | |
| 86 | + <h4>已沉淀经验(结论与原料原文并排,抽查用)</h4> | |
| 87 | + <div v-if="detail.experience.length === 0" class="empty-inline">还没有沉淀过</div> | |
| 88 | + <div v-for="(e, i) in detail.experience" :key="i" class="exp-card"> | |
| 89 | + <div class="exp-title">{{ e.title }}</div> | |
| 90 | + <div v-for="(s, j) in e.sources" :key="j" class="exp-source"> | |
| 91 | + <span class="exp-source-id">{{ s.source }}</span> | |
| 92 | + <span v-if="s.text !== null" class="exp-source-text">{{ s.text }}</span> | |
| 93 | + <span v-else class="exp-source-dangling">⚠️ 指针悬空(原料被清理过 / 出处编造)</span> | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + </section> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + | |
| 101 | + <!-- 手动跑结果弹窗 --> | |
| 102 | + <div v-if="runResult" class="modal-overlay" @click="runResult = null"> | |
| 103 | + <div class="modal-content" @click.stop> | |
| 104 | + <div class="modal-header"> | |
| 105 | + <h2>{{ runResult.agentId }} 跑一次的结果</h2> | |
| 106 | + <button class="btn-close" @click="runResult = null">✕</button> | |
| 107 | + </div> | |
| 108 | + <div class="modal-body"> | |
| 109 | + <pre class="state-pre">{{ JSON.stringify(runResult, null, 2) }}</pre> | |
| 110 | + </div> | |
| 111 | + </div> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 115 | +</template> | |
| 116 | + | |
| 117 | +<script setup> | |
| 118 | +import { ref, onMounted, onUnmounted } from 'vue' | |
| 119 | +import AppNav from '@/components/AppNav.vue' | |
| 120 | + | |
| 121 | +const agents = ref([]) | |
| 122 | +const loading = ref(true) | |
| 123 | +const detail = ref(null) | |
| 124 | +const runResult = ref(null) | |
| 125 | +const runningAgent = ref(null) | |
| 126 | +const sweepStatus = ref({ sweeping: false, startedAt: null, finishedAt: null, results: null, error: null }) | |
| 127 | +let pollTimer = null | |
| 128 | + | |
| 129 | +async function loadAgents() { | |
| 130 | + loading.value = true | |
| 131 | + try { | |
| 132 | + const res = await fetch('/api/dream/agents') | |
| 133 | + const data = await res.json() | |
| 134 | + agents.value = data.agents || [] | |
| 135 | + } catch (e) { | |
| 136 | + console.error('加载 dream 本体列表失败', e) | |
| 137 | + } finally { | |
| 138 | + loading.value = false | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +async function loadSweepStatus() { | |
| 143 | + try { | |
| 144 | + const res = await fetch('/api/dream/sweep/status') | |
| 145 | + sweepStatus.value = await res.json() | |
| 146 | + } catch (e) { | |
| 147 | + console.error('加载 sweep 状态失败', e) | |
| 148 | + } | |
| 149 | +} | |
| 150 | + | |
| 151 | +async function triggerSweep() { | |
| 152 | + try { | |
| 153 | + const res = await fetch('/api/dream/sweep', { method: 'POST' }) | |
| 154 | + if (res.status === 409) { alert('上一轮扫描还没跑完'); return } | |
| 155 | + await loadSweepStatus() | |
| 156 | + } catch (e) { | |
| 157 | + alert('触发失败: ' + e.message) | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +async function viewDetail(agentId) { | |
| 162 | + try { | |
| 163 | + const res = await fetch(`/api/dream/agents/${agentId}`) | |
| 164 | + detail.value = await res.json() | |
| 165 | + } catch (e) { | |
| 166 | + alert('加载详情失败: ' + e.message) | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +async function runOne(agentId, dryRun) { | |
| 171 | + runningAgent.value = agentId | |
| 172 | + try { | |
| 173 | + const res = await fetch(`/api/dream/agents/${agentId}/run`, { | |
| 174 | + method: 'POST', | |
| 175 | + headers: { 'Content-Type': 'application/json' }, | |
| 176 | + body: JSON.stringify({ dryRun }), | |
| 177 | + }) | |
| 178 | + runResult.value = await res.json() | |
| 179 | + await loadAgents() | |
| 180 | + } catch (e) { | |
| 181 | + alert('跑失败: ' + e.message) | |
| 182 | + } finally { | |
| 183 | + runningAgent.value = null | |
| 184 | + } | |
| 185 | +} | |
| 186 | + | |
| 187 | +function formatTime(iso) { | |
| 188 | + if (!iso) return '-' | |
| 189 | + return new Date(iso).toLocaleString('zh-CN') | |
| 190 | +} | |
| 191 | + | |
| 192 | +onMounted(() => { | |
| 193 | + loadAgents() | |
| 194 | + loadSweepStatus() | |
| 195 | + // 全量扫描可能跑几十分钟,轮询一下让「还在跑」不需要手动刷新才能看到 | |
| 196 | + pollTimer = setInterval(() => { if (sweepStatus.value.sweeping) loadSweepStatus() }, 15000) | |
| 197 | +}) | |
| 198 | +onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) }) | |
| 199 | +</script> | |
| 200 | + | |
| 201 | +<style scoped> | |
| 202 | +.dream-page { min-height: 100vh; background: var(--bg-primary); color: var(--text-primary); } | |
| 203 | +.dream-layout { max-width: 1100px; margin: 0 auto; padding: 32px 40px; } | |
| 204 | + | |
| 205 | +.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; } | |
| 206 | +.page-header h1 { font-size: 24px; font-weight: 700; margin: 0 0 6px; } | |
| 207 | +.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0; } | |
| 208 | +.header-actions { display: flex; gap: 10px; } | |
| 209 | + | |
| 210 | +.btn-refresh, .btn-sweep { | |
| 211 | + padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; | |
| 212 | + border: 1px solid var(--border-primary); background: var(--bg-surface); color: var(--text-secondary); | |
| 213 | +} | |
| 214 | +.btn-sweep { background: var(--accent); border-color: transparent; color: white; } | |
| 215 | +.btn-sweep:disabled { opacity: 0.6; cursor: not-allowed; } | |
| 216 | +.btn-refresh:hover { background: var(--bg-surface-hover); } | |
| 217 | + | |
| 218 | +.sweep-banner { | |
| 219 | + padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; | |
| 220 | + background: var(--accent-bg); color: var(--text-secondary); border: 1px solid var(--border-active); | |
| 221 | +} | |
| 222 | +.sweep-banner.running { color: var(--accent-light); } | |
| 223 | + | |
| 224 | +.loading-state, .empty-state, .empty-inline { text-align: center; padding: 40px; color: var(--text-muted); } | |
| 225 | +.empty-inline { padding: 12px; text-align: left; font-style: italic; } | |
| 226 | + | |
| 227 | +.agents-table { | |
| 228 | + background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden; | |
| 229 | +} | |
| 230 | +.table-header, .table-row { | |
| 231 | + display: grid; grid-template-columns: 1.4fr 0.6fr 1fr 1fr 1.6fr; | |
| 232 | + gap: 12px; padding: 12px 16px; align-items: center; | |
| 233 | +} | |
| 234 | +.table-header { font-size: 12px; font-weight: 600; color: var(--text-muted); border-bottom: 1px solid var(--border-primary); } | |
| 235 | +.table-row { border-bottom: 1px solid var(--border-primary); font-size: 13px; } | |
| 236 | +.table-row:last-child { border-bottom: none; } | |
| 237 | +.col-agent { font-weight: 600; } | |
| 238 | +.col-time { color: var(--text-secondary); } | |
| 239 | +.col-actions { display: flex; gap: 6px; flex-wrap: wrap; } | |
| 240 | + | |
| 241 | +.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; margin-right: 6px; } | |
| 242 | +.badge.accepted { background: var(--accent-bg); color: var(--accent-light); } | |
| 243 | +.badge.rejected { background: rgba(248, 81, 73, 0.12); color: #f85149; } | |
| 244 | + | |
| 245 | +.btn-action { | |
| 246 | + padding: 5px 10px; border-radius: 6px; border: 1px solid var(--border-primary); | |
| 247 | + background: var(--bg-surface); color: var(--text-primary); font-size: 12px; cursor: pointer; | |
| 248 | +} | |
| 249 | +.btn-action:hover { background: var(--bg-surface-hover); } | |
| 250 | +.btn-action:disabled { opacity: 0.5; cursor: not-allowed; } | |
| 251 | + | |
| 252 | +/* Modal */ | |
| 253 | +.modal-overlay { | |
| 254 | + position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; | |
| 255 | + justify-content: center; z-index: 1000; backdrop-filter: blur(4px); | |
| 256 | +} | |
| 257 | +.modal-content { | |
| 258 | + background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: 12px; | |
| 259 | + width: 640px; max-width: 92vw; max-height: 84vh; overflow-y: auto; | |
| 260 | +} | |
| 261 | +.modal-header { | |
| 262 | + display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; | |
| 263 | + border-bottom: 1px solid var(--border-primary); position: sticky; top: 0; background: var(--bg-secondary); | |
| 264 | +} | |
| 265 | +.modal-header h2 { margin: 0; font-size: 16px; font-family: monospace; } | |
| 266 | +.btn-close { background: none; border: none; font-size: 16px; color: var(--text-secondary); cursor: pointer; } | |
| 267 | +.modal-body { padding: 20px; } | |
| 268 | + | |
| 269 | +.detail-section { margin-bottom: 20px; } | |
| 270 | +.detail-section h4 { font-size: 13px; color: var(--text-secondary); margin: 0 0 8px; } | |
| 271 | +.state-pre { | |
| 272 | + background: var(--bg-primary); padding: 12px; border-radius: 8px; font-size: 12px; | |
| 273 | + overflow-x: auto; max-height: 200px; overflow-y: auto; | |
| 274 | +} | |
| 275 | + | |
| 276 | +.def-list { display: flex; flex-direction: column; gap: 6px; } | |
| 277 | +.def-row { display: flex; align-items: center; gap: 10px; font-size: 12px; } | |
| 278 | +.def-file { font-family: monospace; min-width: 80px; } | |
| 279 | +.def-reason { color: var(--text-muted); } | |
| 280 | + | |
| 281 | +.exp-card { | |
| 282 | + background: var(--bg-primary); border: 1px solid var(--border-primary); border-radius: 8px; | |
| 283 | + padding: 12px; margin-bottom: 10px; | |
| 284 | +} | |
| 285 | +.exp-title { font-weight: 700; font-size: 13px; margin-bottom: 6px; } | |
| 286 | +.exp-source { font-size: 12px; margin-bottom: 4px; padding-left: 8px; border-left: 2px solid var(--border-active); } | |
| 287 | +.exp-source-id { color: var(--text-muted); font-family: monospace; margin-right: 8px; } | |
| 288 | +.exp-source-text { color: var(--text-secondary); } | |
| 289 | +.exp-source-dangling { color: #f85149; } | |
| 290 | +</style> | ... | ... |