From 6b3695e55fa93a378dc9dfbae209acac961ec884 Mon Sep 17 00:00:00 2001 From: haowei Date: Fri, 17 Jul 2026 11:32:51 +0800 Subject: [PATCH 1/4] feat(codemap): directory locators + verify-before-write prompt rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report #3: a module loc pointed at a DIRECTORY (server/src/gateway) — natural for module nodes, but the probe only used the file endpoint, where directories surface as E_IS_DIR or, on some connectors, as not-found. So: - wsLocate treats E_IS_DIR as a hit and, after a file-endpoint miss, confirms candidates with a tree listing before ruling them out; resolution now returns {path, kind} and a directory jump opens the workspace folder view (the dialog's deep-link already lists dirs). - Line anchors only apply to files. - map.yaml header guide gains the strongest lever against invented paths: VERIFY the path exists (ls it) before writing loc, and prefer a representative file + #L over a bare directory. Template seed regenerated byte-identical. - Renderer search also matches loc paths. Co-Authored-By: Claude Fable 5 --- docs/arch/CODEMAP.md | 9 +++ docs/arch/examples/codemap.plugin.html | 6 ++ docs/arch/examples/codemap.template.json | 2 +- frontend/src/features/chat/ChannelView.tsx | 4 +- frontend/src/features/chat/wsLocate.ts | 76 +++++++++++++++------- 5 files changed, 71 insertions(+), 26 deletions(-) diff --git a/docs/arch/CODEMAP.md b/docs/arch/CODEMAP.md index de7799c1..5265b33b 100644 --- a/docs/arch/CODEMAP.md +++ b/docs/arch/CODEMAP.md @@ -317,6 +317,11 @@ pin 的 `codemap/conventions.md` 只剩发现层(一行): # '@deng please ...'). NEVER guess it and never copy a # handle from an example - a wrong handle breaks every # jump. Unsure? Omit loc rather than invent one. +# The path part is relative to YOUR workspace root: +# VERIFY it exists (e.g. `ls `) before writing it - +# a guessed path is a broken jump. Prefer a representative +# FILE plus #L even for modules; a bare directory +# loc only opens the folder view. # summary: what it does / what matters; <=200 chars; facts only # status: explored | partial | stale # tags: [optional, short] @@ -350,6 +355,10 @@ pin 的 `codemap/conventions.md` 只剩发现层(一行): prompt 并不告诉 bot 自己的 mention 名,它唯一可靠的来源是**触发消息里别人 @ 它的那个名字**——提示词明说这一点,并禁止猜测/照抄示例,不确定就不写 `loc`。 解析侧配套兜底:频道只有一个 bot 时,错误 handle 直接落到它(存在性探测照旧)。 +- **verify-before-write**(实战教训二:目录 loc + 根基准漂移双杀):要求 bot 写 + `loc` 前实际 `ls` 验证路径、优先"代表性文件 + 行号"而非裸目录——bot 有工具, + 就该让它自证,这比任何解析侧纠偏都便宜。解析侧同时补齐:目录 locator 合法 + (跳文件夹视图),文件接口 miss 后用目录列表二次确认(两种 connector 行为都兜住)。 - 全文 ~2.4KB,不到 256KB 预算的 1%,一次性成本。 ## 6. 缺口与补丁(分阶段) diff --git a/docs/arch/examples/codemap.plugin.html b/docs/arch/examples/codemap.plugin.html index 601a657c..f0ea912e 100644 --- a/docs/arch/examples/codemap.plugin.html +++ b/docs/arch/examples/codemap.plugin.html @@ -308,6 +308,11 @@ "# '@deng please ...'). NEVER guess it and never copy a", "# handle from an example - a wrong handle breaks every", "# jump. Unsure? Omit loc rather than invent one.", + "# The path part is relative to YOUR workspace root:", + "# VERIFY it exists (e.g. `ls `) before writing it -", + "# a guessed path is a broken jump. Prefer a representative", + "# FILE plus #L even for modules; a bare directory", + "# loc only opens the folder view.", "# summary: what it does / what matters; <=200 chars; facts only", "# status: explored | partial | stale", "# tags: [optional, short]", @@ -612,6 +617,7 @@ function matches(n, s) { if (!s) return true; if (n.label.toLowerCase().includes(s) || (n.summary || "").toLowerCase().includes(s)) return true; + if (n.loc && String(n.loc).toLowerCase().includes(s)) return true; // 按源码路径搜模块 return (model.satsOf[n.id] || []).some(function (cid) { return model.nodes[cid].label.toLowerCase().includes(s); }); } function render() { diff --git a/docs/arch/examples/codemap.template.json b/docs/arch/examples/codemap.template.json index c78f7cb3..5cd95f4e 100644 --- a/docs/arch/examples/codemap.template.json +++ b/docs/arch/examples/codemap.template.json @@ -6,7 +6,7 @@ "codemap/conventions.md" ], "seed": { - "codemap/map.yaml": "# =================================================================\n# CODEMAP - an agent-maintained map of this repository.\n# Humans see this file rendered as an interactive graph in the\n# Workbench; you (the agent) are its maintainer. Keep it truthful.\n#\n# WHEN TO UPDATE (in the same turn as the work, not batched):\n# - explored new code -> add or refine the nodes you touched\n# - changed code -> update those summaries, or set\n# status: stale if you didn't re-verify\n# - starting work somewhere -> put those node ids in `focus:`,\n# and clear them when you move on\n#\n# SCHEMA (structure is the contract; comments are yours to use):\n# nodes: # a MAP keyed by node id - never a list\n# : # dotted path: a.b.c is a child of a.b;\n# # segment chars [a-z0-9_-] only, so file\n# # names need '_' (fs.rs -> fs_rs), and the\n# # real name goes in `label`\n# kind: area | module | file | symbol\n# label: short human-readable name\n# loc: cheers:ws/@/#L[-L]\n# = the EXACT name this channel @-mentions\n# you by (you see it in messages addressed to you, e.g.\n# '@deng please ...'). NEVER guess it and never copy a\n# handle from an example - a wrong handle breaks every\n# jump. Unsure? Omit loc rather than invent one.\n# summary: what it does / what matters; <=200 chars; facts only\n# status: explored | partial | stale\n# tags: [optional, short]\n# edges:\n# - { from: , to: , kind: calls|data, label: short }\n#\n# HOW TO EDIT:\n# - append NEW nodes at the end of the file (desk_append) -\n# `nodes:` is intentionally the last top-level key\n# - change EXISTING nodes with desk_edit, replacing single lines;\n# each node's unique ' :' line is your anchor - never\n# rewrite the whole file\n# - comments survive every write path; leave margin notes freely\n# (e.g. '# TODO: auth branch unverified')\n# - granularity: areas + modules always; files only when they\n# matter; symbols only for hot paths. Keep the file under 256 KB\n# - unsure? prefer `status: partial` over invented detail\n# =================================================================\ncodemap: 1\nrepo: \"\" # owner/name - fill in on your first write\nupdated: \"\" # ISO-8601 - refresh on every write\nfocus: []\nedges: []\nnodes:\n # (empty - append your first node below, e.g.\n # gateway:\n # kind: area\n # label: Gateway\n # summary: ...\n # status: partial )\n\n", + "codemap/map.yaml": "# =================================================================\n# CODEMAP - an agent-maintained map of this repository.\n# Humans see this file rendered as an interactive graph in the\n# Workbench; you (the agent) are its maintainer. Keep it truthful.\n#\n# WHEN TO UPDATE (in the same turn as the work, not batched):\n# - explored new code -> add or refine the nodes you touched\n# - changed code -> update those summaries, or set\n# status: stale if you didn't re-verify\n# - starting work somewhere -> put those node ids in `focus:`,\n# and clear them when you move on\n#\n# SCHEMA (structure is the contract; comments are yours to use):\n# nodes: # a MAP keyed by node id - never a list\n# : # dotted path: a.b.c is a child of a.b;\n# # segment chars [a-z0-9_-] only, so file\n# # names need '_' (fs.rs -> fs_rs), and the\n# # real name goes in `label`\n# kind: area | module | file | symbol\n# label: short human-readable name\n# loc: cheers:ws/@/#L[-L]\n# = the EXACT name this channel @-mentions\n# you by (you see it in messages addressed to you, e.g.\n# '@deng please ...'). NEVER guess it and never copy a\n# handle from an example - a wrong handle breaks every\n# jump. Unsure? Omit loc rather than invent one.\n# The path part is relative to YOUR workspace root:\n# VERIFY it exists (e.g. `ls `) before writing it -\n# a guessed path is a broken jump. Prefer a representative\n# FILE plus #L even for modules; a bare directory\n# loc only opens the folder view.\n# summary: what it does / what matters; <=200 chars; facts only\n# status: explored | partial | stale\n# tags: [optional, short]\n# edges:\n# - { from: , to: , kind: calls|data, label: short }\n#\n# HOW TO EDIT:\n# - append NEW nodes at the end of the file (desk_append) -\n# `nodes:` is intentionally the last top-level key\n# - change EXISTING nodes with desk_edit, replacing single lines;\n# each node's unique ' :' line is your anchor - never\n# rewrite the whole file\n# - comments survive every write path; leave margin notes freely\n# (e.g. '# TODO: auth branch unverified')\n# - granularity: areas + modules always; files only when they\n# matter; symbols only for hot paths. Keep the file under 256 KB\n# - unsure? prefer `status: partial` over invented detail\n# =================================================================\ncodemap: 1\nrepo: \"\" # owner/name - fill in on your first write\nupdated: \"\" # ISO-8601 - refresh on every write\nfocus: []\nedges: []\nnodes:\n # (empty - append your first node below, e.g.\n # gateway:\n # kind: area\n # label: Gateway\n # summary: ...\n # status: partial )\n\n", "codemap/conventions.md": "Maintain codemap/map.yaml — read that file first; its header comments are the full instructions.\n" } } diff --git a/frontend/src/features/chat/ChannelView.tsx b/frontend/src/features/chat/ChannelView.tsx index 06f7c683..929e6790 100644 --- a/frontend/src/features/chat/ChannelView.tsx +++ b/frontend/src/features/chat/ChannelView.tsx @@ -942,7 +942,9 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P ); return; } - setWsInit({ botId, path: resolved, line: loc.line }); + // A directory loc opens the folder view (the dialog's deep-link already falls + // back to listing); a line anchor only makes sense on a file. + setWsInit({ botId, path: resolved.path, line: resolved.kind === "file" ? loc.line : undefined }); setWsOpen(true); } catch (e) { const offline = String(e).includes("offline"); diff --git a/frontend/src/features/chat/wsLocate.ts b/frontend/src/features/chat/wsLocate.ts index 3f05b5fa..6b1d6fce 100644 --- a/frontend/src/features/chat/wsLocate.ts +++ b/frontend/src/features/chat/wsLocate.ts @@ -1,59 +1,87 @@ import { getWorkspaceFile, getWorkspaceTree } from "@/api/workspace"; // Tolerant workspace-path resolution for BOT-WRITTEN paths (codemap locators, and any -// future consumer of `cheers:ws/…`). Agents write repo-root-relative paths, but the -// bot's browse root may sit one level ABOVE the repo (root lists `Cheers/…`) or the -// path may redundantly carry the repo dir while the root already IS the repo. Mirrors -// the chat ref-jump philosophy (resolveAndOpenRef): probe layer by layer, only commit -// to a jump the server confirms, and keep every correction BOUNDED — this is a couple -// of targeted probes, not a workspace-wide search. +// future consumer of `cheers:ws/…`). Two classes of uncertainty, both observed in the +// field: // -// Returns the path that actually resolves (open the browser THERE), or null when -// nothing does. Non-NOT_FOUND failures (offline connector, authz) propagate — they -// are different failures with different user-facing messages. +// - ROOT BASIS: agents write repo-root-relative paths, but the bot's browse root may +// sit one level above the repo, or the path may redundantly carry the repo dir. +// - FILE vs DIRECTORY: a module's natural loc is often a folder ("server/src/gateway"). +// The file endpoint reports directories as E_IS_DIR — or, on some connectors, as +// not-found — so a candidate is only ruled out after a cheap tree listing confirms +// it is not a directory either. +// +// Mirrors the chat ref-jump philosophy (resolveAndOpenRef): probe layer by layer, only +// commit to a jump the server confirms, and keep every correction BOUNDED — a handful +// of targeted probes, never a workspace-wide search. Returns what actually resolved +// (and whether it is a file or a directory — directories open the folder view), or +// null when nothing does. Failures other than not-found (offline connector, authz) +// propagate — they are different failures with different user-facing messages. + +export interface LocatedWorkspacePath { + path: string; + kind: "file" | "dir"; +} function isNotFound(e: unknown): boolean { const s = String(e); - return s.includes("E_NOT_FOUND") || s.includes("No such file"); + return s.includes("E_NOT_FOUND") || s.includes("No such file") || s.includes("404"); +} +function isDirError(e: unknown): boolean { + return String(e).includes("E_IS_DIR"); } export async function locateWorkspaceFile( channelId: string, botId: string, path: string -): Promise { - const probe = async (p: string): Promise => { +): Promise { + const probe = async (p: string): Promise<"file" | "dir" | null> => { try { await getWorkspaceFile(channelId, botId, p); - return true; + return "file"; } catch (e) { - if (isNotFound(e)) return false; - throw e; + if (isDirError(e)) return "dir"; + if (!isNotFound(e)) throw e; } + // The file endpoint said not-found — but some connectors say that for directories + // too. Confirm with a listing before ruling the candidate out. + try { + await getWorkspaceTree(channelId, botId, p); + return "dir"; + } catch { + return null; // tree can't see it either — a real miss for this candidate + } + }; + + const hit = async (p: string): Promise => { + const kind = await probe(p); + return kind ? { path: p, kind } : null; }; - if (await probe(path)) return path; + const exact = await hit(path); + if (exact) return exact; // (a) The locator redundantly starts with the repo dir but the root already IS the - // repo: "Cheers/server/src/x.rs" → "server/src/x.rs". One extra probe. + // repo: "Cheers/server/src/x.rs" → "server/src/x.rs". const segs = path.split("/"); if (segs.length > 1) { - const stripped = segs.slice(1).join("/"); - if (await probe(stripped)) return stripped; + const stripped = await hit(segs.slice(1).join("/")); + if (stripped) return stripped; } - // (b) The root is a PARENT of the repo: the file lives under some top-level dir - // ("Cheers/server/src/x.rs"). One tree listing, then one probe per top-level + // (b) The root is a PARENT of the repo: the target lives under some top-level dir + // ("Cheers/server/src/x.rs"). One root listing, then one probe per top-level // dir, capped — a root with many dirs degrades to "not found", never to a scan. try { const tree = await getWorkspaceTree(channelId, botId, ""); const dirs = tree.entries.filter((e) => e.is_dir).slice(0, 12); for (const d of dirs) { - const candidate = `${d.path}/${path}`; - if (await probe(candidate)) return candidate; + const prefixed = await hit(`${d.path}/${path}`); + if (prefixed) return prefixed; } } catch { - // tree unavailable (root not listable) — fall through to "not found" + // root not listable — fall through to "not found" } return null; } From ff3fab0ff61b103d6b26fb5d7fbe511cf9734c9a Mon Sep 17 00:00:00 2001 From: haowei Date: Fri, 17 Jul 2026 11:45:45 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(workbench):=20cheers:compose=20host=20?= =?UTF-8?q?verb=20=E2=80=94=20plugin-suggested,=20human-sent=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer plugins can now PREFILL the channel composer (plugin -> host cheers:compose {text}); nothing is ever sent on the plugin's behalf. The human's send keystroke is what turns a suggestion into a channel action — side effects stay human-in-the-loop, audit-visible, and behind the existing bot approval gates. Enables ChatOps-style boards (click Deploy -> composer holds '@ops deploy web-1 test' -> review -> send). - MessageComposer gains a prefill prop: an empty draft is filled, a typed draft gets the text appended on a new line (user text is never lost); @label tokens matching mentionables are registered as picked mentions — routing's source of truth — so the suggestion actually reaches its bot; focus + cursor-to-end. - SandboxRenderer shape-gates the message (string, <=4000 chars, control chars stripped) and threads it via ctx.composeMessage to ChannelView, which owns the composer. - Protocol docs (EN normative + zh) and the SDK gain the message / compose(text). - codemap example: the detail drawer gains '让 bot 讲讲这个模块' — the mention is derived from the node's loc handle (the bot that wrote the loc is the bot that knows the code). Co-Authored-By: Claude Fable 5 --- docs/arch/CODEMAP.md | 9 +++-- docs/arch/RENDERER_PLUGIN.md | 1 + docs/arch/examples/cheers-plugin-sdk.js | 5 +++ docs/arch/examples/codemap.plugin.html | 16 ++++++++- docs/developer/PLUGIN_DEVELOPMENT.md | 1 + frontend/src/features/chat/ChannelView.tsx | 10 ++++++ .../src/features/chat/MessageComposer.tsx | 34 +++++++++++++++++++ .../chat/workbench/WorkbenchDrawer.tsx | 8 +++-- .../src/features/chat/workbench/context.ts | 4 +++ .../chat/workbench/renderers/RendererHost.tsx | 1 + .../workbench/sandbox/SandboxRenderer.tsx | 16 ++++++++- 11 files changed, 98 insertions(+), 7 deletions(-) diff --git a/docs/arch/CODEMAP.md b/docs/arch/CODEMAP.md index 5265b33b..9c75bc15 100644 --- a/docs/arch/CODEMAP.md +++ b/docs/arch/CODEMAP.md @@ -46,7 +46,7 @@ Devin 的 codemap 是 **agent 边探索边维护的代码库认知地图**,不 | 实时刷新(R4) | `filesTick` 只热刷内置 lens 和 Raw 编辑器;**沙箱插件不会重收 `cheers:render`**(协议 1 合并后复核过 `SandboxRenderer`,仍未接) | ⚠️ 缺口 G1 | | 点节点跳代码(R5) | 三个半成品:① context chip 的 `{verb,params}` → `jumpToContextSource` 跳 Workbench/工作区;② 消息里反引号路径 → `resolveAndOpenRef` + 服务端 `resolveRef` 按来源 bot 跨 inbox/desk/workspace **启发式**解析(带存在性探测与清晰报错);③ `RemoteWorkspaceDialog` 的 `initialBotId/initialPath` 深链 | ⚠️ 缺口 G2:无**确定性文本定位符**、无**行号锚点**、未暴露给插件 | | 大 repo(>256KB) | 单文件 ≤256KB;插件只能读被指派的那一个文件 | ⚠️ 缺口 G3(v2) | -| 节点发起提问(R6) | 插件无任何触达聊天输入的通道 | ⚠️ 缺口 G4(v2) | +| 节点发起提问(R6) | `cheers:compose` 预填输入框(**已实现**):空草稿填入/有草稿换行追加、`@名字` 注册为可路由提及、绝不代发 | ✅ G4 已落地 | > G1 的定性随协议 1 变了:英文规范(PLUGIN_DEVELOPMENT.md §5.2 与 SDK 注释) > 现在**明文规定** render 只有两个触发点(回应 `cheers:ready`、冲突保存之后), @@ -400,8 +400,11 @@ pin 的 `codemap/conventions.md` 只剩发现层(一行): `codemap/map.yaml`(索引)+ `codemap/modules/.yaml`,突破 256KB。 协议 1 明确「host 忽略未知 manifest 键」,`reads` 可以在协议 1 内增补而不 bump 版本。 host 在代理层按 glob 白名单放行 `fs.read`,仍然只读、仍然本频道。 -4. **G4(对话入口)**:`cheers:compose { text }` —— 只**预填**聊天输入框, - 绝不代发。点节点 →「问 bot:解释 gateway.resource」→ 用户自己按发送。 +4. **G4(对话入口)——已实现**:`cheers:compose { text }` 只**预填**聊天输入框, + 绝不代发:空草稿填入、已有草稿换行追加(用户文字永不丢)、文本里匹配成员的 + `@名字` 注册进 picked(否则光有文本路由不到 bot——提及的真源是 picker 状态); + host 侧 ≤4000 字符 + 剥控制符。codemap 详情抽屉的「让 bot 讲讲这个模块」用它, + 提及从节点 `loc` 的 handle 推导(写 loc 的 bot = 懂这块代码的 bot)。 发消息的主体始终是人,避开沙箱插件代用户说话的信任问题。 5. (可选)**行号漂移兜底**:locator 增加 `q=`,查看器行号 未命中时按内容搜索定位。 diff --git a/docs/arch/RENDERER_PLUGIN.md b/docs/arch/RENDERER_PLUGIN.md index f7e0d1cf..cec621ae 100644 --- a/docs/arch/RENDERER_PLUGIN.md +++ b/docs/arch/RENDERER_PLUGIN.md @@ -74,6 +74,7 @@ | plugin → host | `cheers:resource` | `{ reqId, resource, params }` | **host API**:读频道信息(见下白名单),`channel_id` 由 host 强制为当前频道 | | host → plugin | `cheers:resource:result` | `{ reqId, ok, data\|error }` | 读取结果 | | plugin → host | `cheers:open` | `{ uri }` | 请求把**用户的视图**导航到一个 `cheers:` 定位符:`cheers:ws//<路径>#L<行>` 打开远程工作区并定位到行(先做存在性探测),`cheers:desk/<路径>` 聚焦工作台文件,`cheers:inbox/` 打开频道文件。发出即忘、无回执;纯 UI 路由——host 严格解析,跳转背后的每次读取照常鉴权,解析不了给用户看清晰报错。不支持的 host 直接忽略(协议 1 的"忽略未知"生长规则),可以无条件发。 | +| plugin → host | `cheers:compose` | `{ text }` | **预填**聊天输入框——**绝不代发**。空草稿直接填入;已有草稿则换行追加(用户敲的字永不丢失);文本里匹配频道成员的 `@名字` 会注册为可路由的提及。人审阅、可改、亲手按发送——那一下按键才让插件的建议变成频道动作,副作用保持人在环、全程可审计。host 侧形状把关(≤4000 字符、剥控制符)。发出即忘;不支持的 host 忽略。 | ### 3.1 Host API:读频道信息 diff --git a/docs/arch/examples/cheers-plugin-sdk.js b/docs/arch/examples/cheers-plugin-sdk.js index 46a555a0..94b9410b 100644 --- a/docs/arch/examples/cheers-plugin-sdk.js +++ b/docs/arch/examples/cheers-plugin-sdk.js @@ -74,6 +74,11 @@ function cheersPlugin(opts) { // desk file, attachment). Fire-and-forget; hosts without support ignore it. parent.postMessage({ type: "cheers:open", uri: uri }, "*"); }, + compose: function (text) { + // PREFILL the channel composer with a suggested message — never sends; the + // human reviews and presses send. Fire-and-forget; unsupported hosts ignore it. + parent.postMessage({ type: "cheers:compose", text: text }, "*"); + }, }; parent.postMessage({ type: "cheers:ready" }, "*"); return api; diff --git a/docs/arch/examples/codemap.plugin.html b/docs/arch/examples/codemap.plugin.html index f0ea912e..94297a80 100644 --- a/docs/arch/examples/codemap.plugin.html +++ b/docs/arch/examples/codemap.plugin.html @@ -943,7 +943,10 @@ return '
' + (out ? "→" : "←") + '' + '' + esc(other.label) + "" + '' + (x.e.kind === "data" ? "数据" : "调用") + (x.e.label ? " · " + esc(x.e.label) : "") + "
"; - }).join("") || '
') + ""; + }).join("") || '
') + "" + + + '
' + + '
预填聊天输入框(cheers:compose),不代发——检查后自己按发送
'; drawer.classList.add("open"); drawer.querySelector("#dClose").addEventListener("click", function () { drawer.classList.remove("open"); selected = null; render(); }); @@ -959,6 +962,17 @@ }); var copyBtn = drawer.querySelector("#copyLoc"); if (copyBtn) copyBtn.addEventListener("click", function () { copyText(String(n.loc)); }); + drawer.querySelector("#askBot").addEventListener("click", function () { + // 提及从节点 loc 的 handle 推导(写 loc 的 bot = 懂这块代码的 bot);没有 loc 就 + // 不带提及,让用户自己 @。预填不代发——发送权始终在人。 + var hm = n.loc ? /^cheers:ws\/@([^/]+)\//.exec(String(n.loc)) : null; + var text = (hm ? "@" + hm[1] + " " : "") + + "请讲讲 " + n.label + "(codemap 节点 " + n.id + "):它负责什么、最近的改动、" + + "有什么要注意的?先读 codemap/map.yaml 里它的条目和注释再回答。"; + if (DEMO) { toast("demo:将预填「" + esc(text.slice(0, 48)) + "…」"); return; } + parent.postMessage({ type: "cheers:compose", text: text }, "*"); + toast("已预填到输入框——检查后按发送(需 host 支持 cheers:compose)"); + }); drawer.querySelector("#saveSum").addEventListener("click", function () { saveField(id, "summary", drawer.querySelector("#sumEdit").value.trim()); }); diff --git a/docs/developer/PLUGIN_DEVELOPMENT.md b/docs/developer/PLUGIN_DEVELOPMENT.md index 17660245..d77cd861 100644 --- a/docs/developer/PLUGIN_DEVELOPMENT.md +++ b/docs/developer/PLUGIN_DEVELOPMENT.md @@ -141,6 +141,7 @@ a specific origin; the host, in turn, only accepts messages from your iframe). | plugin → host | `cheers:resource` | `{ reqId, resource, params }` | Read-only channel info (whitelist, §5.4). `reqId` is your correlation number. | | host → plugin | `cheers:resource:result` | `{ reqId, ok, data\|error }` | Resource read result. | | plugin → host | `cheers:open` | `{ uri }` | Ask the host to navigate the **user's view** to a `cheers:` locator — `cheers:ws//#L[-L]` opens the workspace browser at that file/line (after an existence probe), `cheers:desk/` focuses the workbench file browser, `cheers:inbox/` opens channel files. Fire-and-forget (no reply). Pure UI routing: the host parses strictly, every read behind the jump passes the normal authz, and an unresolvable locator shows the user an error. Hosts that don't support it ignore the message (the protocol-1 growth rule) — safe to send unconditionally. | +| plugin → host | `cheers:compose` | `{ text }` | **PREFILL** the channel composer with a suggested message — **never sends**. An empty draft is filled; a typed draft gets the text appended on a new line (user text is never lost); `@label` tokens matching channel members register as routable mentions. The human reviews, edits, and presses send — that keystroke is what turns a plugin suggestion into a channel action, keeping side effects human-in-the-loop and audit-visible. Shape-gated by the host (≤4000 chars, control chars stripped). Fire-and-forget; unsupported hosts ignore it. | ### 5.2 Lifecycle diff --git a/frontend/src/features/chat/ChannelView.tsx b/frontend/src/features/chat/ChannelView.tsx index 929e6790..22275b56 100644 --- a/frontend/src/features/chat/ChannelView.tsx +++ b/frontend/src/features/chat/ChannelView.tsx @@ -621,6 +621,8 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P const [settingsOpen, setSettingsOpen] = useState(false); const [wsOpen, setWsOpen] = useState(false); const [wsInit, setWsInit] = useState<{ botId?: string; path?: string; line?: number }>({}); + // Composer prefill (a plugin's cheers:compose — G4): suggestion only, never a send. + const [composePrefill, setComposePrefill] = useState<{ text: string; seq: number } | null>(null); const [filesFocus, setFilesFocus] = useState(undefined); // The work lane is the bounded canvas the instrument windows drag/resize + @@ -958,6 +960,12 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P [channel, botLabels] ); + // A renderer plugin suggested a message (cheers:compose). Prefill only — the human + // reviews, edits, and presses send; that keystroke is what makes it a channel action. + const composeMessage = useCallback((text: string) => { + setComposePrefill((p) => ({ text, seq: (p?.seq ?? 0) + 1 })); + }, []); + const handleSend = useCallback( async ( content: string, @@ -1474,6 +1482,7 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P toolbar={composerToolbar} onMentionsChange={setMentionedBots} onTextChange={setDraftText} + prefill={composePrefill} streamingCount={streamingIds.length} onStopStreaming={stopStreaming} onSend={handleSend} @@ -1558,6 +1567,7 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P openFilePath={wbTarget} filesTick={boardTick.files} onOpenLocator={openLocator} + onCompose={composeMessage} /> {/* Channel files lives in the lane too, so it floats/drags/resizes like the diff --git a/frontend/src/features/chat/MessageComposer.tsx b/frontend/src/features/chat/MessageComposer.tsx index aa1bac2b..4c009b16 100644 --- a/frontend/src/features/chat/MessageComposer.tsx +++ b/frontend/src/features/chat/MessageComposer.tsx @@ -74,6 +74,13 @@ interface Props { /** Fires with the raw draft text on change, so the parent can derive suggested context (F3 — e.g. a filename mentioned in the draft). */ onTextChange?: (text: string) => void; + /** External PREFILL request (a renderer plugin's cheers:compose, or any host + surface that suggests a message). Never sends: an empty draft is filled, a + typed draft gets the text appended on a new line — user text is never lost. + `seq` bumps per request so the same text can be prefilled again. Any `@label` + tokens matching `mentionables` are registered as picked mentions, so the + suggested command routes to its bot when the user presses send. */ + prefill?: { text: string; seq: number } | null; /** Bot turns currently streaming in the channel. With an empty draft the send button morphs into Stop; a typed draft always keeps Send (concurrent sends are legal in a channel chat). */ @@ -138,6 +145,7 @@ function MessageComposerImpl({ toolbar, onMentionsChange, onTextChange, + prefill, streamingCount = 0, onStopStreaming, onSend, @@ -158,6 +166,32 @@ function MessageComposerImpl({ const [libraryOpen, setLibraryOpen] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); + + // External prefill (see Props.prefill): fill empty / append to typed, register any + // @label mentions so routing works, focus with the cursor at the end. NEVER sends. + const prefillSeq = useRef(0); + useEffect(() => { + if (!prefill || prefill.seq === prefillSeq.current) return; + prefillSeq.current = prefill.seq; + setText((t) => (t.trim() ? `${t}\n${prefill.text}` : prefill.text)); + const mentioned = mentionables.filter((m) => prefill.text.includes(`@${m.label}`)); + if (mentioned.length) { + setPicked((prev) => { + const have = new Set(prev.map((p) => p.id)); + return [...prev, ...mentioned.filter((m) => !have.has(m.id))]; + }); + } + const el = textareaRef.current; + if (el) { + el.focus(); + requestAnimationFrame(() => { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + el.setSelectionRange(el.value.length, el.value.length); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefill]); const attachRef = useRef(null); // Voice-to-deaf-bot guard state (used further below): audio attachments the // transcribe-then-send flow completed, and the paused-send warning. diff --git a/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx b/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx index 8fa05f53..5e6f46fe 100644 --- a/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx +++ b/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx @@ -31,6 +31,9 @@ interface Props { * host API). Owned by ChannelView — it holds every jump surface (workspace dialog, * channel files, this drawer's own deep-link). */ onOpenLocator?: (uri: string) => void; + /** Prefill the channel composer (the renderer plugins' cheers:compose host API). + * Never sends — owned by ChannelView, which holds the composer. */ + onCompose?: (text: string) => void; } interface WbConfig { @@ -93,7 +96,7 @@ function parseCfg(content: string): WbConfig { // Installing global templates / plugins lives in Settings → Workbench extensions (admin); // the drawer only CONSUMES them, and offers a no-persistence temporary upload to anyone // (.json template or .html plugin — the plugin dev loop). -function WorkbenchDrawerImpl({ open, onClose, channelId, sendResourceReq, openFilePath, filesTick, onOpenLocator }: Props) { +function WorkbenchDrawerImpl({ open, onClose, channelId, sendResourceReq, openFilePath, filesTick, onOpenLocator, onCompose }: Props) { const fs = useMemo(() => makeFsClient(sendResourceReq, channelId), [sendResourceReq, channelId]); const [cfg, setCfg] = useState({}); const [globalTemplates, setGlobalTemplates] = useState([]); @@ -382,8 +385,9 @@ function WorkbenchDrawerImpl({ open, onClose, channelId, sendResourceReq, openFi openTarget: focus, filesTick, openLocator: onOpenLocator, + composeMessage: onCompose, }), - [channelId, fs, sendResourceReq, pinned, togglePin, plugins, bindings, setBinding, configs, focus, filesTick, onOpenLocator] + [channelId, fs, sendResourceReq, pinned, togglePin, plugins, bindings, setBinding, configs, focus, filesTick, onOpenLocator, onCompose] ); // Desktop: the original card chrome, placed in the work area — hidden (but diff --git a/frontend/src/features/chat/workbench/context.ts b/frontend/src/features/chat/workbench/context.ts index dbda76e3..c72c04d5 100644 --- a/frontend/src/features/chat/workbench/context.ts +++ b/frontend/src/features/chat/workbench/context.ts @@ -32,6 +32,10 @@ export interface WorkbenchContext { * features/chat/locator.ts). Handed to renderer plugins as the cheers:open host * API; implemented by ChannelView, which owns every jump surface. UI routing only. */ openLocator?: (uri: string) => void; + /** PREFILL the channel composer with a suggested message (the cheers:compose host + * API). Never sends — the human reviews and presses send; that keystroke is what + * turns a plugin suggestion into a channel action. */ + composeMessage?: (text: string) => void; /** Live-push tick for the Desk ("files" board): bump → the browser re-pulls the tree * and reloads a clean open file (unsaved edits are never clobbered). */ filesTick?: number; diff --git a/frontend/src/features/chat/workbench/renderers/RendererHost.tsx b/frontend/src/features/chat/workbench/renderers/RendererHost.tsx index b9aaf8cd..bffce113 100644 --- a/frontend/src/features/chat/workbench/renderers/RendererHost.tsx +++ b/frontend/src/features/chat/workbench/renderers/RendererHost.tsx @@ -53,6 +53,7 @@ export function RendererHost({ path={path} readChannel={readChannel} onOpen={ctx.openLocator} + onCompose={ctx.composeMessage} /> ); } diff --git a/frontend/src/features/chat/workbench/sandbox/SandboxRenderer.tsx b/frontend/src/features/chat/workbench/sandbox/SandboxRenderer.tsx index 69b07eeb..37513f05 100644 --- a/frontend/src/features/chat/workbench/sandbox/SandboxRenderer.tsx +++ b/frontend/src/features/chat/workbench/sandbox/SandboxRenderer.tsx @@ -20,6 +20,7 @@ export function SandboxRenderer({ path, readChannel, onOpen, + onCompose, }: { fs: FsClient; plugin: PluginMeta; @@ -31,6 +32,9 @@ export function SandboxRenderer({ * routing — the host parses/validates the locator and every read behind the jump * still passes the existing authz; the plugin gains no data access from this. */ onOpen?: (uri: string) => void; + /** host API: PREFILL the channel composer (cheers:compose). Never sends — the human + * reviews and presses send, which is what turns the suggestion into an action. */ + onCompose?: (text: string) => void; }) { const [bundle, setBundle] = useState(null); const [err, setErr] = useState(null); @@ -86,6 +90,7 @@ export function SandboxRenderer({ resource?: string; params?: Record; uri?: string; + text?: string; }; if (!m || typeof m !== "object") return; if (m.type === "cheers:ready") { @@ -108,6 +113,15 @@ export function SandboxRenderer({ // handler — pre-existing plugins may send this before every surface supports it. const uri = typeof m.uri === "string" ? m.uri.trim() : ""; if (onOpen && uri.startsWith("cheers:") && uri.length <= 2048) onOpen(uri); + } else if (m.type === "cheers:compose") { + // host API: PREFILL the channel composer — never send. Shape-gated (string, + // control chars stripped, length cap); the human reviews and presses send. + const text = + typeof m.text === "string" + ? // eslint-disable-next-line no-control-regex + m.text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim() + : ""; + if (onCompose && text && text.length <= 4000) onCompose(text); } else if (m.type === "cheers:unsupported") { // the renderer inspected the content and can't render it (its final judgment) setUnsupported(typeof m.reason === "string" ? m.reason : ""); @@ -130,7 +144,7 @@ export function SandboxRenderer({ }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); - }, [fs, plugin.plugin_id, rendererId, path, readChannel, onOpen]); + }, [fs, plugin.plugin_id, rendererId, path, readChannel, onOpen, onCompose]); if (err) return
Failed to load renderer: {err}
; if (bundle === null) return
Loading renderer…
; From cf3a57d531778c2cc3d48e93a9cad85d041cc539 Mon Sep 17 00:00:00 2001 From: haowei Date: Fri, 17 Jul 2026 12:45:34 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(arch):=20ops-board=20interactive=20moc?= =?UTF-8?q?kup=20=E2=80=94=20approved=20v3=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatOps server dashboard over an agent-maintained ops/servers.yaml: control-machine model (bot = the machine that SSHes into servers, via routing, unreachable≠down), lean cards (status badges + flags, details in a drawer), lucide-style icons, deploy via cheers:compose prefill. Design artifact for the upcoming ops-board plugin/template pair. Co-Authored-By: Claude Fable 5 --- docs/arch/opsboard-mockup.html | 429 +++++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/arch/opsboard-mockup.html diff --git a/docs/arch/opsboard-mockup.html b/docs/arch/opsboard-mockup.html new file mode 100644 index 00000000..c2c27b39 --- /dev/null +++ b/docs/arch/opsboard-mockup.html @@ -0,0 +1,429 @@ + + + + + +Ops Board · 运维看板设计稿 v3(克制卡片 + 图标) + + + +
+ +
+ 工作台 · #ops + ops/servers.yaml + 渲染器:运维看板 · ops-board ▾ + 设计稿 mockup · 样例数据 +
+ +
+ + + + + + @ops 维护 · 2 分钟前 · v31 + + +
+ +
+
+ + + + +
+ + servers:1 + 体积22.8 KB / 256 KB +
+
+
+ + + + From 0b23d4dff4b25ba1b9a3d437139519416ae99fb8 Mon Sep 17 00:00:00 2001 From: haowei Date: Fri, 17 Jul 2026 12:45:45 +0800 Subject: [PATCH 4/4] feat(website): plugins gallery page with official + example downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New website/plugins.html — the plugin column: what a workbench plugin is (one sandboxed HTML file), the three-step try/install/preinstalled story, a gallery of the four official plugins (descriptions from their actual manifests) and the docs/arch examples (codemap, paper tracker, code review board, starter kit + SDK), the one-line security model, and links into the dev guide + normative spec. Download links serve from ./downloads/plugins/*, assembled by pages.yml at build time from the sources of truth (server/assets/workbench-plugins + docs/arch/examples) — no duplicate copies in git. The workflow also triggers on changes to those paths so downloads stay fresh. Every page's nav gains a Plugins entry; plugin-dev's self-link now points here. Co-Authored-By: Claude Fable 5 --- .github/workflows/pages.yml | 14 ++ website/connector.html | 1 + website/connector.zh-CN.html | 1 + website/index.html | 1 + website/index.zh-CN.html | 1 + website/mcp.html | 2 +- website/plugin-dev.html | 2 +- website/plugins.html | 341 +++++++++++++++++++++++++++++++++++ 8 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 website/plugins.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 214b315b..a305479c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -10,6 +10,9 @@ on: paths: - "website/**" - ".github/workflows/pages.yml" + # the plugins page serves these as downloads (assembled at build time below) + - "server/assets/workbench-plugins/**" + - "docs/arch/examples/**" workflow_dispatch: permissions: @@ -34,6 +37,17 @@ jobs: - name: Configure Pages uses: actions/configure-pages@v6 + # plugins.html links to ./downloads/plugins/* — assembled here from the + # sources of truth (official set + docs examples), never duplicated in git. + - name: Assemble plugin downloads + run: | + mkdir -p website/downloads/plugins + cp server/assets/workbench-plugins/*.plugin.html website/downloads/plugins/ + cp docs/arch/examples/*.plugin.html \ + docs/arch/examples/*.template.json \ + docs/arch/examples/cheers-plugin-sdk.js \ + website/downloads/plugins/ + - name: Upload site artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/website/connector.html b/website/connector.html index 8a99e454..1d96b8d8 100644 --- a/website/connector.html +++ b/website/connector.html @@ -151,6 +151,7 @@ Features For Users Quick Start + Plugins Docs