@4399ywkf/editor-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # @4399ywkf/editor-mcp
2
+
3
+ 把 [`@4399ywkf/editor`](../editor) 的结构化编辑面接出去,让 AI 操作**浏览器里那个真实的
4
+ tiptap 实例** —— AI 改的和用户看的是同一个。
5
+
6
+ ## 分层
7
+
8
+ MCP 只是最外面那一层的一种传输。中间两层跟它没关系:
9
+
10
+ | 层 | 在哪 | 知道 MCP 吗 |
11
+ |---|---|---|
12
+ | runtime:LiteXML 编解码 + 8 类操作 | `@4399ywkf/editor/doc-runtime` | 不知道 |
13
+ | 工具面:name / JSON Schema / description | 本包 `./tools` | 不知道 |
14
+ | 传输:浏览器桥 / hub / stdio MCP | 本包 `./bridge` 与 `bin/` | 知道 |
15
+
16
+ runtime 住在编辑器包里而不是这儿,是因为 `NODE_TAG` 镜像的是**那个编辑器自己的
17
+ schema**。跨包之后每加一个节点类型,序列化就静默降级成 `<node type="..."/>` 占位符 ——
18
+ 不报错、不丢数据、也不告诉你。
19
+
20
+ ## 用法一:不走 MCP(推荐给已有 agent loop 的项目)
21
+
22
+ jizhi_ai 那种 `builtin-tool-*` executor 直接调的形态,只要工具定义和 runtime,
23
+ 一行传输代码都不需要:
24
+
25
+ ```ts
26
+ import { DOC_TOOLS } from "@4399ywkf/editor-mcp/tools"
27
+ import { DocRuntime } from "@4399ywkf/editor/doc-runtime"
28
+
29
+ const runtime = new DocRuntime()
30
+ // <NotionEditor onEditorReady={(editor) => runtime.setEditor(editor)} />
31
+
32
+ // 把 DOC_TOOLS 喂给模型,回调里:
33
+ const result = await runtime.call(toolName, args)
34
+ ```
35
+
36
+ `DocRuntime` 的方法名对齐了 jizhi_ai 的 `@jizhi/editor-runtime`
37
+ (`getPageContent` / `modifyNodes` / `replaceText` / `setEditor` / `isReady`),
38
+ 两套编辑器可以被同一套工具面驱动。
39
+
40
+ ## 用法二:走 MCP
41
+
42
+ ```
43
+ agent ──stdio MCP──▶ ywkf-editor-mcp ──POST /api/call──▶ ywkf-editor-hub ──WS──▶ 浏览器
44
+ (只转发) (中继) (执行体)
45
+ ```
46
+
47
+ **1. 页面侧接上桥**
48
+
49
+ ```tsx
50
+ import { DocRuntime } from "@4399ywkf/editor/doc-runtime"
51
+ import { attachBridge } from "@4399ywkf/editor-mcp/bridge"
52
+
53
+ const runtime = useMemo(() => new DocRuntime(), [])
54
+
55
+ useEffect(
56
+ () => attachBridge({
57
+ runtime,
58
+ url: "ws://127.0.0.1:4399/bridge",
59
+ onStatus: (s) => setConn(s), // connecting | primary | observer | disconnected
60
+ }),
61
+ [runtime],
62
+ )
63
+
64
+ <NotionEditor onEditorReady={(editor) => runtime.setEditor(editor)} />
65
+ ```
66
+
67
+ 样式记得引一次 `@4399ywkf/editor/styles`(AI 改动块的闪烁提示 `.ai-flash` 在里面)。
68
+
69
+ **2. 起 hub**
70
+
71
+ ```bash
72
+ npx ywkf-editor-hub --port 4399 --token $(openssl rand -hex 16)
73
+ ```
74
+
75
+ 默认只听 `127.0.0.1`。`--token` 可选但建议开:这个口的权限是「以用户身份重写他正在
76
+ 编辑的文档」,不开的话本机任何进程都能调。`--static <dir>` 可以顺便托管一个页面,
77
+ 本地调试方便。
78
+
79
+ **3. 注册 MCP**
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "ywkf-editor": {
85
+ "command": "npx",
86
+ "args": ["ywkf-editor-mcp", "--url", "http://127.0.0.1:4399"],
87
+ "env": { "EDITOR_MCP_TOKEN": "刚才那个 token" }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ > codex `exec` 下需要 `--dangerously-bypass-approvals-and-sandbox`:`approval: never`
94
+ > 会把 MCP 调用直接判成 "user cancelled"。交互式 `codex` 里可以逐次批准。
95
+
96
+ ## 工具(8 个)
97
+
98
+ | 工具 | 用途 |
99
+ |---|---|
100
+ | `doc_read` | 读全文,xml / json / both |
101
+ | `doc_schema` | 运行时反射出的节点·标记·标签映射·哪些类型带 id。**永不与实现漂移** |
102
+ | `doc_find` | 检索,返回可直接回填的 nodeId |
103
+ | `doc_get_selection` | 用户此刻的选区;无选区时显式声明「不要沿用历史」 |
104
+ | `doc_replace_text` | 块内文本替换(首选),只改命中的那一段,其余格式保住 |
105
+ | `doc_format_text` | 加/去 bold·italic·underline·strike·code·sup·sub(改格式首选) |
106
+ | `doc_modify_nodes` | 结构化 insert / remove / modify |
107
+ | `doc_table_edit` | 表格:增删行列、合并拆分、批量写值 |
108
+
109
+ 刻意保持小。参照系:BlockNote AI 只有 3 个工具;把 tiptap 每个 command 都包成工具的
110
+ `tiptap-apcore` 做到 79 个,GitHub 1 star。
111
+
112
+ 工具描述里的可用标签清单是从 runtime 的标签表**生成**的,不是手抄的 —— 编辑器加一个
113
+ 节点类型,description 自动跟着变(`src/tools.test.ts` 守这条)。
114
+
115
+ ## 两条硬规则
116
+
117
+ LiteXML 对 ProseMirror JSON 是**有损压缩**(省 1.55x 以上 token),所以:
118
+
119
+ 1. **`modify` 以原节点 attrs 为底做合并**,只让 XML 里出现过的键覆盖。否则
120
+ `textAlign` / `backgroundColor` / `language` 会被静默抹掉 ——
121
+ 「AI 改一个词顺手毁排版」。
122
+ 2. **未知节点降级成 `<node type="..."/>`**,不抛错、不丢失。
123
+
124
+ 原则:**读可以有损压缩,写绝不要求模型重述它没看见的状态。**
125
+ 所以 `doc_replace_text` / `doc_format_text`(外科手术式)优先于
126
+ `doc_modify_nodes`(整块替换)。
127
+
128
+ ## 已知限制
129
+
130
+ 都源自同一件事:**文档活在浏览器内存里**。
131
+
132
+ 1. **刷新页面文档就回到初始内容。** 真架构里文档应该活在浏览器之外(yjs / 服务端持有)。
133
+ 2. **多标签页 = 多份互不相干的文档。** 现在是「第一个连上的持有桥,后开的降级旁观」,
134
+ `onStatus` 会回 `observer`,请在界面上显示出来。接了 yjs 之后自然消失。
135
+ 3. **没有 AI 编辑的审计与回滚。** ProseMirror 的 Step 可 invert,应该持久化 inverted
136
+ steps 做 `doc_revert_turn`,目前没做 —— 想兜底的话用 `attachBridge` 的 `onCall`
137
+ 钩子自己存快照。
138
+
139
+ ## 开发
140
+
141
+ ```bash
142
+ pnpm --filter @4399ywkf/editor build # tools.ts 依赖它的标签表
143
+ pnpm --filter @4399ywkf/editor-mcp test # hub 端到端 + 工具面形状
144
+ ```
package/bin/hub.mjs ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hub —— 纯中继。
4
+ *
5
+ * POST /api/call MCP 进程(或任何 HTTP 调用方)从这儿进来
6
+ * WS /bridge 浏览器里的 attachBridge 反连上来,真正的执行体
7
+ * GET /api/health 桥是否就绪
8
+ * GET /* 可选:--static 给了目录才提供静态页
9
+ *
10
+ * 它不持有文档、不认识 tiptap、也不认识 MCP —— 只负责把一次调用送到浏览器再把
11
+ * 结果送回来。文档活在浏览器那个 editor 实例里(这也带来两个已知限制,见 README)。
12
+ *
13
+ * ywkf-editor-hub [--port 4399] [--host 127.0.0.1] [--static ./dist] [--token xxx]
14
+ */
15
+ import { createServer } from "node:http"
16
+ import { readFile } from "node:fs/promises"
17
+ import { extname, join, normalize, resolve } from "node:path"
18
+ import { WebSocketServer } from "ws"
19
+
20
+ /* ------------------------------ 参数 ------------------------------ */
21
+
22
+ const argv = process.argv.slice(2)
23
+ const flag = (name, fallback) => {
24
+ const i = argv.indexOf(`--${name}`)
25
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback
26
+ }
27
+
28
+ const PORT = Number(flag("port", process.env.PORT ?? 4399))
29
+ /**
30
+ * 默认只听回环。spike 阶段裸听 0.0.0.0 意味着同网段任何机器都能改用户的文档 ——
31
+ * 这东西的权限是「以用户身份重写他正在编辑的内容」,不该默认对外。
32
+ */
33
+ const HOST = flag("host", process.env.HOST ?? "127.0.0.1")
34
+ const STATIC = flag("static", process.env.EDITOR_MCP_STATIC ?? null)
35
+ const TOKEN = flag("token", process.env.EDITOR_MCP_TOKEN ?? null)
36
+ const TIMEOUT = Number(flag("timeout", process.env.EDITOR_MCP_TIMEOUT ?? 15_000))
37
+
38
+ const MIME = {
39
+ ".html": "text/html; charset=utf-8",
40
+ ".js": "text/javascript; charset=utf-8",
41
+ ".mjs": "text/javascript; charset=utf-8",
42
+ ".css": "text/css; charset=utf-8",
43
+ ".json": "application/json; charset=utf-8",
44
+ ".map": "application/json",
45
+ ".png": "image/png",
46
+ ".jpg": "image/jpeg",
47
+ ".jpeg": "image/jpeg",
48
+ ".gif": "image/gif",
49
+ ".webp": "image/webp",
50
+ ".svg": "image/svg+xml",
51
+ ".woff": "font/woff",
52
+ ".woff2": "font/woff2",
53
+ }
54
+
55
+ /* ------------------------------ 桥 ------------------------------ */
56
+
57
+ /**
58
+ * 多标签页:**第一个连上的实例持有桥**,后开的降级为旁观者。
59
+ *
60
+ * 浏览器执行器模式下「文档」等于「某个页面里的那个 editor 实例」,两个标签页就是
61
+ * 两份互不相干的文档。若让后连的抢走桥,agent 就会对着一个和用户正在看的不同的
62
+ * 实例干活 —— 腾讯 skill 里反复告诫的「不要对已 present 的文档重复 open_file」
63
+ * 说的就是这件事。真正的解法是让文档活在浏览器之外(yjs / 服务端持有)。
64
+ */
65
+ const sockets = []
66
+ const primary = () => sockets.find((s) => s.readyState === 1) ?? null
67
+ const pending = new Map()
68
+ let seq = 0
69
+
70
+ function callBrowser(tool, args, timeoutMs) {
71
+ return new Promise((resolve, reject) => {
72
+ const browser = primary()
73
+ if (!browser) {
74
+ reject(new Error(`编辑器页面未连接:请确认页面已调用 attachBridge() 连到本 hub(:${PORT})`))
75
+ return
76
+ }
77
+ const id = ++seq
78
+ const timer = setTimeout(() => {
79
+ pending.delete(id)
80
+ reject(new Error(`工具 ${tool} 超时(${timeoutMs}ms)`))
81
+ }, timeoutMs)
82
+ pending.set(id, { resolve, reject, timer })
83
+ browser.send(JSON.stringify({ id, tool, args }))
84
+ })
85
+ }
86
+
87
+ function readBody(req) {
88
+ return new Promise((resolve, reject) => {
89
+ const chunks = []
90
+ req.on("data", (c) => chunks.push(c))
91
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
92
+ req.on("error", reject)
93
+ })
94
+ }
95
+
96
+ const authorized = (req, url) => {
97
+ if (!TOKEN) return true
98
+ const header = req.headers.authorization ?? ""
99
+ if (header === `Bearer ${TOKEN}`) return true
100
+ return url.searchParams.get("token") === TOKEN
101
+ }
102
+
103
+ /* ------------------------------ HTTP ------------------------------ */
104
+
105
+ const staticRoot = STATIC ? resolve(STATIC) : null
106
+
107
+ const server = createServer(async (req, res) => {
108
+ const url = new URL(req.url, `http://${req.headers.host}`)
109
+ const json = (code, body) => {
110
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" })
111
+ res.end(JSON.stringify(body))
112
+ }
113
+
114
+ if (url.pathname === "/api/health") {
115
+ json(200, {
116
+ ok: true,
117
+ browserConnected: Boolean(primary()),
118
+ instances: sockets.filter((s) => s.readyState === 1).length,
119
+ authRequired: Boolean(TOKEN),
120
+ })
121
+ return
122
+ }
123
+
124
+ if (url.pathname === "/api/call" && req.method === "POST") {
125
+ if (!authorized(req, url)) {
126
+ json(401, { ok: false, error: "未授权:hub 开了 --token,请带 Authorization: Bearer <token>" })
127
+ return
128
+ }
129
+ try {
130
+ const { tool, args, timeout } = JSON.parse(await readBody(req))
131
+ const result = await callBrowser(tool, args, Number(timeout) || TIMEOUT)
132
+ json(200, { ok: true, result })
133
+ } catch (e) {
134
+ // 故意返回 200 + ok:false:调用方是 MCP 进程,它要的是错误文本本身
135
+ json(200, { ok: false, error: String(e?.message ?? e) })
136
+ }
137
+ return
138
+ }
139
+
140
+ if (!staticRoot) {
141
+ json(404, { ok: false, error: "hub 是纯中继,没有开静态页(要开的话加 --static <dir>)" })
142
+ return
143
+ }
144
+
145
+ // 静态页:normalize + 前缀校验,挡穿越
146
+ const rel = normalize(url.pathname === "/" ? "/index.html" : url.pathname)
147
+ const file = join(staticRoot, rel)
148
+ if (!file.startsWith(staticRoot)) {
149
+ res.writeHead(403).end("forbidden")
150
+ return
151
+ }
152
+ try {
153
+ const buf = await readFile(file)
154
+ res.writeHead(200, {
155
+ "content-type": MIME[extname(file)] ?? "application/octet-stream",
156
+ "cache-control": "no-store",
157
+ })
158
+ res.end(buf)
159
+ } catch {
160
+ res.writeHead(404).end("not found")
161
+ }
162
+ })
163
+
164
+ /* ------------------------------ WS ------------------------------ */
165
+
166
+ function announceRoles() {
167
+ const holder = primary()
168
+ for (const s of sockets) {
169
+ if (s.readyState !== 1) continue
170
+ s.send(JSON.stringify({ type: "role", role: s === holder ? "primary" : "observer" }))
171
+ }
172
+ }
173
+
174
+ const wss = new WebSocketServer({ server, path: "/bridge" })
175
+
176
+ wss.on("connection", (socket, req) => {
177
+ const url = new URL(req.url, `http://${req.headers.host}`)
178
+ if (!authorized(req, url)) {
179
+ socket.close(1008, "未授权")
180
+ return
181
+ }
182
+
183
+ sockets.push(socket)
184
+ console.error(`[hub] 浏览器已连接(当前 ${sockets.filter((s) => s.readyState === 1).length} 个实例)`)
185
+ announceRoles()
186
+
187
+ socket.on("message", (raw) => {
188
+ let msg
189
+ try {
190
+ msg = JSON.parse(raw.toString())
191
+ } catch {
192
+ return
193
+ }
194
+ const slot = pending.get(msg.id)
195
+ if (!slot) return
196
+ clearTimeout(slot.timer)
197
+ pending.delete(msg.id)
198
+ slot.resolve(msg.result)
199
+ })
200
+
201
+ socket.on("close", () => {
202
+ const i = sockets.indexOf(socket)
203
+ if (i !== -1) sockets.splice(i, 1)
204
+ console.error("[hub] 浏览器断开,桥移交给下一个实例")
205
+ announceRoles()
206
+ })
207
+ })
208
+
209
+ server.listen(PORT, HOST, () => {
210
+ // 打真实端口而不是 PORT —— --port 0 时内核才刚分配,打 0 是骗人的
211
+ const port = server.address().port
212
+ console.error(`[hub] 监听 http://${HOST}:${port}`)
213
+ console.error(`[hub] 浏览器桥 ws://${HOST}:${port}/bridge`)
214
+ console.error(`[hub] 转发口 POST http://${HOST}:${port}/api/call`)
215
+ if (staticRoot) console.error(`[hub] 静态页 ${staticRoot}`)
216
+ if (!TOKEN) console.error("[hub] 未开鉴权:本机任何进程都能改文档。要收紧就加 --token")
217
+ })
package/bin/stdio.mjs ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stdio MCP server —— 纯转发。
4
+ *
5
+ * agent ──stdio MCP──▶ 本进程 ──POST /api/call──▶ hub ──WS /bridge──▶ 浏览器里的 tiptap 实例
6
+ * (只转发) (中继) (真正的执行体)
7
+ *
8
+ * 这个进程不管编辑器生命周期,也不认识 tiptap。工具定义从 ../dist/tools.js 来,
9
+ * 那份定义又从 doc-runtime 的标签表生成 —— 所以「模型看到的能力」和
10
+ * 「运行时真有的能力」是同一个源头。
11
+ *
12
+ * ywkf-editor-mcp [--url http://127.0.0.1:4399] [--token xxx]
13
+ */
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js"
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
16
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
17
+
18
+ import { DOC_TOOLS } from "../dist/tools.js"
19
+
20
+ const argv = process.argv.slice(2)
21
+ const flag = (name, fallback) => {
22
+ const i = argv.indexOf(`--${name}`)
23
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback
24
+ }
25
+
26
+ const HUB = flag(
27
+ "url",
28
+ process.env.EDITOR_MCP_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4399}`,
29
+ )
30
+ const TOKEN = flag("token", process.env.EDITOR_MCP_TOKEN ?? null)
31
+
32
+ async function callHub(tool, args) {
33
+ let res
34
+ try {
35
+ res = await fetch(`${HUB}/api/call`, {
36
+ method: "POST",
37
+ headers: {
38
+ "content-type": "application/json",
39
+ ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
40
+ },
41
+ body: JSON.stringify({ tool, args }),
42
+ })
43
+ } catch (e) {
44
+ throw new Error(`连不上 hub(${HUB})。先起 \`ywkf-editor-hub\`。原始错误:${e.message}`)
45
+ }
46
+ const body = await res.json()
47
+ if (!body.ok) throw new Error(body.error)
48
+ return body.result
49
+ }
50
+
51
+ const server = new Server(
52
+ { name: "ywkf-editor", version: "0.1.0" },
53
+ { capabilities: { tools: {} } },
54
+ )
55
+
56
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: DOC_TOOLS }))
57
+
58
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
59
+ const { name, arguments: args } = req.params
60
+ try {
61
+ const result = await callHub(name, args ?? {})
62
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }
63
+ } catch (e) {
64
+ // 错误信息本身就是给模型的教学,原样透给它,不要包装成「调用失败」
65
+ return { isError: true, content: [{ type: "text", text: String(e?.message ?? e) }] }
66
+ }
67
+ })
68
+
69
+ await server.connect(new StdioServerTransport())
70
+ process.stderr.write(`[ywkf-editor-mcp] stdio 就绪,转发到 ${HUB}\n`)
@@ -0,0 +1,61 @@
1
+ import { DocRuntime } from '@4399ywkf/editor/doc-runtime';
2
+
3
+ /**
4
+ * 浏览器侧的桥:把一个 `DocRuntime` 挂到 hub 的 WebSocket 上,
5
+ * 让 hub 转来的工具调用落在**用户此刻正在看的那个编辑器实例**上。
6
+ *
7
+ * 这是整套架构的关键约束:AI 改的和用户看的必须是同一个实例。形状照抄腾讯
8
+ * WorkBuddy 的 editor_sdk(一个进程同时提供编辑器页面和 MCP 端点),
9
+ * 区别是不用付它那 193MB 原生二进制的赎金。
10
+ *
11
+ * ```tsx
12
+ * const runtime = useMemo(() => new DocRuntime(), [])
13
+ * useEffect(() => attachBridge({ runtime, url: "ws://127.0.0.1:4399/bridge" }), [runtime])
14
+ * <NotionEditor onEditorReady={(e) => runtime.setEditor(e)} />
15
+ * ```
16
+ */
17
+
18
+ type BridgeStatus =
19
+ /** 正在连 hub */
20
+ "connecting"
21
+ /** 已连上且持有桥 —— 工具调用会打到这个实例 */
22
+ | "primary"
23
+ /** 已连上但另一个标签页持有桥 —— 本实例只旁观 */
24
+ | "observer"
25
+ /** 断开 */
26
+ | "disconnected";
27
+ interface AttachBridgeOptions {
28
+ runtime: DocRuntime;
29
+ /** hub 的 WS 地址。默认 `ws://<当前 host>/bridge`。 */
30
+ url?: string;
31
+ /** hub 开了 --token 时必须给,会作为 query 参数带上。 */
32
+ token?: string;
33
+ /** 状态变化回调,用来在界面上显示「● MCP 桥已连接」这类提示。 */
34
+ onStatus?: (status: BridgeStatus, detail?: string) => void;
35
+ /**
36
+ * 每次工具调用后触发,给业务侧做审计 / 埋点用。
37
+ *
38
+ * 说明:本桥**不提供 AI 编辑的撤销与回滚**。ProseMirror 的 Step 可以 invert,
39
+ * 真要做 `doc_revert_turn` 应该持久化 inverted steps —— 目前没做,
40
+ * 想兜底的话在这个钩子里自己存快照。
41
+ */
42
+ onCall?: (event: {
43
+ tool: string;
44
+ args: unknown;
45
+ result?: unknown;
46
+ error?: string;
47
+ }) => void;
48
+ /** 断线自动重连,默认 true。 */
49
+ reconnect?: boolean;
50
+ }
51
+ /**
52
+ * 连上 hub,返回 detach 函数。
53
+ *
54
+ * 多标签页:**第一个连上的实例持有桥**,后开的降级为旁观者。这不是偷懒,是这套
55
+ * 架构的固有性质 —— 浏览器执行器模式下「文档」等于「某个页面里的那个 editor 实例」,
56
+ * 两个标签页就是两份互不相干的文档。若让后连的抢走桥,agent 就会对着一个和用户
57
+ * 正在看的不同的实例干活。接了 yjs(文档活在浏览器之外)之后这个问题自然消失。
58
+ */
59
+ declare function attachBridge(options: AttachBridgeOptions): () => void;
60
+
61
+ export { type AttachBridgeOptions, type BridgeStatus, attachBridge };
package/dist/bridge.js ADDED
@@ -0,0 +1,3 @@
1
+ export { attachBridge } from './chunk-AFXUKOKO.js';
2
+ //# sourceMappingURL=bridge.js.map
3
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"bridge.js"}
@@ -0,0 +1,74 @@
1
+ // src/bridge.ts
2
+ function attachBridge(options) {
3
+ const { runtime, token, onStatus, onCall, reconnect = true } = options;
4
+ if (typeof WebSocket === "undefined") {
5
+ throw new Error("attachBridge 只能在浏览器里用:当前环境没有 WebSocket");
6
+ }
7
+ const url = options.url ?? defaultUrl();
8
+ const target = token ? `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}` : url;
9
+ let socket = null;
10
+ let retry = 0;
11
+ let closed = false;
12
+ let timer = null;
13
+ const status = (s, detail) => onStatus?.(s, detail);
14
+ const connect = () => {
15
+ if (closed) return;
16
+ status("connecting");
17
+ socket = new WebSocket(target);
18
+ socket.onopen = () => {
19
+ retry = 0;
20
+ status("primary");
21
+ };
22
+ socket.onclose = () => {
23
+ status("disconnected");
24
+ if (!reconnect || closed) return;
25
+ retry += 1;
26
+ timer = setTimeout(connect, Math.min(3e3, 300 * retry));
27
+ };
28
+ socket.onmessage = (ev) => {
29
+ let req;
30
+ try {
31
+ req = JSON.parse(String(ev.data));
32
+ } catch {
33
+ return;
34
+ }
35
+ if (req.type === "role") {
36
+ status(req.role === "primary" ? "primary" : "observer");
37
+ return;
38
+ }
39
+ const { id, tool, args } = req;
40
+ if (id == null || !tool) return;
41
+ const send = (payload) => {
42
+ if (socket?.readyState === WebSocket.OPEN) {
43
+ socket.send(JSON.stringify({ id, result: payload }));
44
+ }
45
+ };
46
+ Promise.resolve().then(() => runtime.call(tool, args ?? {})).then((result) => {
47
+ onCall?.({ tool, args, result });
48
+ send(result);
49
+ }).catch((e) => {
50
+ const error = String(e?.message ?? e);
51
+ onCall?.({ tool, args, error });
52
+ send({ error });
53
+ });
54
+ };
55
+ };
56
+ connect();
57
+ return () => {
58
+ closed = true;
59
+ if (timer) clearTimeout(timer);
60
+ socket?.close();
61
+ socket = null;
62
+ };
63
+ }
64
+ function defaultUrl() {
65
+ if (typeof location === "undefined") {
66
+ throw new Error("没有 location,请显式传 url(如 ws://127.0.0.1:4399/bridge)");
67
+ }
68
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
69
+ return `${proto}//${location.host}/bridge`;
70
+ }
71
+
72
+ export { attachBridge };
73
+ //# sourceMappingURL=chunk-AFXUKOKO.js.map
74
+ //# sourceMappingURL=chunk-AFXUKOKO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bridge.ts"],"names":[],"mappings":";AA8DO,SAAS,aAAa,OAAA,EAA0C;AACrE,EAAA,MAAM,EAAE,OAAA,EAAS,KAAA,EAAO,UAAU,MAAA,EAAQ,SAAA,GAAY,MAAK,GAAI,OAAA;AAE/D,EAAA,IAAI,OAAO,cAAc,WAAA,EAAa;AACpC,IAAA,MAAM,IAAI,MAAM,wCAAwC,CAAA;AAAA,EAC1D;AAEA,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,GAAA,IAAO,UAAA,EAAW;AACtC,EAAA,MAAM,MAAA,GAAS,KAAA,GACX,CAAA,EAAG,GAAG,GAAG,GAAA,CAAI,QAAA,CAAS,GAAG,CAAA,GAAI,MAAM,GAAG,CAAA,MAAA,EAAS,kBAAA,CAAmB,KAAK,CAAC,CAAA,CAAA,GACxE,GAAA;AAEJ,EAAA,IAAI,MAAA,GAA2B,IAAA;AAC/B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,IAAI,MAAA,GAAS,KAAA;AACb,EAAA,IAAI,KAAA,GAA8C,IAAA;AAElD,EAAA,MAAM,SAAS,CAAC,CAAA,EAAiB,MAAA,KAAoB,QAAA,GAAW,GAAG,MAAM,CAAA;AAEzE,EAAA,MAAM,UAAU,MAAM;AACpB,IAAA,IAAI,MAAA,EAAQ;AACZ,IAAA,MAAA,CAAO,YAAY,CAAA;AACnB,IAAA,MAAA,GAAS,IAAI,UAAU,MAAM,CAAA;AAE7B,IAAA,MAAA,CAAO,SAAS,MAAM;AACpB,MAAA,KAAA,GAAQ,CAAA;AACR,MAAA,MAAA,CAAO,SAAS,CAAA;AAAA,IAClB,CAAA;AAEA,IAAA,MAAA,CAAO,UAAU,MAAM;AACrB,MAAA,MAAA,CAAO,cAAc,CAAA;AACrB,MAAA,IAAI,CAAC,aAAa,MAAA,EAAQ;AAC1B,MAAA,KAAA,IAAS,CAAA;AACT,MAAA,KAAA,GAAQ,WAAW,OAAA,EAAS,IAAA,CAAK,IAAI,GAAA,EAAM,GAAA,GAAM,KAAK,CAAC,CAAA;AAAA,IACzD,CAAA;AAEA,IAAA,MAAA,CAAO,SAAA,GAAY,CAAC,EAAA,KAAqB;AACvC,MAAA,IAAI,GAAA;AACJ,MAAA,IAAI;AACF,QAAA,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,EAAA,CAAG,IAAI,CAAC,CAAA;AAAA,MAClC,CAAA,CAAA,MAAQ;AACN,QAAA;AAAA,MACF;AAEA,MAAA,IAAI,GAAA,CAAI,SAAS,MAAA,EAAQ;AACvB,QAAA,MAAA,CAAO,GAAA,CAAI,IAAA,KAAS,SAAA,GAAY,SAAA,GAAY,UAAU,CAAA;AACtD,QAAA;AAAA,MACF;AAEA,MAAA,MAAM,EAAE,EAAA,EAAI,IAAA,EAAM,IAAA,EAAK,GAAI,GAAA;AAC3B,MAAA,IAAI,EAAA,IAAM,IAAA,IAAQ,CAAC,IAAA,EAAM;AAEzB,MAAA,MAAM,IAAA,GAAO,CAAC,OAAA,KAAqB;AACjC,QAAA,IAAI,MAAA,EAAQ,UAAA,KAAe,SAAA,CAAU,IAAA,EAAM;AACzC,UAAA,MAAA,CAAO,IAAA,CAAK,KAAK,SAAA,CAAU,EAAE,IAAI,MAAA,EAAQ,OAAA,EAAS,CAAC,CAAA;AAAA,QACrD;AAAA,MACF,CAAA;AAIA,MAAA,OAAA,CAAQ,OAAA,EAAQ,CACb,IAAA,CAAK,MAAM,QAAQ,IAAA,CAAK,IAAA,EAAM,IAAA,IAAQ,EAAE,CAAC,CAAA,CACzC,IAAA,CAAK,CAAC,MAAA,KAAW;AAChB,QAAA,MAAA,GAAS,EAAE,IAAA,EAAM,IAAA,EAAM,MAAA,EAAQ,CAAA;AAC/B,QAAA,IAAA,CAAK,MAAM,CAAA;AAAA,MACb,CAAC,CAAA,CACA,KAAA,CAAM,CAAC,CAAA,KAAe;AACrB,QAAA,MAAM,KAAA,GAAQ,MAAA,CAAQ,CAAA,EAAa,OAAA,IAAW,CAAC,CAAA;AAC/C,QAAA,MAAA,GAAS,EAAE,IAAA,EAAM,IAAA,EAAM,KAAA,EAAO,CAAA;AAC9B,QAAA,IAAA,CAAK,EAAE,OAAO,CAAA;AAAA,MAChB,CAAC,CAAA;AAAA,IACL,CAAA;AAAA,EACF,CAAA;AAEA,EAAA,OAAA,EAAQ;AAER,EAAA,OAAO,MAAM;AACX,IAAA,MAAA,GAAS,IAAA;AACT,IAAA,IAAI,KAAA,eAAoB,KAAK,CAAA;AAC7B,IAAA,MAAA,EAAQ,KAAA,EAAM;AACd,IAAA,MAAA,GAAS,IAAA;AAAA,EACX,CAAA;AACF;AAEA,SAAS,UAAA,GAAqB;AAC5B,EAAA,IAAI,OAAO,aAAa,WAAA,EAAa;AACnC,IAAA,MAAM,IAAI,MAAM,oDAAoD,CAAA;AAAA,EACtE;AACA,EAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,QAAA,KAAa,QAAA,GAAW,MAAA,GAAS,KAAA;AACxD,EAAA,OAAO,CAAA,EAAG,KAAK,CAAA,EAAA,EAAK,QAAA,CAAS,IAAI,CAAA,OAAA,CAAA;AACnC","file":"chunk-AFXUKOKO.js","sourcesContent":["/**\n * 浏览器侧的桥:把一个 `DocRuntime` 挂到 hub 的 WebSocket 上,\n * 让 hub 转来的工具调用落在**用户此刻正在看的那个编辑器实例**上。\n *\n * 这是整套架构的关键约束:AI 改的和用户看的必须是同一个实例。形状照抄腾讯\n * WorkBuddy 的 editor_sdk(一个进程同时提供编辑器页面和 MCP 端点),\n * 区别是不用付它那 193MB 原生二进制的赎金。\n *\n * ```tsx\n * const runtime = useMemo(() => new DocRuntime(), [])\n * useEffect(() => attachBridge({ runtime, url: \"ws://127.0.0.1:4399/bridge\" }), [runtime])\n * <NotionEditor onEditorReady={(e) => runtime.setEditor(e)} />\n * ```\n */\nimport type { DocRuntime } from \"@4399ywkf/editor/doc-runtime\"\n\nexport type BridgeStatus =\n /** 正在连 hub */\n | \"connecting\"\n /** 已连上且持有桥 —— 工具调用会打到这个实例 */\n | \"primary\"\n /** 已连上但另一个标签页持有桥 —— 本实例只旁观 */\n | \"observer\"\n /** 断开 */\n | \"disconnected\"\n\nexport interface AttachBridgeOptions {\n runtime: DocRuntime\n /** hub 的 WS 地址。默认 `ws://<当前 host>/bridge`。 */\n url?: string\n /** hub 开了 --token 时必须给,会作为 query 参数带上。 */\n token?: string\n /** 状态变化回调,用来在界面上显示「● MCP 桥已连接」这类提示。 */\n onStatus?: (status: BridgeStatus, detail?: string) => void\n /**\n * 每次工具调用后触发,给业务侧做审计 / 埋点用。\n *\n * 说明:本桥**不提供 AI 编辑的撤销与回滚**。ProseMirror 的 Step 可以 invert,\n * 真要做 `doc_revert_turn` 应该持久化 inverted steps —— 目前没做,\n * 想兜底的话在这个钩子里自己存快照。\n */\n onCall?: (event: { tool: string; args: unknown; result?: unknown; error?: string }) => void\n /** 断线自动重连,默认 true。 */\n reconnect?: boolean\n}\n\ninterface BridgeRequest {\n id?: number\n type?: string\n role?: BridgeStatus\n tool?: string\n args?: Record<string, unknown>\n}\n\n/**\n * 连上 hub,返回 detach 函数。\n *\n * 多标签页:**第一个连上的实例持有桥**,后开的降级为旁观者。这不是偷懒,是这套\n * 架构的固有性质 —— 浏览器执行器模式下「文档」等于「某个页面里的那个 editor 实例」,\n * 两个标签页就是两份互不相干的文档。若让后连的抢走桥,agent 就会对着一个和用户\n * 正在看的不同的实例干活。接了 yjs(文档活在浏览器之外)之后这个问题自然消失。\n */\nexport function attachBridge(options: AttachBridgeOptions): () => void {\n const { runtime, token, onStatus, onCall, reconnect = true } = options\n\n if (typeof WebSocket === \"undefined\") {\n throw new Error(\"attachBridge 只能在浏览器里用:当前环境没有 WebSocket\")\n }\n\n const url = options.url ?? defaultUrl()\n const target = token\n ? `${url}${url.includes(\"?\") ? \"&\" : \"?\"}token=${encodeURIComponent(token)}`\n : url\n\n let socket: WebSocket | null = null\n let retry = 0\n let closed = false\n let timer: ReturnType<typeof setTimeout> | null = null\n\n const status = (s: BridgeStatus, detail?: string) => onStatus?.(s, detail)\n\n const connect = () => {\n if (closed) return\n status(\"connecting\")\n socket = new WebSocket(target)\n\n socket.onopen = () => {\n retry = 0\n status(\"primary\")\n }\n\n socket.onclose = () => {\n status(\"disconnected\")\n if (!reconnect || closed) return\n retry += 1\n timer = setTimeout(connect, Math.min(3000, 300 * retry))\n }\n\n socket.onmessage = (ev: MessageEvent) => {\n let req: BridgeRequest\n try {\n req = JSON.parse(String(ev.data))\n } catch {\n return\n }\n\n if (req.type === \"role\") {\n status(req.role === \"primary\" ? \"primary\" : \"observer\")\n return\n }\n\n const { id, tool, args } = req\n if (id == null || !tool) return\n\n const send = (payload: unknown) => {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify({ id, result: payload }))\n }\n }\n\n // 必须 await:有些操作是异步的,Promise 被 JSON.stringify 会变成 {},\n // 调用方拿到空结果还以为成功了。\n Promise.resolve()\n .then(() => runtime.call(tool, args ?? {}))\n .then((result) => {\n onCall?.({ tool, args, result })\n send(result)\n })\n .catch((e: unknown) => {\n const error = String((e as Error)?.message ?? e)\n onCall?.({ tool, args, error })\n send({ error })\n })\n }\n }\n\n connect()\n\n return () => {\n closed = true\n if (timer) clearTimeout(timer)\n socket?.close()\n socket = null\n }\n}\n\nfunction defaultUrl(): string {\n if (typeof location === \"undefined\") {\n throw new Error(\"没有 location,请显式传 url(如 ws://127.0.0.1:4399/bridge)\")\n }\n const proto = location.protocol === \"https:\" ? \"wss:\" : \"ws:\"\n return `${proto}//${location.host}/bridge`\n}\n"]}
@@ -0,0 +1,173 @@
1
+ import { NODE_TAG, READONLY_TAGS, MARK_TAG, FORMATTABLE } from '@4399ywkf/editor/doc-runtime';
2
+
3
+ // src/tools.ts
4
+ var ADDRESSING_NOTE = "定位一律用 node id(读工具返回的 id 属性),逐字复制。注意:与 Google Docs / 腾讯文档那类 API 不同,本工具面的定位在执行的那一瞬间才解析,所以你不需要从文档尾部往前处理,也不需要每改一处就重新读一遍。";
5
+ var BLOCK_TAGS = [...new Set(Object.values(NODE_TAG))].filter((t) => !READONLY_TAGS.has(t)).sort().join(" ");
6
+ var INLINE_TAGS = Object.values(MARK_TAG).join(" ");
7
+ var DOC_TOOLS = [
8
+ {
9
+ name: "doc_read",
10
+ description: "读取整篇文档,返回 LiteXML(每个块带稳定 id)。要编辑之前必须先调它拿 id。" + ADDRESSING_NOTE,
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ format: {
15
+ type: "string",
16
+ enum: ["xml", "json", "both"],
17
+ description: "默认 xml。json 是 ProseMirror 原生结构(无损但费 token,约 1.5-2.8 倍),只在你怀疑 LiteXML 丢了信息时才用。"
18
+ }
19
+ },
20
+ additionalProperties: false
21
+ }
22
+ },
23
+ {
24
+ name: "doc_schema",
25
+ description: "返回本编辑器支持的全部节点类型 / 标记类型 / LiteXML 标签映射 / 哪些类型带稳定 id。不确定某个结构能不能写、该用什么标签时先查它。这份数据是从运行时 schema 反射出来的,永远不会和实现漂移。",
26
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
27
+ },
28
+ {
29
+ name: "doc_find",
30
+ description: "全文检索,返回可直接回填给写工具的地址:{nodeId, nodeType, contextBefore, match, contextAfter}。",
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ query: { type: "string", description: "要查找的文本(字面量,不是正则)" },
35
+ limit: { type: "number", description: "最多返回几条,默认 20" }
36
+ },
37
+ required: ["query"],
38
+ additionalProperties: false
39
+ }
40
+ },
41
+ {
42
+ name: "doc_get_selection",
43
+ description: "读取用户此刻在编辑器里选中了什么。返回 selection:null 时表示本轮无选区,不得沿用历史选区。",
44
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
45
+ },
46
+ {
47
+ name: "doc_replace_text",
48
+ description: "块内文本替换,最推荐的细粒度改法。只改命中的那一段,块内其余的格式、链接、mention 都会保住。可用 nodeIds 限定只在某些块里替换。" + ADDRESSING_NOTE,
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ searchText: { type: "string" },
53
+ newText: { type: "string" },
54
+ nodeIds: {
55
+ type: "array",
56
+ items: { type: "string" },
57
+ description: "限定生效范围;省略则全文"
58
+ },
59
+ replaceAll: {
60
+ type: "boolean",
61
+ description: "默认 false,只替换文档里第一处"
62
+ }
63
+ },
64
+ required: ["searchText", "newText"],
65
+ additionalProperties: false
66
+ }
67
+ },
68
+ {
69
+ name: "doc_format_text",
70
+ description: "给某个块里的一段文字加/去格式(加粗、斜体、下划线、删除线、行内代码、上下标)。**要改格式请优先用它,不要用 doc_modify_nodes 整块重写** —— 整块重写要求你重述整块内容,你没看见的属性和内联节点会在重述中丢失。\n先用 doc_read / doc_find 拿到块的 id 和它的文本,再指定要施加格式的那段文字。",
71
+ inputSchema: {
72
+ type: "object",
73
+ properties: {
74
+ nodeId: { type: "string", description: "目标块的 id" },
75
+ searchText: { type: "string", description: "块内要施加格式的那段文字(精确匹配)" },
76
+ marks: {
77
+ type: "array",
78
+ items: { type: "string", enum: [...FORMATTABLE] },
79
+ description: "要施加的标记,可多个"
80
+ },
81
+ remove: { type: "boolean", description: "true 则移除这些标记而不是施加,默认 false" },
82
+ occurrence: { type: "number", description: "块内第几处匹配,从 1 开始,默认 1" }
83
+ },
84
+ required: ["nodeId", "searchText", "marks"],
85
+ additionalProperties: false
86
+ }
87
+ },
88
+ {
89
+ name: "doc_modify_nodes",
90
+ description: `结构化编辑:一次提交多个 insert / remove / modify 操作。批量写入优先用它,不要拆成很多次单点调用。
91
+ LiteXML 块标签:${BLOCK_TAGS} h1-h6;未知节点用 <node type="x"/>。
92
+ 内联:${INLINE_TAGS} a[href] mark[color] color[v] br。
93
+ 例:<code lang="ts">…</code>、<img src="…"/>、<tasks><task checked="true">…</task></tasks>。
94
+ modify 的 litexml 必须带上要改的那个块的 id 属性。你没写进 XML 的属性会以原节点为底保留下来(不会被抹掉),所以不必重述你没看见的状态。` + ADDRESSING_NOTE,
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ operations: {
99
+ type: "array",
100
+ items: {
101
+ type: "object",
102
+ properties: {
103
+ action: { type: "string", enum: ["insert", "remove", "modify"] },
104
+ afterId: { type: "string", description: "insert:插到这个块之后" },
105
+ beforeId: { type: "string", description: "insert:插到这个块之前" },
106
+ id: { type: "string", description: "remove:要删除的块 id" },
107
+ litexml: {
108
+ type: "string",
109
+ description: "insert/modify 的内容。modify 时必须带 id 属性"
110
+ }
111
+ },
112
+ required: ["action"]
113
+ }
114
+ }
115
+ },
116
+ required: ["operations"],
117
+ additionalProperties: false
118
+ }
119
+ },
120
+ {
121
+ name: "doc_table_edit",
122
+ description: "表格的**结构性**改动(增删行列、合并拆分、切表头)和批量写值。用 (tableId, row, col) 这种结构坐标定位,row/col 都是 0-based,表头是第 0 行。\n注意:单元格里的段落是有 id 的,所以「只改某个格子的文字」直接用 doc_replace_text / doc_format_text 打那个段落的 id 通常更简单。",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ tableId: { type: "string" },
127
+ action: {
128
+ type: "string",
129
+ enum: [
130
+ "set_cells",
131
+ "insert_row_after",
132
+ "insert_row_before",
133
+ "insert_col_after",
134
+ "insert_col_before",
135
+ "delete_row",
136
+ "delete_col",
137
+ "toggle_header_row",
138
+ "merge_cells",
139
+ "split_cell"
140
+ ]
141
+ },
142
+ row: {
143
+ type: "number",
144
+ description: "0-based 行号(表头是第 0 行)。除 set_cells 外**必填**,作为锚点单元格"
145
+ },
146
+ col: { type: "number", description: "0-based 列号。除 set_cells 外**必填**" },
147
+ values: {
148
+ type: "array",
149
+ description: "set_cells 用:[{row, col, text}]",
150
+ items: {
151
+ type: "object",
152
+ properties: {
153
+ row: { type: "number" },
154
+ col: { type: "number" },
155
+ text: { type: "string" }
156
+ },
157
+ required: ["row", "col", "text"]
158
+ }
159
+ }
160
+ },
161
+ required: ["tableId", "action"],
162
+ additionalProperties: false
163
+ }
164
+ }
165
+ ];
166
+ var DOC_TOOL_NAMES = DOC_TOOLS.map((t) => t.name);
167
+ function getDocTool(name) {
168
+ return DOC_TOOLS.find((t) => t.name === name);
169
+ }
170
+
171
+ export { DOC_TOOLS, DOC_TOOL_NAMES, getDocTool };
172
+ //# sourceMappingURL=chunk-TUKXSXAN.js.map
173
+ //# sourceMappingURL=chunk-TUKXSXAN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/tools.ts"],"names":[],"mappings":"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","file":"chunk-TUKXSXAN.js","sourcesContent":["/**\n * 工具面:8 个工具的 name / description / JSON Schema。\n *\n * **这里没有任何传输**。是 MCP、是 HTTP、还是像 jizhi_ai 那样 agent loop 直接调\n * executor,都由上层决定 —— 想自己接的只 import 这个模块就够了:\n *\n * ```ts\n * import { DOC_TOOLS } from \"@4399ywkf/editor-mcp/tools\"\n * ```\n *\n * 为什么只有 8 个:参照系是 BlockNote AI 的 3 个工具,和把 tiptap 每个 command\n * 都包成工具的 `tiptap-apcore`(79 个,GitHub 1 star)。工具面大了模型反而选不准。\n * `doc_stats` 那类对账工具刻意不在这里 —— 它是给人调试用的,`DocRuntime.call`\n * 支持,但不该占模型的注意力。\n */\nimport { FORMATTABLE, MARK_TAG, NODE_TAG, READONLY_TAGS } from \"@4399ywkf/editor/doc-runtime\"\n\nexport interface DocToolSchema {\n type: \"object\"\n properties: Record<string, unknown>\n required?: string[]\n additionalProperties?: boolean\n}\n\nexport interface DocTool {\n name: string\n description: string\n inputSchema: DocToolSchema\n}\n\n/**\n * 定位纪律。写进 description 而不是 README —— 和实现同生共死,不会漂。\n *\n * 后半句是必需的:模型带着 Google Docs / 腾讯文档那套 API 的条件反射\n * (改一处就失效一次索引),不明说它会自作聪明地倒序处理、反复重读。\n */\nconst ADDRESSING_NOTE =\n \"定位一律用 node id(读工具返回的 id 属性),逐字复制。\" +\n \"注意:与 Google Docs / 腾讯文档那类 API 不同,本工具面的定位在执行的那一瞬间才解析,\" +\n \"所以你不需要从文档尾部往前处理,也不需要每改一处就重新读一遍。\"\n\n/**\n * 可用标签清单从 doc-runtime 的标签表**生成**,不是手抄的。\n * 编辑器加一个节点类型,这段 description 自动跟着变。\n */\nconst BLOCK_TAGS = [...new Set(Object.values(NODE_TAG))]\n .filter((t) => !READONLY_TAGS.has(t))\n .sort()\n .join(\" \")\nconst INLINE_TAGS = Object.values(MARK_TAG).join(\" \")\n\nexport const DOC_TOOLS: readonly DocTool[] = [\n {\n name: \"doc_read\",\n description:\n \"读取整篇文档,返回 LiteXML(每个块带稳定 id)。要编辑之前必须先调它拿 id。\" +\n ADDRESSING_NOTE,\n inputSchema: {\n type: \"object\",\n properties: {\n format: {\n type: \"string\",\n enum: [\"xml\", \"json\", \"both\"],\n description:\n \"默认 xml。json 是 ProseMirror 原生结构(无损但费 token,约 1.5-2.8 倍),\" +\n \"只在你怀疑 LiteXML 丢了信息时才用。\",\n },\n },\n additionalProperties: false,\n },\n },\n {\n name: \"doc_schema\",\n description:\n \"返回本编辑器支持的全部节点类型 / 标记类型 / LiteXML 标签映射 / 哪些类型带稳定 id。\" +\n \"不确定某个结构能不能写、该用什么标签时先查它。这份数据是从运行时 schema 反射出来的,\" +\n \"永远不会和实现漂移。\",\n inputSchema: { type: \"object\", properties: {}, additionalProperties: false },\n },\n {\n name: \"doc_find\",\n description:\n \"全文检索,返回可直接回填给写工具的地址:{nodeId, nodeType, contextBefore, match, contextAfter}。\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: { type: \"string\", description: \"要查找的文本(字面量,不是正则)\" },\n limit: { type: \"number\", description: \"最多返回几条,默认 20\" },\n },\n required: [\"query\"],\n additionalProperties: false,\n },\n },\n {\n name: \"doc_get_selection\",\n description:\n \"读取用户此刻在编辑器里选中了什么。返回 selection:null 时表示本轮无选区,不得沿用历史选区。\",\n inputSchema: { type: \"object\", properties: {}, additionalProperties: false },\n },\n {\n name: \"doc_replace_text\",\n description:\n \"块内文本替换,最推荐的细粒度改法。只改命中的那一段,块内其余的格式、链接、\" +\n \"mention 都会保住。可用 nodeIds 限定只在某些块里替换。\" +\n ADDRESSING_NOTE,\n inputSchema: {\n type: \"object\",\n properties: {\n searchText: { type: \"string\" },\n newText: { type: \"string\" },\n nodeIds: {\n type: \"array\",\n items: { type: \"string\" },\n description: \"限定生效范围;省略则全文\",\n },\n replaceAll: {\n type: \"boolean\",\n description: \"默认 false,只替换文档里第一处\",\n },\n },\n required: [\"searchText\", \"newText\"],\n additionalProperties: false,\n },\n },\n {\n name: \"doc_format_text\",\n description:\n \"给某个块里的一段文字加/去格式(加粗、斜体、下划线、删除线、行内代码、上下标)。\" +\n \"**要改格式请优先用它,不要用 doc_modify_nodes 整块重写** —— \" +\n \"整块重写要求你重述整块内容,你没看见的属性和内联节点会在重述中丢失。\\n\" +\n \"先用 doc_read / doc_find 拿到块的 id 和它的文本,再指定要施加格式的那段文字。\",\n inputSchema: {\n type: \"object\",\n properties: {\n nodeId: { type: \"string\", description: \"目标块的 id\" },\n searchText: { type: \"string\", description: \"块内要施加格式的那段文字(精确匹配)\" },\n marks: {\n type: \"array\",\n items: { type: \"string\", enum: [...FORMATTABLE] },\n description: \"要施加的标记,可多个\",\n },\n remove: { type: \"boolean\", description: \"true 则移除这些标记而不是施加,默认 false\" },\n occurrence: { type: \"number\", description: \"块内第几处匹配,从 1 开始,默认 1\" },\n },\n required: [\"nodeId\", \"searchText\", \"marks\"],\n additionalProperties: false,\n },\n },\n {\n name: \"doc_modify_nodes\",\n description:\n \"结构化编辑:一次提交多个 insert / remove / modify 操作。\" +\n \"批量写入优先用它,不要拆成很多次单点调用。\\n\" +\n `LiteXML 块标签:${BLOCK_TAGS} h1-h6;未知节点用 <node type=\"x\"/>。\\n` +\n `内联:${INLINE_TAGS} a[href] mark[color] color[v] br。\\n` +\n '例:<code lang=\"ts\">…</code>、<img src=\"…\"/>、<tasks><task checked=\"true\">…</task></tasks>。\\n' +\n \"modify 的 litexml 必须带上要改的那个块的 id 属性。\" +\n \"你没写进 XML 的属性会以原节点为底保留下来(不会被抹掉),所以不必重述你没看见的状态。\" +\n ADDRESSING_NOTE,\n inputSchema: {\n type: \"object\",\n properties: {\n operations: {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n action: { type: \"string\", enum: [\"insert\", \"remove\", \"modify\"] },\n afterId: { type: \"string\", description: \"insert:插到这个块之后\" },\n beforeId: { type: \"string\", description: \"insert:插到这个块之前\" },\n id: { type: \"string\", description: \"remove:要删除的块 id\" },\n litexml: {\n type: \"string\",\n description: \"insert/modify 的内容。modify 时必须带 id 属性\",\n },\n },\n required: [\"action\"],\n },\n },\n },\n required: [\"operations\"],\n additionalProperties: false,\n },\n },\n {\n name: \"doc_table_edit\",\n description:\n \"表格的**结构性**改动(增删行列、合并拆分、切表头)和批量写值。\" +\n \"用 (tableId, row, col) 这种结构坐标定位,row/col 都是 0-based,表头是第 0 行。\\n\" +\n \"注意:单元格里的段落是有 id 的,所以「只改某个格子的文字」直接用 \" +\n \"doc_replace_text / doc_format_text 打那个段落的 id 通常更简单。\",\n inputSchema: {\n type: \"object\",\n properties: {\n tableId: { type: \"string\" },\n action: {\n type: \"string\",\n enum: [\n \"set_cells\",\n \"insert_row_after\",\n \"insert_row_before\",\n \"insert_col_after\",\n \"insert_col_before\",\n \"delete_row\",\n \"delete_col\",\n \"toggle_header_row\",\n \"merge_cells\",\n \"split_cell\",\n ],\n },\n row: {\n type: \"number\",\n description: \"0-based 行号(表头是第 0 行)。除 set_cells 外**必填**,作为锚点单元格\",\n },\n col: { type: \"number\", description: \"0-based 列号。除 set_cells 外**必填**\" },\n values: {\n type: \"array\",\n description: \"set_cells 用:[{row, col, text}]\",\n items: {\n type: \"object\",\n properties: {\n row: { type: \"number\" },\n col: { type: \"number\" },\n text: { type: \"string\" },\n },\n required: [\"row\", \"col\", \"text\"],\n },\n },\n },\n required: [\"tableId\", \"action\"],\n additionalProperties: false,\n },\n },\n]\n\nexport const DOC_TOOL_NAMES: readonly string[] = DOC_TOOLS.map((t) => t.name)\n\n/** 单个工具的定义,找不到返回 undefined。 */\nexport function getDocTool(name: string): DocTool | undefined {\n return DOC_TOOLS.find((t) => t.name === name)\n}\n"]}
@@ -0,0 +1,3 @@
1
+ export { AttachBridgeOptions, BridgeStatus, attachBridge } from './bridge.js';
2
+ export { DOC_TOOLS, DOC_TOOL_NAMES, DocTool, DocToolSchema, getDocTool } from './tools.js';
3
+ import '@4399ywkf/editor/doc-runtime';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { DOC_TOOLS, DOC_TOOL_NAMES, getDocTool } from './chunk-TUKXSXAN.js';
2
+ export { attachBridge } from './chunk-AFXUKOKO.js';
3
+ //# sourceMappingURL=index.js.map
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -0,0 +1,17 @@
1
+ interface DocToolSchema {
2
+ type: "object";
3
+ properties: Record<string, unknown>;
4
+ required?: string[];
5
+ additionalProperties?: boolean;
6
+ }
7
+ interface DocTool {
8
+ name: string;
9
+ description: string;
10
+ inputSchema: DocToolSchema;
11
+ }
12
+ declare const DOC_TOOLS: readonly DocTool[];
13
+ declare const DOC_TOOL_NAMES: readonly string[];
14
+ /** 单个工具的定义,找不到返回 undefined。 */
15
+ declare function getDocTool(name: string): DocTool | undefined;
16
+
17
+ export { DOC_TOOLS, DOC_TOOL_NAMES, type DocTool, type DocToolSchema, getDocTool };
package/dist/tools.js ADDED
@@ -0,0 +1,3 @@
1
+ export { DOC_TOOLS, DOC_TOOL_NAMES, getDocTool } from './chunk-TUKXSXAN.js';
2
+ //# sourceMappingURL=tools.js.map
3
+ //# sourceMappingURL=tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"tools.js"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@4399ywkf/editor-mcp",
3
+ "version": "0.1.0",
4
+ "description": "把 @4399ywkf/editor 的结构化编辑面接出去:工具定义(transport 无关)+ 浏览器桥 + hub + stdio MCP server",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./tools": {
14
+ "types": "./dist/tools.d.ts",
15
+ "import": "./dist/tools.js"
16
+ },
17
+ "./bridge": {
18
+ "types": "./dist/bridge.d.ts",
19
+ "import": "./dist/bridge.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "bin": {
24
+ "ywkf-editor-hub": "./bin/hub.mjs",
25
+ "ywkf-editor-mcp": "./bin/stdio.mjs"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "bin"
30
+ ],
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.30.0",
33
+ "ws": "^8.21.3",
34
+ "@4399ywkf/editor": "^0.2.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.10.0",
38
+ "@types/ws": "^8.5.13",
39
+ "tsup": "^8.4.0",
40
+ "typescript": "^5.8.2"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "keywords": [
46
+ "tiptap",
47
+ "mcp",
48
+ "editor",
49
+ "ai",
50
+ "4399"
51
+ ],
52
+ "engines": {
53
+ "node": ">=22"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "dev": "tsup --watch",
58
+ "test": "node --experimental-strip-types --test src/*.test.ts",
59
+ "typecheck": "tsc --noEmit"
60
+ }
61
+ }