From 4334234062128f25cd418316c57921da74177fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E8=B1=AA?= Date: Thu, 3 Sep 2026 17:21:11 +0800 Subject: [PATCH] feat: add upload_asset tool (16th tool) for image/asset upload - POST /api/asset/upload with multipart field name file[] (files/files[] are silently swallowed by the reverse proxy on this deployment, per SiYuan v3.8.2 kernel/model/upload.go) - audit: target notebook requires W permission - code:0 + empty succMap is treated as a hard error (the exact silent failure that forced a previous session to fall back to raw HTTP) - client: extract callRaw, allow FormData body, 120s timeout for uploads - self-check: src/upload.test.ts (stubbed fetch, no HTTP) - live-tested against siyuan.ipao.vip; probe asset cleaned up --- AGENTS.md | 2 ++ README.md | 4 +-- package.json | 4 +-- src/index.ts | 69 +++++++++++++++++++++++++++++++++++++--- src/siyuan-client.ts | 60 ++++++++++++++++++++++++++++++---- src/upload.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 src/upload.test.ts diff --git a/AGENTS.md b/AGENTS.md index a3c0de1..1c46162 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,5 @@ permissions 取值为 R / W / D 的自由组合,或仅为 ["NONE"]。name 仅 11、把 15 个工具全部映射进 extension,工具名与 siyuan-mcp 保持一致:unified_search、get_document_content、create_document、append_to_document、update_document、move_documents、get_document_tree、append_to_daily_note、list_notebooks、get_recently_updated_documents、create_snapshot、list_snapshots、rollback_to_snapshot、list_all_tags、batch_replace_tag。 12、留一个审计逻辑的最小自检(纯函数测试权限矩阵判定即可,不需要 mock HTTP)。 + +13、(2026-09-04 补充)发布图文文章需要图片上传能力,新增第 16 个工具 `upload_asset`(不在 siyuan-mcp 工具集内,为 pi-siyuan 扩展):读本地文件列表 → POST /api/asset/upload(multipart 字段名 **file[]**,files/files[] 会被该部署反代吞成空 succMap)→ 返回 succMap 原文件名→assets/ 路径。审计:目标笔记本需 W。上传后把返回的 assets/ 路径填进 create_document 的 Markdown 图片链接。code:0 + 空 succMap 视为失败必须报错(该部署字段名回归时的静默失败症状)。 diff --git a/README.md b/README.md index b956055..ed02a3c 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ ## 特性 -- **15 个工具**,与 siyuan-mcp 同名:`unified_search`、`get_document_content`、`create_document`、`append_to_document`、`update_document`、`move_documents`、`get_document_tree`、`append_to_daily_note`、`list_notebooks`、`get_recently_updated_documents`、`create_snapshot`、`list_snapshots`、`rollback_to_snapshot`、`list_all_tags`、`batch_replace_tag` -- **渐进式披露**:初始仅激活一个 `siyuan_discover` loader 工具(含连通性检查),其余 15 个工具按需增量加载,保持系统提示词前缀稳定 +- **16 个工具**:前 15 个与 siyuan-mcp 同名(`unified_search`、`get_document_content`、`create_document`、`append_to_document`、`update_document`、`move_documents`、`get_document_tree`、`append_to_daily_note`、`list_notebooks`、`get_recently_updated_documents`、`create_snapshot`、`list_snapshots`、`rollback_to_snapshot`、`list_all_tags`、`batch_replace_tag`),外加 `upload_asset`(上传本地图片/附件,multipart 字段名 `file[]`,需目标笔记本 W;返回 `succMap` 原文件名 → `assets/` 路径,可直接填进 Markdown 图片链接,用于发布图文文章) +- **渐进式披露**:初始仅激活一个 `siyuan_discover` loader 工具(含连通性检查),其余 16 个工具按需增量加载,保持系统提示词前缀稳定 - **笔记本级 RWD 权限审计**:R 读 / W 写 / D 破坏性(rollback、move 移出、删除类),可自由组合;`NONE` = 禁止一切操作 - 拒绝消息包含:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名 - Pi 启动及调用 `list_notebooks` 时自动同步笔记本;新笔记本以 `R` 权限写入配置文件(审计豁免) diff --git a/package.json b/package.json index e4685bb..da575e1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "dependencies": { "typebox": "1.3.7" }, - "version": "0.1.3", + "version": "0.1.4", "description": "SiYuan Note extension for pi coding agent — HTTP API wrapper with notebook-level RWD permission auditing", "keywords": [ "pi-package", @@ -37,7 +37,7 @@ ], "author": "rogee", "scripts": { - "check": "node --experimental-strip-types --no-warnings src/audit.test.ts" + "check": "node --experimental-strip-types --no-warnings src/audit.test.ts && node --experimental-strip-types --no-warnings src/upload.test.ts" }, "prepublishOnly": "node --experimental-strip-types --no-warnings src/audit.test.ts" } diff --git a/src/index.ts b/src/index.ts index c6cde0e..0abd58c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ * 架构(AGENTS.md 第 5 条):直接封装 SiYuan HTTP API + 权限审计层。 * 工具划分与参数设计参考 porkll/siyuan-mcp,不依赖其代码。 * - * 渐进式披露(goal 4):15 个工具全部 registerTool 注册, + * 渐进式披露(goal 4):16 个工具全部 registerTool 注册, * 但初始只激活 siyuan_discover 一个 loader 工具; * loader 按需 setActiveTools 增量激活匹配的工具。 * @@ -12,6 +12,8 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; import { Type } from "typebox"; import { SiYuanClient } from "./siyuan-client.ts"; import { @@ -23,7 +25,8 @@ import { type PiSiyuanConfig, } from "./audit.ts"; -// 15 个工具名(AGENTS.md 第 11 条,与 porkll/siyuan-mcp 一致) +// 16 个工具名(AGENTS.md 第 11 条 + 第 13 条;前 15 个与 porkll/siyuan-mcp 一致, +// upload_asset 为 pi-siyuan 扩展:发布图文文章所需的图片/附件上传) const SIYUAN_TOOL_NAMES = new Set([ "unified_search", "get_document_content", @@ -40,6 +43,7 @@ const SIYUAN_TOOL_NAMES = new Set([ "rollback_to_snapshot", "list_all_tags", "batch_replace_tag", + "upload_asset", ]); interface ToolDeps { @@ -95,7 +99,7 @@ export default function siyuanExtension(pi: ExtensionAPI) { }; } - // ============ 15 个工具(第 11 条) ============ + // ============ 16 个工具(第 11/13 条) ============ pi.registerTool({ name: "list_notebooks", @@ -375,11 +379,68 @@ export default function siyuanExtension(pi: ExtensionAPI) { // ============ 渐进式披露 loader(goal 4) ============ + const ASSET_MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + pdf: "application/pdf", + mp3: "audio/mpeg", + mp4: "video/mp4", + }; + + pi.registerTool({ + name: "upload_asset", + label: "Upload Asset", + description: + "上传本地图片/附件到 SiYuan assets(发布图文文章用;需目标笔记本 W 权限)。返回 succMap:原文件名 → assets/ 路径,可直接作为 create_document/append_to_document 里 Markdown 图片链接。一次最多 50 个文件。", + parameters: Type.Object({ + notebook: Type.String({ description: "目标笔记本 ID(审计 W)" }), + paths: Type.Array(Type.String(), { + description: "本地文件路径列表(相对当前目录或绝对路径)", + }), + }), + execute: audited("upload_asset", async (p) => { + deps.auditor.auditNotebookId(p.notebook, ["W"], "upload_asset"); + if (!p.paths.length) return fail(new Error("paths 不能为空")); + if (p.paths.length > 50) + return fail(new Error("一次最多上传 50 个文件")); + const files: Array<{ name: string; data: Uint8Array; mime?: string }> = []; + for (const path of p.paths) { + let data: Buffer; + try { + data = readFileSync(path); + } catch { + return fail(new Error(`读取文件失败:${path}`)); + } + const ext = basename(path).split(".").pop()?.toLowerCase() ?? ""; + files.push({ + name: basename(path), + data: new Uint8Array(data), + mime: ASSET_MIME[ext], + }); + } + const r = await client.uploadAssets(files); + const lines = Object.entries(r.succMap).map( + ([name, url]) => `${name} → ${url}`, + ); + if (r.errFiles.length) + lines.push(`失败:${r.errFiles.join(", ")}`); + return ok( + `上传成功 ${Object.keys(r.succMap).length}/${files.length}${r.errFiles.length ? `,失败 ${r.errFiles.length}` : ""}:\n` + + lines.join("\n"), + { succMap: r.succMap, errFiles: r.errFiles }, + ); + }), + }); + pi.registerTool({ name: "siyuan_discover", label: "SiYuan Discover", description: - "搜索并激活 SiYuan 笔记工具(15 个工具按需加载)。输入能力关键词(如 search / read / write / snapshot / tag / notebook / move / daily)。", + "搜索并激活 SiYuan 笔记工具(16 个工具按需加载)。输入能力关键词(如 search / read / write / upload / image / snapshot / tag / notebook / move / daily)。", promptSnippet: "操作 SiYuan 笔记时先用 siyuan_discover 激活对应工具", parameters: Type.Object({ query: Type.Optional( diff --git a/src/siyuan-client.ts b/src/siyuan-client.ts index 583702f..19ea1de 100644 --- a/src/siyuan-client.ts +++ b/src/siyuan-client.ts @@ -130,6 +130,40 @@ export class SiYuanClient { return { docId, created: true }; } + // ---- 附件 ---- + + /** + * 上传图片/附件(/api/asset/upload)。 + * 该部署实测契约:multipart 字段名必须是 file[](files/files[] 会被吞成空 succMap, + * SiYuan v3.8.2 kernel/model/upload.go: form.File["file[]"]),无 Authorization 401。 + * 响应 {errFiles, succFiles:[{index,name,path}], succMap:{原名: "assets/新名"}}。 + */ + async uploadAssets( + files: Array<{ name: string; data: Uint8Array; mime?: string }>, + ): Promise<{ errFiles: string[]; succMap: Record }> { + if (!files.length) + throw new SiYuanError("uploadAssets: 文件列表为空"); + const fd = new FormData(); + for (const f of files) { + fd.append( + "file[]", + new Blob([f.data], { + type: f.mime || "application/octet-stream", + }), + f.name, + ); + } + const d = await this.callForm("/api/asset/upload", fd); + const errFiles: string[] = d?.errFiles ?? []; + const succMap: Record = d?.succMap ?? {}; + // 字段名回归/反代吞 body 时的症状就是 code:0 + 空 succMap —— 静默失败,必须显式报错 + if (!Object.keys(succMap).length && !errFiles.length) + throw new SiYuanError( + "上传返回空 succMap(文件未落库):疑似 multipart 字段名回归或反代吞掉 body,当前字段名 file[]", + ); + return { errFiles, succMap }; + } + // ---- 搜索(fulltext 端点被吞,用 SQL)---- async searchContent(query: string, limit: number): Promise { @@ -181,16 +215,30 @@ export class SiYuanClient { } private async call(path: string, body: unknown): Promise { + return this.callRaw( + path, + { "Content-Type": "application/json" }, + JSON.stringify(body), + ); + } + + private async callForm(path: string, body: FormData): Promise { + // FormData 由 fetch 自动生成含 boundary 的 Content-Type + return this.callRaw(path, {}, body); + } + + private async callRaw( + path: string, + extraHeaders: Record, + body?: BodyInit, + ): Promise { let res: Response; try { res = await fetch(this.apiUrl + path, { method: "POST", - headers: { - Authorization: `Token ${this.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(30000), + headers: { Authorization: `Token ${this.token}`, ...extraHeaders }, + body, + signal: AbortSignal.timeout(120000), }); } catch (e: any) { throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`); diff --git a/src/upload.test.ts b/src/upload.test.ts new file mode 100644 index 0000000..ebd3257 --- /dev/null +++ b/src/upload.test.ts @@ -0,0 +1,76 @@ +/** + * uploadAssets 最小自检(无 HTTP):stub fetch 捕获 FormData, + * 验证该部署实测契约——multipart 字段名必须是 file[](AGENTS.md 第 13 条)。 + * 运行:node --experimental-strip-types src/upload.test.ts + */ + +import assert from "node:assert"; +import { SiYuanClient } from "./siyuan-client.ts"; + +// ---- 捕获 fetch ---- +let captured: { url: string; init: RequestInit } | null = null; +const realFetch = globalThis.fetch; +globalThis.fetch = (async (url: any, init: any) => { + captured = { url: String(url), init }; + return new Response( + JSON.stringify({ + code: 0, + msg: "", + data: { + errFiles: null, + succFiles: [{ index: 0, name: "a.png", path: "assets/a-x.png" }], + succMap: { "a.png": "assets/a-x.png" }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); +}) as typeof fetch; + +const client = new SiYuanClient("http://127.0.0.1:6806", "test-token"); + +// 1. 正常上传:字段名 file[]、Authorization 头、不手工设 multipart Content-Type +const r = await client.uploadAssets([ + { name: "a.png", data: new Uint8Array([1, 2, 3]), mime: "image/png" }, + { name: "b.jpg", data: new Uint8Array([4, 5]) }, +]); +assert.ok(captured, "fetch 应被调用"); +assert.ok(captured!.url.endsWith("/api/asset/upload"), "打到 /api/asset/upload"); +assert.equal( + (captured!.init.headers as any).Authorization, + "Token test-token", + "带 Token 认证头", +); +assert.equal( + (captured!.init.headers as any)["Content-Type"], + undefined, + "multipart Content-Type 必须由 fetch 自动生成(含 boundary),不能手工设置", +); +const form = captured!.init.body as FormData; +assert.equal(form.getAll("file[]").length, 2, "两个文件都用 file[] 字段"); +assert.equal((form.get("file[]") as File).name, "a.png", "保留原文件名"); +assert.deepEqual(r.succMap, { "a.png": "assets/a-x.png" }); + +// 2. 空 succMap(字段名回归/反代吞 body 的静默失败症状)必须报错 +globalThis.fetch = (async () => + new Response( + JSON.stringify({ + code: 0, + msg: "", + data: { errFiles: null, failedFiles: [], succFiles: [], succMap: {} }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as typeof fetch; +await assert.rejects( + () => client.uploadAssets([{ name: "x.png", data: new Uint8Array([1]) }]), + /空 succMap/, + "code:0 + 空 succMap 必须抛错(这正是历史上 files[] 被8吞掉的症状)", +); + +// 3. 空文件列表直接拒绝 +await assert.rejects( + () => client.uploadAssets([]), + /文件列表为空/, +); + +globalThis.fetch = realFetch; +console.log("upload self-check: all assertions passed ✅");