Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4334234062 | ||
|
|
e33c50754c |
@@ -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。
|
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)。
|
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 视为失败必须报错(该部署字段名回归时的静默失败症状)。
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- **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`
|
- **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 工具(含连通性检查),其余 15 个工具按需增量加载,保持系统提示词前缀稳定
|
- **渐进式披露**:初始仅激活一个 `siyuan_discover` loader 工具(含连通性检查),其余 16 个工具按需增量加载,保持系统提示词前缀稳定
|
||||||
- **笔记本级 RWD 权限审计**:R 读 / W 写 / D 破坏性(rollback、move 移出、删除类),可自由组合;`NONE` = 禁止一切操作
|
- **笔记本级 RWD 权限审计**:R 读 / W 写 / D 破坏性(rollback、move 移出、删除类),可自由组合;`NONE` = 禁止一切操作
|
||||||
- 拒绝消息包含:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名
|
- 拒绝消息包含:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名
|
||||||
- 新笔记本自动以 `R` 权限同步进配置文件(发现入口 `list_notebooks`,审计豁免)
|
- Pi 启动及调用 `list_notebooks` 时自动同步笔记本;新笔记本以 `R` 权限写入配置文件(审计豁免)
|
||||||
- 连接信息:环境变量优先,配置文件兜底
|
- 连接信息:环境变量优先,配置文件兜底
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
@@ -64,7 +64,7 @@ export SIYUAN_TOKEN="your-api-token" # SiYuan 设置 → 关于
|
|||||||
|
|
||||||
- `permissions`:`R`/`W`/`D` 自由组合,或 `["NONE"]`(禁止一切操作)
|
- `permissions`:`R`/`W`/`D` 自由组合,或 `["NONE"]`(禁止一切操作)
|
||||||
- `name` 仅做 human-readable,一切逻辑按 `id` 匹配
|
- `name` 仅做 human-readable,一切逻辑按 `id` 匹配
|
||||||
- 首次调用 `list_notebooks` 会把未记录的笔记本以 `["R"]` 补写进该文件
|
- Pi 启动时会创建该配置文件并同步笔记本;调用 `list_notebooks` 时也会再次同步。未记录的笔记本以 `["R"]` 补写,已有笔记本名称变化时更新 `name`
|
||||||
|
|
||||||
### 权限规则速查
|
### 权限规则速查
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-siyuan",
|
"name": "pi-siyuan",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-siyuan",
|
"name": "pi-siyuan",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typebox": "1.3.7"
|
"typebox": "1.3.7"
|
||||||
|
|||||||
+4
-4
@@ -5,8 +5,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typebox": "1.3.7"
|
"typebox": "1.3.7"
|
||||||
},
|
},
|
||||||
"version": "0.1.2",
|
"version": "0.1.4",
|
||||||
"description": "SiYuan Note extension for pi coding agent \u2014 HTTP API wrapper with notebook-level RWD permission auditing",
|
"description": "SiYuan Note extension for pi coding agent — HTTP API wrapper with notebook-level RWD permission auditing",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
"pi-extension",
|
"pi-extension",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"author": "rogee",
|
"author": "rogee",
|
||||||
"scripts": {
|
"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"
|
"prepublishOnly": "node --experimental-strip-types --no-warnings src/audit.test.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
|
import { existsSync, rmSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
permsOf,
|
permsOf,
|
||||||
auditNotebook,
|
auditNotebook,
|
||||||
auditAny,
|
auditAny,
|
||||||
auditAll,
|
auditAll,
|
||||||
denyMessage,
|
denyMessage,
|
||||||
|
loadConfig,
|
||||||
syncNotebooks,
|
syncNotebooks,
|
||||||
} from "./audit.ts";
|
} from "./audit.ts";
|
||||||
|
|
||||||
@@ -87,6 +89,19 @@ assert.ok(
|
|||||||
assert.ok(msg.includes("get_document_content"), "含操作名");
|
assert.ok(msg.includes("get_document_content"), "含操作名");
|
||||||
assert.ok(/需要 R/.test(msg) && /缺失 R/.test(msg), "含需要/缺失权限");
|
assert.ok(/需要 R/.test(msg) && /缺失 R/.test(msg), "含需要/缺失权限");
|
||||||
|
|
||||||
|
// ---- 配置首次加载自动初始化 ----
|
||||||
|
const initDir = "/tmp/pi-siyuan-audit-test";
|
||||||
|
const initPath = `${initDir}/nested/config.json`;
|
||||||
|
rmSync(initDir, { recursive: true, force: true });
|
||||||
|
const initialized = loadConfig(initPath);
|
||||||
|
assert.equal(existsSync(initPath), true, "配置文件应在首次加载时创建");
|
||||||
|
assert.deepEqual(initialized, {
|
||||||
|
apiUrl: "http://127.0.0.1:6806",
|
||||||
|
token: "",
|
||||||
|
notebooks: [],
|
||||||
|
});
|
||||||
|
rmSync(initDir, { recursive: true, force: true });
|
||||||
|
|
||||||
// ---- syncNotebooks(第 10 条)— 用临时路径,不污染真实配置 ----
|
// ---- syncNotebooks(第 10 条)— 用临时路径,不污染真实配置 ----
|
||||||
const cfg: any = {
|
const cfg: any = {
|
||||||
apiUrl: "u",
|
apiUrl: "u",
|
||||||
|
|||||||
+9
-5
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import type { SiYuanClient } from "./siyuan-client.ts";
|
import type { SiYuanClient } from "./siyuan-client.ts";
|
||||||
|
|
||||||
export type Perm = "R" | "W" | "D";
|
export type Perm = "R" | "W" | "D";
|
||||||
@@ -86,7 +86,13 @@ export function loadConfig(cfgPath: string = CONFIG_PATH): PiSiyuanConfig {
|
|||||||
// 损坏则重建
|
// 损坏则重建
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { apiUrl: "", token: "", notebooks: [] };
|
const cfg = {
|
||||||
|
apiUrl: "http://127.0.0.1:6806",
|
||||||
|
token: "",
|
||||||
|
notebooks: [],
|
||||||
|
};
|
||||||
|
saveConfig(cfg, cfgPath);
|
||||||
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConnection(): { apiUrl: string; token: string } {
|
export function resolveConnection(): { apiUrl: string; token: string } {
|
||||||
@@ -121,9 +127,7 @@ export function syncNotebooks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveConfig(cfg: PiSiyuanConfig, cfgPath: string = CONFIG_PATH) {
|
export function saveConfig(cfg: PiSiyuanConfig, cfgPath: string = CONFIG_PATH) {
|
||||||
mkdirSync(join(homedir(), ".pi/agent/extensions/pi-siyuan"), {
|
mkdirSync(dirname(cfgPath), { recursive: true });
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
|
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-6
@@ -4,7 +4,7 @@
|
|||||||
* 架构(AGENTS.md 第 5 条):直接封装 SiYuan HTTP API + 权限审计层。
|
* 架构(AGENTS.md 第 5 条):直接封装 SiYuan HTTP API + 权限审计层。
|
||||||
* 工具划分与参数设计参考 porkll/siyuan-mcp,不依赖其代码。
|
* 工具划分与参数设计参考 porkll/siyuan-mcp,不依赖其代码。
|
||||||
*
|
*
|
||||||
* 渐进式披露(goal 4):15 个工具全部 registerTool 注册,
|
* 渐进式披露(goal 4):16 个工具全部 registerTool 注册,
|
||||||
* 但初始只激活 siyuan_discover 一个 loader 工具;
|
* 但初始只激活 siyuan_discover 一个 loader 工具;
|
||||||
* loader 按需 setActiveTools 增量激活匹配的工具。
|
* loader 按需 setActiveTools 增量激活匹配的工具。
|
||||||
*
|
*
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { basename } from "node:path";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { SiYuanClient } from "./siyuan-client.ts";
|
import { SiYuanClient } from "./siyuan-client.ts";
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +25,8 @@ import {
|
|||||||
type PiSiyuanConfig,
|
type PiSiyuanConfig,
|
||||||
} from "./audit.ts";
|
} 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([
|
const SIYUAN_TOOL_NAMES = new Set([
|
||||||
"unified_search",
|
"unified_search",
|
||||||
"get_document_content",
|
"get_document_content",
|
||||||
@@ -40,6 +43,7 @@ const SIYUAN_TOOL_NAMES = new Set([
|
|||||||
"rollback_to_snapshot",
|
"rollback_to_snapshot",
|
||||||
"list_all_tags",
|
"list_all_tags",
|
||||||
"batch_replace_tag",
|
"batch_replace_tag",
|
||||||
|
"upload_asset",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface ToolDeps {
|
interface ToolDeps {
|
||||||
@@ -95,7 +99,7 @@ export default function siyuanExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 15 个工具(第 11 条) ============
|
// ============ 16 个工具(第 11/13 条) ============
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "list_notebooks",
|
name: "list_notebooks",
|
||||||
@@ -375,11 +379,68 @@ export default function siyuanExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// ============ 渐进式披露 loader(goal 4) ============
|
// ============ 渐进式披露 loader(goal 4) ============
|
||||||
|
|
||||||
|
const ASSET_MIME: Record<string, string> = {
|
||||||
|
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({
|
pi.registerTool({
|
||||||
name: "siyuan_discover",
|
name: "siyuan_discover",
|
||||||
label: "SiYuan Discover",
|
label: "SiYuan Discover",
|
||||||
description:
|
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 激活对应工具",
|
promptSnippet: "操作 SiYuan 笔记时先用 siyuan_discover 激活对应工具",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
query: Type.Optional(
|
query: Type.Optional(
|
||||||
@@ -422,11 +483,16 @@ export default function siyuanExtension(pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始只激活 loader(渐进式披露)
|
// 初始只激活 loader(渐进式披露),并同步笔记本权限配置
|
||||||
pi.on("session_start", () => {
|
pi.on("session_start", async () => {
|
||||||
const initial = pi
|
const initial = pi
|
||||||
.getActiveTools()
|
.getActiveTools()
|
||||||
.filter((name) => !SIYUAN_TOOL_NAMES.has(name));
|
.filter((name) => !SIYUAN_TOOL_NAMES.has(name));
|
||||||
pi.setActiveTools([...new Set([...initial, "siyuan_discover"])]);
|
pi.setActiveTools([...new Set([...initial, "siyuan_discover"])]);
|
||||||
|
try {
|
||||||
|
syncNotebooks(cfg, await client.lsNotebooks());
|
||||||
|
} catch {
|
||||||
|
// SiYuan 离线不应阻止 Pi 启动;工具调用时会返回连接错误
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-6
@@ -130,6 +130,40 @@ export class SiYuanClient {
|
|||||||
return { docId, created: true };
|
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<string, string> }> {
|
||||||
|
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<string, string> = 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)----
|
// ---- 搜索(fulltext 端点被吞,用 SQL)----
|
||||||
|
|
||||||
async searchContent(query: string, limit: number): Promise<any[]> {
|
async searchContent(query: string, limit: number): Promise<any[]> {
|
||||||
@@ -181,16 +215,30 @@ export class SiYuanClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async call(path: string, body: unknown): Promise<any> {
|
private async call(path: string, body: unknown): Promise<any> {
|
||||||
|
return this.callRaw(
|
||||||
|
path,
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
JSON.stringify(body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callForm(path: string, body: FormData): Promise<any> {
|
||||||
|
// FormData 由 fetch 自动生成含 boundary 的 Content-Type
|
||||||
|
return this.callRaw(path, {}, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callRaw(
|
||||||
|
path: string,
|
||||||
|
extraHeaders: Record<string, string>,
|
||||||
|
body?: BodyInit,
|
||||||
|
): Promise<any> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(this.apiUrl + path, {
|
res = await fetch(this.apiUrl + path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { Authorization: `Token ${this.token}`, ...extraHeaders },
|
||||||
Authorization: `Token ${this.token}`,
|
body,
|
||||||
"Content-Type": "application/json",
|
signal: AbortSignal.timeout(120000),
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`);
|
throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`);
|
||||||
|
|||||||
@@ -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 ✅");
|
||||||
Reference in New Issue
Block a user