@shgroup/dsh-serenity-hooks 1.50.1 → 1.51.1
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 +3 -2
- package/assets/diagram/DejaVuSans.ttf +0 -0
- package/assets/diagram/DroidSansFallbackFull.ttf +0 -0
- package/assets/diagram/LICENSE-Apache-2.0.txt +202 -0
- package/assets/diagram/LICENSE-DejaVu.txt +78 -0
- package/assets/diagram/LICENSE-OFL-1.1.txt +155 -0
- package/assets/diagram/NotoSans-Regular.ttf +0 -0
- package/assets/diagram/PROVENANCE.md +89 -0
- package/dsh.plugin.json +4 -3
- package/lib/{ccc-roots-Duk89a-h.js → ccc-roots-DtiMVRPo.js} +2 -2
- package/lib/client.js +23 -0
- package/lib/diagram/dsl-render.d.ts +8 -0
- package/lib/diagram/dsl.d.ts +65 -0
- package/lib/diagram/fonts.d.ts +40 -0
- package/lib/diagram/rasterize.d.ts +35 -0
- package/lib/handyman-ops.d.ts +1 -1
- package/lib/index.d.ts +21 -0
- package/lib/index.js +1028 -40
- package/lib/invariant.d.ts +5 -1
- package/lib/invariant.js +6 -1
- package/lib/{message-source-D72jte0u.js → message-source-LnJwIqLm.js} +1 -1
- package/lib/{rolldown-runtime-D7D4PA-g.js → rolldown-runtime-8H4AJuhK.js} +1 -0
- package/lib/{settings-section-EmaYsI-e.js → settings-section-xHABl8nk.js} +8 -2
- package/lib/settings-section.d.ts +14 -0
- package/lib/{skiff-role-KqQA6pkr.js → skiff-role-DQZ6eR51.js} +1 -1
- package/lib/tools/diagram.d.ts +2 -0
- package/lib/{weixin-api-CtFYm53S.js → weixin-api-BrRcPiQs.js} +1 -1
- package/lib/{weixin-route-CWMRa36W.js → weixin-route-7BtOiy4X.js} +1 -1
- package/package.json +12 -2
package/lib/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime-
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
2
|
import { a as hostSessions, c as hostWeb, i as hostService, l as hostWebServer, n as hostInjected, o as hostSettings, r as hostLlm, s as hostSubagents, t as hostAgents } from "./access-CNccNtgf.js";
|
|
3
3
|
import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$1, f as readExclusiveTools, g as resolveInside, i as findGitRoot, l as pathInside, m as readTrajectorySkills, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readHandymanConfig, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-DG4Oc7I1.js";
|
|
4
|
-
import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-
|
|
5
|
-
import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-
|
|
6
|
-
import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-
|
|
4
|
+
import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-DtiMVRPo.js";
|
|
5
|
+
import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-LnJwIqLm.js";
|
|
6
|
+
import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-7BtOiy4X.js";
|
|
7
7
|
import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, o as ACC_VERSION, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-BjIX_KD1.js";
|
|
8
|
-
import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-
|
|
9
|
-
import { a as
|
|
10
|
-
import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-
|
|
8
|
+
import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-DQZ6eR51.js";
|
|
9
|
+
import { a as ACP_HTTP_PORT, c as MAIN_WEB_PORT, d as WEIXIN_SEND_PORT, l as MECHANISM_PORTS, n as readSimpleSettings, o as FACE_PORTS, r as registerSettingsSection, s as GATEWAY_PORT, u as SKIFF_DEBUG_PORT } from "./settings-section-xHABl8nk.js";
|
|
10
|
+
import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-BrRcPiQs.js";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
11
12
|
import z from "@deepseek-ai/schemastery";
|
|
12
13
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
13
14
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -24,6 +25,7 @@ import * as zlib from "node:zlib";
|
|
|
24
25
|
import { deriveEventMessage } from "@deepseek-ai/dsh-session";
|
|
25
26
|
import { lookup } from "node:dns/promises";
|
|
26
27
|
import { isIP } from "node:net";
|
|
28
|
+
import { Resvg, initWasm } from "@resvg/resvg-wasm";
|
|
27
29
|
//#region src/fs-ops.ts
|
|
28
30
|
/**
|
|
29
31
|
* fs-ops.ts — container_fs 纯操作层(cc_fs → container_fs,v1.30;零 DSH 依赖,可独立单测)
|
|
@@ -1984,11 +1986,11 @@ const HANDYMAN_GUIDE = `# handyman — Scale-Up Usage Guide (guide)
|
|
|
1984
1986
|
## Two modes (v1.31.3; default = foreground)
|
|
1985
1987
|
| | foreground (default) | background |
|
|
1986
1988
|
|---|---|---|
|
|
1987
|
-
| What | child agent(s) run the task ONCE and return their final text (jobs=[...] ⇒ parallel, cap
|
|
1989
|
+
| What | child agent(s) run the task ONCE and return their final text (jobs=[...] ⇒ parallel, cap 10) | loop-validated worker: hard while-loop + random completion code |
|
|
1988
1990
|
| Use when | you need result(s) back now (per-case testers, single conversions, parallel fan-out) | long/unattended work needing anti-early-finish, resumability, or parallel jobs |
|
|
1989
1991
|
| Loop / validation | none | stop-token is the ONLY completion condition; round cap (default 100); auto-restart (≤100) |
|
|
1990
1992
|
| Progress file | none | AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes) |
|
|
1991
|
-
| Parallel jobs | **yes** — jobs=[{task,label?,model?},...], **cap
|
|
1993
|
+
| Parallel jobs | **yes** — jobs=[{task,label?,model?},...], **cap 10 (fixed)** | yes (jobs=[...], handyman.maxParallel default 10) |
|
|
1992
1994
|
| Mechanism | host delegation service ctx.subagents.start("spawn") + agentOptions | ctx.agents.create + internal while-loop |
|
|
1993
1995
|
| Model source | CCC handyman.models whitelist (same for both) | same |
|
|
1994
1996
|
|
|
@@ -2027,12 +2029,12 @@ Before calling handyman, load eap (acc-eap skill) and design the "scale-up handy
|
|
|
2027
2029
|
|
|
2028
2030
|
### 4. Parallel strategy (both modes)
|
|
2029
2031
|
- Independent subtasks can run in parallel:
|
|
2030
|
-
· **foreground** — handyman(jobs=[{task,label?,model?},...]) ⇒ fan-out of custom-model subagents, **cap
|
|
2032
|
+
· **foreground** — handyman(jobs=[{task,label?,model?},...]) ⇒ fan-out of custom-model subagents, **cap 10**,
|
|
2031
2033
|
results come back **in the order you passed them** (label is only a display name here; per-job "model" allowed)
|
|
2032
2034
|
· **background** — handyman(mode="background", jobs=[...]): each job gets its own label + task + stop token + progress file
|
|
2033
2035
|
- Concurrency safety guaranteed: unique sessionId (handyman-<label>-<uuid>), progress files isolated per label
|
|
2034
2036
|
(AGENT_SESSIONS/handyman-<label>.json) — same label resumes, different labels never interfere
|
|
2035
|
-
- Parallel cap: **foreground
|
|
2037
|
+
- Parallel cap: **foreground 10 (fixed)** / background handyman.maxParallel (default 10 — cheap models are cheap)
|
|
2036
2038
|
- Aggregation: foreground returns all outputs in one result; background: after each parallel job produces progress, the main agent merges (or spawns one aggregation handyman)
|
|
2037
2039
|
- For programmable pipeline/phase orchestration at scale, use the platform's workflow tool instead
|
|
2038
2040
|
|
|
@@ -3133,7 +3135,7 @@ function toolsBlock() {
|
|
|
3133
3135
|
" container_git— git operations (status/commit/push/log)",
|
|
3134
3136
|
" msm — execute a registered CCC MSM: msm(name, args); partial name returns candidates; inspect=true shows usage",
|
|
3135
3137
|
" praxis — actionable theory injection: praxis (index) / praxis eap / praxis neat / praxis cce",
|
|
3136
|
-
" handyman — delegate work to a worker agent on a CCC-whitelisted model; mode=\"foreground\" (default) = child agent(s) run once and return their final text (jobs=[...] fans out in parallel, cap
|
|
3138
|
+
" handyman — delegate work to a worker agent on a CCC-whitelisted model; mode=\"foreground\" (default) = child agent(s) run once and return their final text (jobs=[...] fans out in parallel, cap 10, per-job model); mode=\"background\" = loop-validated worker (completion code + round cap + restart + progress file), jobs=[] orchestrates parallel work",
|
|
3137
3139
|
" localstore — ACC local credential/config storage (CCC-root localstore.json; git policy localstore.gitTrack default deny)",
|
|
3138
3140
|
" container_admin — container administration (the maintenance bay): role (Skiff roles: guide/validate/apply/list) / msm (register/deregister/check/guide/catalog/ccc-config) / config (view)",
|
|
3139
3141
|
"",
|
|
@@ -4459,16 +4461,18 @@ function parseJobs(raw) {
|
|
|
4459
4461
|
return jobs;
|
|
4460
4462
|
}
|
|
4461
4463
|
/**
|
|
4462
|
-
* 🔴 **foreground 并行上限 =
|
|
4463
|
-
*
|
|
4464
|
+
* 🔴 **foreground 并行上限 = 10**(owner 2026-09-29 具名令:「这个版本顺手帮我把 handyman 并行度从 5 调整成 10」;
|
|
4465
|
+
* 原值 5 出自 owner 2026-09-27 的具名令「最大 5;就当是支持自定模型的 subagent 用了,急需」)。
|
|
4464
4466
|
*
|
|
4465
|
-
* 为什么是**硬编码**而不是又一个配置键:owner
|
|
4466
|
-
* —— 设定项能不加就不加,见 S142
|
|
4467
|
+
* 为什么是**硬编码**而不是又一个配置键:owner 两次都是**直接给数**(同 `REBUILD-TODO` 那次的裁决口径
|
|
4468
|
+
* —— 设定项能不加就不加,见 S142 的「可砍设定项」)。若将来还要调,仍按具名令改这个常量。
|
|
4467
4469
|
*
|
|
4468
|
-
* 为什么 foreground
|
|
4469
|
-
* agent
|
|
4470
|
+
* 为什么 foreground 仍与 background 分开(后台 = `handyman.maxParallel`,缺省 **10**):**foreground 会占住
|
|
4471
|
+
* 主 agent 的这一轮**(调用方在等结果),并发越高越容易把宿主的并发面与用量推上去。
|
|
4472
|
+
* ⚠️ 现在两档**数值相等但语义不同**(前台 = 占住本轮 / 后台 = 不占)⇒ **刻意不合并成一个常量**:
|
|
4473
|
+
* 合并会让"哪天要给前台单独降档"变成一次语义手术。
|
|
4470
4474
|
*/
|
|
4471
|
-
const FOREGROUND_MAX_PARALLEL =
|
|
4475
|
+
const FOREGROUND_MAX_PARALLEL = 10;
|
|
4472
4476
|
/** foreground 的 jobs 解析(与 background 的差别:**label 可选** —— 前台 label 只是显示名,不命名进度文件) */
|
|
4473
4477
|
function parseForegroundJobs(raw) {
|
|
4474
4478
|
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
@@ -4708,11 +4712,11 @@ async function runHandymanJob(ctx, opts) {
|
|
|
4708
4712
|
function createHandymanTool(ctx) {
|
|
4709
4713
|
return defineTool({
|
|
4710
4714
|
name: "handyman",
|
|
4711
|
-
description: "The handyman (杂工): delegate work to a worker agent on a CCC-configured model. Two modes (v1.31.3).\nMode \"foreground\" (DEFAULT): one serial child agent runs the task once and returns its final text — no loop, no completion-code validation, no progress file; use it for one-shot serial delegation (e.g. per-case testers that just gather evidence). Usage: handyman(task, [label], [model]).\nMode \"background\": the loop-validated worker — internal hard while-loop whose ONLY completion condition is the worker echoing this round's random completion code (stop token, prevents low-intelligence models from finishing early), round cap (default 100, resumable), automatic restart on abnormal stop (≤100), progress file AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes), and parallel jobs (handyman.maxParallel, default 10). Usage: handyman(mode=\"background\", task, label, [model]) or handyman(mode=\"background\", jobs=[{task,label,model?},...]).\nChoosing: need exactly one result back now → foreground; need anti-early-finish guarantees or resumability → background; need N results back now (parallel, per-job model) → foreground with jobs (cap
|
|
4715
|
+
description: "The handyman (杂工): delegate work to a worker agent on a CCC-configured model. Two modes (v1.31.3).\nMode \"foreground\" (DEFAULT): one serial child agent runs the task once and returns its final text — no loop, no completion-code validation, no progress file; use it for one-shot serial delegation (e.g. per-case testers that just gather evidence). Usage: handyman(task, [label], [model]).\nMode \"background\": the loop-validated worker — internal hard while-loop whose ONLY completion condition is the worker echoing this round's random completion code (stop token, prevents low-intelligence models from finishing early), round cap (default 100, resumable), automatic restart on abnormal stop (≤100), progress file AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes), and parallel jobs (handyman.maxParallel, default 10). Usage: handyman(mode=\"background\", task, label, [model]) or handyman(mode=\"background\", jobs=[{task,label,model?},...]).\nChoosing: need exactly one result back now → foreground; need anti-early-finish guarantees or resumability → background; need N results back now (parallel, per-job model) → foreground with jobs (cap 10).\nModel: only models whitelisted in .opencode/serenity.json \"handyman.models\" (missing config = error); default reads handyman.defaultModel. Both modes share the same whitelist and default.\nProvider check: the whitelist lives in the CCC (it travels with the repo) while provider adapters are registered per machine by the host — so before spawning, the provider of the resolved model is verified against the adapters present on THIS machine. If it is missing you get the available providers plus the exact fix (foreground: in `diagnostic`; background: thrown), never an opaque failure.\nRecursion: a worker's tool set excludes handyman itself (orchestration belongs to the main agent).\nGuide: handyman(guide=true) prints the scale-up usage guide.",
|
|
4712
4716
|
parameters: {
|
|
4713
4717
|
mode: {
|
|
4714
4718
|
type: "string",
|
|
4715
|
-
description: "Delegation mode: \"foreground\" (default — child agent(s) run once and return their final text; accepts `jobs` for parallel fan-out, cap
|
|
4719
|
+
description: "Delegation mode: \"foreground\" (default — child agent(s) run once and return their final text; accepts `jobs` for parallel fan-out, cap 10) or \"background\" (loop-validated worker with completion-code check, round cap, auto-restart, progress file, parallel jobs)"
|
|
4716
4720
|
},
|
|
4717
4721
|
task: {
|
|
4718
4722
|
type: "string",
|
|
@@ -4732,7 +4736,7 @@ function createHandymanTool(ctx) {
|
|
|
4732
4736
|
},
|
|
4733
4737
|
jobs: {
|
|
4734
4738
|
type: "array",
|
|
4735
|
-
description: "Multi-job orchestration (workflow capability): [{task, label?, model?}, ...] — runs jobs in parallel. Cap: foreground
|
|
4739
|
+
description: "Multi-job orchestration (workflow capability): [{task, label?, model?}, ...] — runs jobs in parallel. Cap: foreground 10 (fixed) / background handyman.maxParallel (default 10). Results keep the order you passed them in.",
|
|
4736
4740
|
items: {
|
|
4737
4741
|
type: "object",
|
|
4738
4742
|
properties: {
|
|
@@ -10354,7 +10358,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10354
10358
|
return;
|
|
10355
10359
|
}
|
|
10356
10360
|
const settings = readAdvancedSettings();
|
|
10357
|
-
const { readSimpleSettings } = await import("./settings-section-
|
|
10361
|
+
const { readSimpleSettings } = await import("./settings-section-xHABl8nk.js").then((n) => n.i);
|
|
10358
10362
|
const simple = readSimpleSettings();
|
|
10359
10363
|
const allowed = settings.publicAsk.allowed;
|
|
10360
10364
|
const port = simple.acpHttpPort ?? ACP_HTTP_PORT;
|
|
@@ -10389,11 +10393,11 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10389
10393
|
sendJson$4(res, 400, { error: chk.error });
|
|
10390
10394
|
return;
|
|
10391
10395
|
}
|
|
10392
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
10396
|
+
const { readWeixinSettings } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10393
10397
|
const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10394
10398
|
const settings = readWeixinSettings(chk.root);
|
|
10395
10399
|
const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
|
|
10396
|
-
const { readWeixinCredential } = await import("./weixin-route-
|
|
10400
|
+
const { readWeixinCredential } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10397
10401
|
const accounts = (settings.accounts ?? []).map((a) => ({
|
|
10398
10402
|
accountId: a.accountId,
|
|
10399
10403
|
name: a.name ?? void 0,
|
|
@@ -10422,10 +10426,10 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10422
10426
|
}
|
|
10423
10427
|
const root = chk.root;
|
|
10424
10428
|
if (body.action === "login-start") {
|
|
10425
|
-
const { fetchQRCode } = await import("./weixin-api-
|
|
10429
|
+
const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
10426
10430
|
const { randomUUID } = await import("node:crypto");
|
|
10427
10431
|
for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
|
|
10428
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
10432
|
+
const { readWeixinSettings } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10429
10433
|
const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
|
|
10430
10434
|
const loginKey = randomUUID();
|
|
10431
10435
|
weixinLogins.set(loginKey, {
|
|
@@ -10446,7 +10450,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10446
10450
|
sendJson$4(res, 400, { error: "missing accountId" });
|
|
10447
10451
|
return;
|
|
10448
10452
|
}
|
|
10449
|
-
const { removeWeixinAccount } = await import("./weixin-route-
|
|
10453
|
+
const { removeWeixinAccount } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10450
10454
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10451
10455
|
removeWeixinAccount(root, body.accountId);
|
|
10452
10456
|
syncCccBridge(ctx, root);
|
|
@@ -10459,8 +10463,8 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10459
10463
|
sendJson$4(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
|
|
10460
10464
|
return;
|
|
10461
10465
|
}
|
|
10462
|
-
const { saveWeixinRoutes } = await import("./weixin-route-
|
|
10463
|
-
const { readSkiffRoles } = await import("./skiff-role-
|
|
10466
|
+
const { saveWeixinRoutes } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10467
|
+
const { readSkiffRoles } = await import("./skiff-role-DQZ6eR51.js").then((n) => n.u);
|
|
10464
10468
|
const roles = readSkiffRoles(root);
|
|
10465
10469
|
for (const r of routes) if (!roles.has(r.role)) {
|
|
10466
10470
|
sendJson$4(res, 400, { error: `unknown role: ${r.role} (not in ${root} skiff.roles)` });
|
|
@@ -10472,7 +10476,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10472
10476
|
}
|
|
10473
10477
|
if (body.action === "set-enabled") {
|
|
10474
10478
|
const enabled = body.enabled === true;
|
|
10475
|
-
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-
|
|
10479
|
+
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10476
10480
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10477
10481
|
const settings = readWeixinSettings(root);
|
|
10478
10482
|
if (enabled && (settings.accounts ?? []).length === 0) {
|
|
@@ -10515,8 +10519,8 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10515
10519
|
sendJson$4(res, 200, { status: "expired" });
|
|
10516
10520
|
return;
|
|
10517
10521
|
}
|
|
10518
|
-
const { pollQRStatus } = await import("./weixin-api-
|
|
10519
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
10522
|
+
const { pollQRStatus } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
10523
|
+
const { readWeixinSettings } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10520
10524
|
const settings = readWeixinSettings(login.root);
|
|
10521
10525
|
const status = await pollQRStatus({
|
|
10522
10526
|
baseUrl: void 0,
|
|
@@ -10531,7 +10535,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10531
10535
|
});
|
|
10532
10536
|
return;
|
|
10533
10537
|
}
|
|
10534
|
-
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-
|
|
10538
|
+
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10535
10539
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10536
10540
|
const accountId = nextWeixinAccountId(settings);
|
|
10537
10541
|
upsertWeixinAccount(login.root, {
|
|
@@ -10646,12 +10650,12 @@ const VOYAGE_PATH = "/serenity/voyage";
|
|
|
10646
10650
|
*/
|
|
10647
10651
|
const VOYAGE_ASSET = "assets/serenity-voyage.html";
|
|
10648
10652
|
/** 包根 = 本模块所在目录的上一级(构建产物在 `lib/`,与 `assets/` 同级;同 constants.ts 定位法) */
|
|
10649
|
-
function packageRootPath() {
|
|
10653
|
+
function packageRootPath$1() {
|
|
10650
10654
|
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10651
10655
|
}
|
|
10652
10656
|
/** 资产绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
|
|
10653
10657
|
function voyageAssetPath() {
|
|
10654
|
-
return join(packageRootPath(), VOYAGE_ASSET);
|
|
10658
|
+
return join(packageRootPath$1(), VOYAGE_ASSET);
|
|
10655
10659
|
}
|
|
10656
10660
|
/** 缓存(830 KB,避免每次展开卡片都读盘;进程内只读一次) */
|
|
10657
10661
|
let cachedHtml = null;
|
|
@@ -16684,7 +16688,7 @@ function manualOutputFallbackNeeded(input) {
|
|
|
16684
16688
|
*/
|
|
16685
16689
|
async function resolveCccRootByAlias(ctx, alias) {
|
|
16686
16690
|
try {
|
|
16687
|
-
const { listCccs } = await import("./ccc-roots-
|
|
16691
|
+
const { listCccs } = await import("./ccc-roots-DtiMVRPo.js").then((n) => n.a);
|
|
16688
16692
|
const entries = await listCccs(ctx);
|
|
16689
16693
|
const byName = entries.find((e) => e.name === alias);
|
|
16690
16694
|
if (byName) return byName.root;
|
|
@@ -17183,7 +17187,7 @@ function syncHumanChannel(ctx) {
|
|
|
17183
17187
|
(async () => {
|
|
17184
17188
|
try {
|
|
17185
17189
|
stopMachineLoops();
|
|
17186
|
-
const { listCccs } = await import("./ccc-roots-
|
|
17190
|
+
const { listCccs } = await import("./ccc-roots-DtiMVRPo.js").then((n) => n.a);
|
|
17187
17191
|
for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
|
|
17188
17192
|
} catch {}
|
|
17189
17193
|
})();
|
|
@@ -17419,7 +17423,7 @@ function matchCcc(input, candidates) {
|
|
|
17419
17423
|
}
|
|
17420
17424
|
/** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
|
|
17421
17425
|
async function collectCandidates(ctx) {
|
|
17422
|
-
const { listCccs } = await import("./ccc-roots-
|
|
17426
|
+
const { listCccs } = await import("./ccc-roots-DtiMVRPo.js").then((n) => n.a);
|
|
17423
17427
|
const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
|
|
17424
17428
|
const seen = /* @__PURE__ */ new Set();
|
|
17425
17429
|
const out = [];
|
|
@@ -19270,6 +19274,936 @@ function createAccDiagTool(ctx) {
|
|
|
19270
19274
|
});
|
|
19271
19275
|
}
|
|
19272
19276
|
//#endregion
|
|
19277
|
+
//#region src/diagram/dsl.ts
|
|
19278
|
+
/** 文本形态的标签(其余标签自闭合、无文本内容) */
|
|
19279
|
+
const TEXT_TAGS = [
|
|
19280
|
+
"title",
|
|
19281
|
+
"subtitle",
|
|
19282
|
+
"text"
|
|
19283
|
+
];
|
|
19284
|
+
/** 属性值 → 数字;缺失或非数字返回 undefined(不抛) */
|
|
19285
|
+
function num(v) {
|
|
19286
|
+
if (v === void 0) return void 0;
|
|
19287
|
+
const t = v.trim();
|
|
19288
|
+
if (t === "") return void 0;
|
|
19289
|
+
const n = Number(t);
|
|
19290
|
+
return Number.isFinite(n) ? n : void 0;
|
|
19291
|
+
}
|
|
19292
|
+
/** 逗号分隔的 4 个数字;不合法返回 undefined */
|
|
19293
|
+
function quad(v) {
|
|
19294
|
+
const parts = v.split(",");
|
|
19295
|
+
if (parts.length !== 4) return void 0;
|
|
19296
|
+
const [x0, y0, x1, y1] = parts.map(num);
|
|
19297
|
+
if (x0 === void 0 || y0 === void 0 || x1 === void 0 || y1 === void 0) return void 0;
|
|
19298
|
+
return {
|
|
19299
|
+
x0,
|
|
19300
|
+
y0,
|
|
19301
|
+
x1,
|
|
19302
|
+
y1
|
|
19303
|
+
};
|
|
19304
|
+
}
|
|
19305
|
+
/** 剥离 <!-- ... -->(可跨行);用空格占位以保住行号 */
|
|
19306
|
+
function stripComments(src) {
|
|
19307
|
+
let out = "";
|
|
19308
|
+
let i = 0;
|
|
19309
|
+
while (i < src.length) {
|
|
19310
|
+
const start = src.indexOf("<!--", i);
|
|
19311
|
+
if (start < 0) {
|
|
19312
|
+
out += src.slice(i);
|
|
19313
|
+
break;
|
|
19314
|
+
}
|
|
19315
|
+
out += src.slice(i, start);
|
|
19316
|
+
const end = src.indexOf("-->", start + 4);
|
|
19317
|
+
const stop = end < 0 ? src.length : end + 3;
|
|
19318
|
+
out += src.slice(start, stop).replace(/[^\n]/g, " ");
|
|
19319
|
+
i = stop;
|
|
19320
|
+
}
|
|
19321
|
+
return out;
|
|
19322
|
+
}
|
|
19323
|
+
/** 索引处字符所在行(1-based) */
|
|
19324
|
+
function lineAt(src, idx) {
|
|
19325
|
+
let ln = 1;
|
|
19326
|
+
for (let i = 0; i < idx; i++) if (src[i] === "\n") ln++;
|
|
19327
|
+
return ln;
|
|
19328
|
+
}
|
|
19329
|
+
/** 解析标签格式源码。**绝不抛异常**:看不懂的行记进 errors 并跳过,其余照收。 */
|
|
19330
|
+
function parseDsl(source) {
|
|
19331
|
+
const src = stripComments(source);
|
|
19332
|
+
const errors = [];
|
|
19333
|
+
/**
|
|
19334
|
+
* 🔴 **默认坐标系 = 画布像素、y 向下**(v1.51.1 修的真缺陷,见下)。
|
|
19335
|
+
*
|
|
19336
|
+
* v1.51.0 的默认值是从旧 mini-SVG 移植时**原样带过来**的:`0~100 × −19~72 且 y 向上`
|
|
19337
|
+
* —— 而工具描述里**只写了 `box="x0,y0,x1,y1"`,没写量程与单位**。
|
|
19338
|
+
* 模型按常识写画布像素(`box="100,100,400,200"`)⇒ 渲染器把它塞进那个 100 宽的域里
|
|
19339
|
+
* ⇒ **所有坐标飞出画布**(实测 SVG:`x=990 y=-919 w=2940`),而 `errors`/`lint` **全空**
|
|
19340
|
+
* ⇒ 图是空白却一声不吭,模型只能反复试错(owner 实测「别的 CCC 根本用不起来」的根因)。
|
|
19341
|
+
*
|
|
19342
|
+
* ⇒ 现在:**默认域 = `0,0,width,height`**(`width/height` 未写时 1000×780)⇒ 1 单位 ≈ 1 像素量级,
|
|
19343
|
+
* `y` 越大越靠下(`dsl-render.ts` 的 `py()` 同批改成 y 向下)。
|
|
19344
|
+
* `domain="x0,y0,x1,y1"` 仍然保留 —— 需要"数学式坐标窗口"时显式写它(**y 也向下**,口径只有一种)。
|
|
19345
|
+
*/
|
|
19346
|
+
const model = {
|
|
19347
|
+
W: 1e3,
|
|
19348
|
+
H: 780,
|
|
19349
|
+
x0: 0,
|
|
19350
|
+
y0: 0,
|
|
19351
|
+
x1: 1e3,
|
|
19352
|
+
y1: 780,
|
|
19353
|
+
pad: 10,
|
|
19354
|
+
title: "",
|
|
19355
|
+
subtitle: "",
|
|
19356
|
+
colors: {},
|
|
19357
|
+
rects: [],
|
|
19358
|
+
segments: [],
|
|
19359
|
+
texts: []
|
|
19360
|
+
};
|
|
19361
|
+
const ids = /* @__PURE__ */ new Set();
|
|
19362
|
+
let rectOrdinal = 0;
|
|
19363
|
+
let tagIndex = 0;
|
|
19364
|
+
let i = 0;
|
|
19365
|
+
const fail = (n, ln, msg) => {
|
|
19366
|
+
errors.push(`第 ${n} 个标签(第 ${ln} 行):${msg}`);
|
|
19367
|
+
};
|
|
19368
|
+
while (i < src.length) {
|
|
19369
|
+
const lt = src.indexOf("<", i);
|
|
19370
|
+
if (lt < 0) break;
|
|
19371
|
+
let j = lt + 1;
|
|
19372
|
+
let inQuote = false;
|
|
19373
|
+
while (j < src.length) {
|
|
19374
|
+
const c = src[j];
|
|
19375
|
+
if (c === "\"") inQuote = !inQuote;
|
|
19376
|
+
else if (c === ">" && !inQuote) break;
|
|
19377
|
+
j++;
|
|
19378
|
+
}
|
|
19379
|
+
const ln = lineAt(src, lt);
|
|
19380
|
+
if (j >= src.length) {
|
|
19381
|
+
tagIndex++;
|
|
19382
|
+
fail(tagIndex, ln, "标签未闭合(属性值引号未闭合)");
|
|
19383
|
+
const gt = src.indexOf(">", lt + 1);
|
|
19384
|
+
if (gt < 0) break;
|
|
19385
|
+
i = gt + 1;
|
|
19386
|
+
continue;
|
|
19387
|
+
}
|
|
19388
|
+
const raw = src.slice(lt + 1, j);
|
|
19389
|
+
if (/^\s*\//.test(raw)) {
|
|
19390
|
+
i = j + 1;
|
|
19391
|
+
continue;
|
|
19392
|
+
}
|
|
19393
|
+
tagIndex++;
|
|
19394
|
+
const selfClosing = /\/\s*$/.test(raw);
|
|
19395
|
+
const body = raw.replace(/\/\s*$/, "");
|
|
19396
|
+
const head = /^\s*([A-Za-z_][\w-]*)/.exec(body);
|
|
19397
|
+
const tag = head ? head[1] : void 0;
|
|
19398
|
+
if (tag === void 0) {
|
|
19399
|
+
fail(tagIndex, ln, "标签名缺失或非法");
|
|
19400
|
+
i = j + 1;
|
|
19401
|
+
continue;
|
|
19402
|
+
}
|
|
19403
|
+
const attrs = {};
|
|
19404
|
+
const attrRe = /([A-Za-z_][\w-]*)\s*=\s*"([^"]*)"/g;
|
|
19405
|
+
let a;
|
|
19406
|
+
while ((a = attrRe.exec(body)) !== null) {
|
|
19407
|
+
const [k, val] = [a[1], a[2]];
|
|
19408
|
+
if (k !== void 0 && val !== void 0) attrs[k] = val;
|
|
19409
|
+
}
|
|
19410
|
+
let content = "";
|
|
19411
|
+
let next = j + 1;
|
|
19412
|
+
if (!selfClosing && TEXT_TAGS.indexOf(tag) >= 0) {
|
|
19413
|
+
const close = src.indexOf(`</${tag}`, next);
|
|
19414
|
+
if (close < 0) {
|
|
19415
|
+
fail(tagIndex, ln, `缺少 </${tag}> 闭合标签`);
|
|
19416
|
+
i = next;
|
|
19417
|
+
continue;
|
|
19418
|
+
}
|
|
19419
|
+
content = src.slice(next, close).trim();
|
|
19420
|
+
const gt = src.indexOf(">", close);
|
|
19421
|
+
next = gt < 0 ? src.length : gt + 1;
|
|
19422
|
+
}
|
|
19423
|
+
i = next;
|
|
19424
|
+
if (tag === "diagram") {
|
|
19425
|
+
const w = num(attrs.width);
|
|
19426
|
+
const h = num(attrs.height);
|
|
19427
|
+
if (w !== void 0) model.W = w;
|
|
19428
|
+
if (h !== void 0) model.H = h;
|
|
19429
|
+
if (attrs.domain !== void 0) {
|
|
19430
|
+
const d = quad(attrs.domain);
|
|
19431
|
+
if (d) {
|
|
19432
|
+
model.x0 = d.x0;
|
|
19433
|
+
model.y0 = d.y0;
|
|
19434
|
+
model.x1 = d.x1;
|
|
19435
|
+
model.y1 = d.y1;
|
|
19436
|
+
} else fail(tagIndex, ln, `domain 不是 4 个数字:${attrs.domain}(已忽略)`);
|
|
19437
|
+
} else {
|
|
19438
|
+
model.x0 = 0;
|
|
19439
|
+
model.y0 = 0;
|
|
19440
|
+
model.x1 = model.W;
|
|
19441
|
+
model.y1 = model.H;
|
|
19442
|
+
}
|
|
19443
|
+
continue;
|
|
19444
|
+
}
|
|
19445
|
+
if (tag === "title" || tag === "subtitle") {
|
|
19446
|
+
if (tag === "title") model.title = content;
|
|
19447
|
+
else model.subtitle = content;
|
|
19448
|
+
continue;
|
|
19449
|
+
}
|
|
19450
|
+
if (tag === "color") {
|
|
19451
|
+
const name = attrs.name;
|
|
19452
|
+
const value = attrs.value;
|
|
19453
|
+
if (!name || value === void 0) {
|
|
19454
|
+
fail(tagIndex, ln, "color 缺 name 或 value");
|
|
19455
|
+
continue;
|
|
19456
|
+
}
|
|
19457
|
+
model.colors[name] = value;
|
|
19458
|
+
continue;
|
|
19459
|
+
}
|
|
19460
|
+
if (tag === "legend") {
|
|
19461
|
+
model.legend = {
|
|
19462
|
+
x: num(attrs.x) ?? 0,
|
|
19463
|
+
y: num(attrs.y) ?? 0,
|
|
19464
|
+
size: num(attrs.size) ?? 12
|
|
19465
|
+
};
|
|
19466
|
+
continue;
|
|
19467
|
+
}
|
|
19468
|
+
if (tag === "rect") {
|
|
19469
|
+
rectOrdinal++;
|
|
19470
|
+
if (attrs.box === void 0) {
|
|
19471
|
+
fail(tagIndex, ln, "rect 缺 box");
|
|
19472
|
+
continue;
|
|
19473
|
+
}
|
|
19474
|
+
const b = quad(attrs.box);
|
|
19475
|
+
if (!b) {
|
|
19476
|
+
fail(tagIndex, ln, `box 不是 4 个数字:${attrs.box}`);
|
|
19477
|
+
continue;
|
|
19478
|
+
}
|
|
19479
|
+
const label = attrs.label;
|
|
19480
|
+
const id = attrs.id || attrs.name || label || `rect${rectOrdinal}`;
|
|
19481
|
+
if (ids.has(id)) {
|
|
19482
|
+
fail(tagIndex, ln, `rect id 重复:${id}`);
|
|
19483
|
+
continue;
|
|
19484
|
+
}
|
|
19485
|
+
ids.add(id);
|
|
19486
|
+
const rect = {
|
|
19487
|
+
id,
|
|
19488
|
+
x0: Math.min(b.x0, b.x1),
|
|
19489
|
+
y0: Math.min(b.y0, b.y1),
|
|
19490
|
+
x1: Math.max(b.x0, b.x1),
|
|
19491
|
+
y1: Math.max(b.y0, b.y1),
|
|
19492
|
+
size: num(attrs.size) ?? 13,
|
|
19493
|
+
fill: attrs.fill ?? ""
|
|
19494
|
+
};
|
|
19495
|
+
if (label !== void 0) rect.label = label;
|
|
19496
|
+
model.rects.push(rect);
|
|
19497
|
+
continue;
|
|
19498
|
+
}
|
|
19499
|
+
if (tag === "line" || tag === "arrow") {
|
|
19500
|
+
const from = attrs.from;
|
|
19501
|
+
const to = attrs.to;
|
|
19502
|
+
if (!from || !to) {
|
|
19503
|
+
fail(tagIndex, ln, `${tag} 缺 from 或 to`);
|
|
19504
|
+
continue;
|
|
19505
|
+
}
|
|
19506
|
+
const seg = {
|
|
19507
|
+
kind: tag,
|
|
19508
|
+
from,
|
|
19509
|
+
to,
|
|
19510
|
+
free: attrs.free === "1",
|
|
19511
|
+
ln
|
|
19512
|
+
};
|
|
19513
|
+
if (attrs.color !== void 0) seg.color = attrs.color;
|
|
19514
|
+
const w = num(attrs.width);
|
|
19515
|
+
if (w !== void 0) seg.width = w;
|
|
19516
|
+
if (attrs.dash !== void 0) seg.dash = attrs.dash;
|
|
19517
|
+
model.segments.push(seg);
|
|
19518
|
+
continue;
|
|
19519
|
+
}
|
|
19520
|
+
if (tag === "text") {
|
|
19521
|
+
const x = num(attrs.x);
|
|
19522
|
+
const y = num(attrs.y);
|
|
19523
|
+
if (x === void 0 || y === void 0) {
|
|
19524
|
+
fail(tagIndex, ln, "text 缺 x 或 y");
|
|
19525
|
+
continue;
|
|
19526
|
+
}
|
|
19527
|
+
const al = attrs.align;
|
|
19528
|
+
const t = {
|
|
19529
|
+
x,
|
|
19530
|
+
y,
|
|
19531
|
+
text: content,
|
|
19532
|
+
align: al === "center" || al === "middle" ? "center" : al === "right" || al === "end" ? "right" : "left",
|
|
19533
|
+
size: num(attrs.size) ?? 12.5,
|
|
19534
|
+
ln
|
|
19535
|
+
};
|
|
19536
|
+
if (attrs.color !== void 0) t.color = attrs.color;
|
|
19537
|
+
model.texts.push(t);
|
|
19538
|
+
continue;
|
|
19539
|
+
}
|
|
19540
|
+
fail(tagIndex, ln, `未知标签:<${tag}>`);
|
|
19541
|
+
}
|
|
19542
|
+
return {
|
|
19543
|
+
model,
|
|
19544
|
+
errors
|
|
19545
|
+
};
|
|
19546
|
+
}
|
|
19547
|
+
//#endregion
|
|
19548
|
+
//#region src/diagram/fonts.ts
|
|
19549
|
+
/**
|
|
19550
|
+
* diagram 字体事实源(**唯一真相源**:资产路径 + 家族名 + CSS 家族栈)
|
|
19551
|
+
*
|
|
19552
|
+
* 为什么单独成模块(R↓):两处都要它,而它们的依赖面**必须**保持不同 ——
|
|
19553
|
+
* · `dsl-render.ts` 只生成 SVG 字符串(**纯函数、零 fs**),它需要在 SVG 里写 `font-family` 栈;
|
|
19554
|
+
* · `rasterize.ts` 要读字体文件喂给 wasm,并设 `defaultFontFamily`。
|
|
19555
|
+
* ⇒ 谁都不该从对方 import(前者会被拖进 `node:fs`/wasm,后者不该定义 SVG 措辞)。
|
|
19556
|
+
*
|
|
19557
|
+
* ## 为什么是**三个**字型(每一个都由一张实测图证明,不是偏好,也不是"多带点保险")
|
|
19558
|
+
*
|
|
19559
|
+
* | 字型 | 覆盖 | 它修好了什么(实测) |
|
|
19560
|
+
* |---|---|---|
|
|
19561
|
+
* | `DroidSansFallbackFull.ttf` | 汉字 | 没有它 ⇒ 中文全是方框(docker 实测:wasm 对系统字体**完全不可见**) |
|
|
19562
|
+
* | `NotoSans-Regular.ttf` | 拉丁 | 没有它 ⇒ `diagram` / `parseDsl` 全变方框(Droid 是 **CJK 兜底字型**,**不含拉丁**) |
|
|
19563
|
+
* | `DejaVuSans.ttf` | 符号 | 没有它 ⇒ `① ② ③` / `⇒ ⇒` / `→` 仍是方框(**前两个都没有这些字形**,实测对照图见 `docs/diagram-tool-feasibility.md`) |
|
|
19564
|
+
*
|
|
19565
|
+
* 🔴 **上一版的判据漏洞**(v1.51.1 修的):那条"有字 vs 无字逐字节不同"只证明**有墨**,
|
|
19566
|
+
* 没证明**墨是那个字** —— 方框也是墨 ⇒ 拉丁全变方框时它**照样绿**。
|
|
19567
|
+
* ⇒ 现在每条覆盖声明都配一条**点名字符集**的差分用例:同长度不同字形 ⇒ 必须出不同墨。
|
|
19568
|
+
*
|
|
19569
|
+
* ## 家族名是**量出来的**,不是猜的
|
|
19570
|
+
* 下表取自各文件 `name` 表 `nameID=1`(平台 3 / UTF-16BE):
|
|
19571
|
+
* `Droid Sans Fallback` / `Noto Sans` / `DejaVu Sans`。
|
|
19572
|
+
* 写错名字时 resvg 的自动回退仍可能把字画出来(**图上看不出来**)⇒ 名字正确与否只能这样证明。
|
|
19573
|
+
*/
|
|
19574
|
+
const DIAGRAM_CJK_FONT_ASSET = "assets/diagram/DroidSansFallbackFull.ttf";
|
|
19575
|
+
const DIAGRAM_LATIN_FONT_ASSET = "assets/diagram/NotoSans-Regular.ttf";
|
|
19576
|
+
const DIAGRAM_SYMBOL_FONT_ASSET = "assets/diagram/DejaVuSans.ttf";
|
|
19577
|
+
/** 汉字字型的家族名(**字体文件自报的族名**,写错就取不到) */
|
|
19578
|
+
const DIAGRAM_CJK_FONT_FAMILY = "Droid Sans Fallback";
|
|
19579
|
+
/**
|
|
19580
|
+
* SVG 里写的家族栈(**顺序即回退顺序**):拉丁优先取 Noto,符号落 DejaVu,其余字形落到 Droid。
|
|
19581
|
+
* ⚠️ 末尾的通用名(`sans-serif`)只是兜底惯例:本部署里 resvg **不带系统字体**
|
|
19582
|
+
* (`loadSystemFonts: false`)⇒ 它取不到任何东西,真正的兜底是上面三个内置字型。
|
|
19583
|
+
*/
|
|
19584
|
+
const DIAGRAM_FONT_STACK = `'Noto Sans', 'DejaVu Sans', '${DIAGRAM_CJK_FONT_FAMILY}', sans-serif`;
|
|
19585
|
+
//#endregion
|
|
19586
|
+
//#region src/diagram/dsl-render.ts
|
|
19587
|
+
const TITLE_BAND = 68;
|
|
19588
|
+
const EPS = .35;
|
|
19589
|
+
const DEF_W = 960;
|
|
19590
|
+
const DEF_H = 540;
|
|
19591
|
+
const DEF_PAD = 24;
|
|
19592
|
+
const DEF_SIZE = 14;
|
|
19593
|
+
const DEF_FILL = "#cccccc";
|
|
19594
|
+
const DEF_INK = "#111111";
|
|
19595
|
+
const n = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
19596
|
+
const r2 = (v) => Math.round(v * 100) / 100;
|
|
19597
|
+
const esc = (v) => String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
19598
|
+
const textWidth = (s, size) => {
|
|
19599
|
+
let w = 0;
|
|
19600
|
+
for (const ch of s) w += (ch.codePointAt(0) ?? 0) > 11904 ? size : size * .55;
|
|
19601
|
+
return w;
|
|
19602
|
+
};
|
|
19603
|
+
const lineNo = (s, i) => typeof s.line === "number" && Number.isFinite(s.line) ? s.line : i + 1;
|
|
19604
|
+
const ANCHOR_RE = /^(.+)\.(ne|nw|se|sw|n|s|e|w|c)(?:([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?))?$/;
|
|
19605
|
+
const NUM_PAIR_RE = /^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/;
|
|
19606
|
+
/** 点是否在块内或贴块边(eps = EPS) */
|
|
19607
|
+
const inBox = (p, r) => p.x >= n(r.x0, 0) - EPS && p.x <= n(r.x1, 0) + EPS && p.y >= n(r.y0, 0) - EPS && p.y <= n(r.y1, 0) + EPS;
|
|
19608
|
+
/** Liang-Barsky:线段与轴对齐盒是否相交 */
|
|
19609
|
+
const segHitsBox = (ax, ay, bx, by, box) => {
|
|
19610
|
+
const p = [
|
|
19611
|
+
ax - bx,
|
|
19612
|
+
bx - ax,
|
|
19613
|
+
ay - by,
|
|
19614
|
+
by - ay
|
|
19615
|
+
];
|
|
19616
|
+
const q = [
|
|
19617
|
+
ax - box[0],
|
|
19618
|
+
box[2] - ax,
|
|
19619
|
+
ay - box[1],
|
|
19620
|
+
box[3] - ay
|
|
19621
|
+
];
|
|
19622
|
+
let t0 = 0;
|
|
19623
|
+
let t1 = 1;
|
|
19624
|
+
for (let i = 0; i < 4; i++) {
|
|
19625
|
+
const pi = p[i] ?? 0;
|
|
19626
|
+
const qi = q[i] ?? 0;
|
|
19627
|
+
if (pi === 0) {
|
|
19628
|
+
if (qi < 0) return false;
|
|
19629
|
+
continue;
|
|
19630
|
+
}
|
|
19631
|
+
const r = qi / pi;
|
|
19632
|
+
if (pi < 0) {
|
|
19633
|
+
if (r > t1) return false;
|
|
19634
|
+
if (r > t0) t0 = r;
|
|
19635
|
+
} else if (r < t0) return false;
|
|
19636
|
+
else if (r < t1) t1 = r;
|
|
19637
|
+
}
|
|
19638
|
+
return true;
|
|
19639
|
+
};
|
|
19640
|
+
/** 把模型渲染成 SVG,并做四项体检。绝不抛异常:出错的图元跳过、记 errors、其余照画。 */
|
|
19641
|
+
/**
|
|
19642
|
+
* 🔴 **适配层(2026-09-29 加,修一处真缺陷)**
|
|
19643
|
+
*
|
|
19644
|
+
* 本文件此前用 `model as unknown as Model` 把 `DslModel` **硬 cast** 成自己的内部形状
|
|
19645
|
+
* ⇒ **编译器对字段名彻底变瞎**。而两侧字段名实际不同:
|
|
19646
|
+
* `W/H` vs `width/height` / `fill` vs `color` / `ln` vs `line` / `align` vs `anchor`
|
|
19647
|
+
* ⇒ 运行期整片取到 `undefined`,**而 `typecheck` 照样绿**(那个绿是假信号)。
|
|
19648
|
+
*
|
|
19649
|
+
* 现在改为**逐字段显式搬运**:类型系统会核对每一个名字 ⇒ 再对不上就**编译不过**。
|
|
19650
|
+
* 🆕 判据:**"typecheck 通过"≠"两侧接口一致"** —— 一个 cast 就足以让类型检查失效;
|
|
19651
|
+
* 这一层在,`typecheck` 才是证据。
|
|
19652
|
+
*/
|
|
19653
|
+
function adapt(model) {
|
|
19654
|
+
return {
|
|
19655
|
+
title: model.title,
|
|
19656
|
+
subtitle: model.subtitle,
|
|
19657
|
+
width: model.W,
|
|
19658
|
+
height: model.H,
|
|
19659
|
+
pad: model.pad,
|
|
19660
|
+
x0: model.x0,
|
|
19661
|
+
x1: model.x1,
|
|
19662
|
+
y0: model.y0,
|
|
19663
|
+
y1: model.y1,
|
|
19664
|
+
colors: model.colors,
|
|
19665
|
+
legend: model.legend,
|
|
19666
|
+
rects: model.rects.map((r) => ({
|
|
19667
|
+
id: r.id,
|
|
19668
|
+
label: r.label,
|
|
19669
|
+
x0: r.x0,
|
|
19670
|
+
y0: r.y0,
|
|
19671
|
+
x1: r.x1,
|
|
19672
|
+
y1: r.y1,
|
|
19673
|
+
color: r.fill,
|
|
19674
|
+
size: r.size
|
|
19675
|
+
})),
|
|
19676
|
+
segs: model.segments.map((s) => ({
|
|
19677
|
+
kind: s.kind,
|
|
19678
|
+
from: s.from,
|
|
19679
|
+
to: s.to,
|
|
19680
|
+
color: s.color,
|
|
19681
|
+
width: s.width,
|
|
19682
|
+
dash: s.dash,
|
|
19683
|
+
free: s.free,
|
|
19684
|
+
line: s.ln
|
|
19685
|
+
})),
|
|
19686
|
+
texts: model.texts.map((t) => ({
|
|
19687
|
+
x: t.x,
|
|
19688
|
+
y: t.y,
|
|
19689
|
+
text: t.text,
|
|
19690
|
+
size: t.size,
|
|
19691
|
+
color: t.color,
|
|
19692
|
+
anchor: t.align
|
|
19693
|
+
}))
|
|
19694
|
+
};
|
|
19695
|
+
}
|
|
19696
|
+
function renderDsl(model) {
|
|
19697
|
+
const m = adapt(model);
|
|
19698
|
+
const lint = [];
|
|
19699
|
+
const errors = [];
|
|
19700
|
+
const rects = m.rects ?? [];
|
|
19701
|
+
const segs = m.segs ?? [];
|
|
19702
|
+
const texts = m.texts ?? [];
|
|
19703
|
+
const colors = m.colors ?? {};
|
|
19704
|
+
const ids = rects.map((r) => r.id);
|
|
19705
|
+
const col = (v, d) => {
|
|
19706
|
+
const s = typeof v === "string" ? v : "";
|
|
19707
|
+
if (s === "") return d;
|
|
19708
|
+
if (s.charAt(0) === "#") return s;
|
|
19709
|
+
return colors[s] ?? s;
|
|
19710
|
+
};
|
|
19711
|
+
const W = n(m.width, DEF_W);
|
|
19712
|
+
const H = n(m.height, DEF_H);
|
|
19713
|
+
const pad = n(m.pad, DEF_PAD);
|
|
19714
|
+
const gx = [];
|
|
19715
|
+
const gy = [];
|
|
19716
|
+
for (const r of rects) gx.push(n(r.x0, 0), n(r.x1, 0));
|
|
19717
|
+
for (const r of rects) gy.push(n(r.y0, 0), n(r.y1, 0));
|
|
19718
|
+
for (const t of texts) gx.push(n(t.x, 0));
|
|
19719
|
+
for (const t of texts) gy.push(n(t.y, 0));
|
|
19720
|
+
const hasBox = gx.length > 0 && gy.length > 0;
|
|
19721
|
+
const x0 = n(m.x0, hasBox ? Math.min(...gx) : 0);
|
|
19722
|
+
const x1 = n(m.x1, hasBox ? Math.max(...gx) : 100);
|
|
19723
|
+
const y0 = n(m.y0, hasBox ? Math.min(...gy) : 0);
|
|
19724
|
+
const y1 = n(m.y1, hasBox ? Math.max(...gy) : 100);
|
|
19725
|
+
const scaleX = (W - 2 * pad) / (x1 - x0 || 1);
|
|
19726
|
+
const scaleY = (H - TITLE_BAND - pad) / (y1 - y0 || 1);
|
|
19727
|
+
const px = (x) => r2(pad + (x - x0) * scaleX);
|
|
19728
|
+
const py = (y) => r2(TITLE_BAND + (y - y0) * scaleY);
|
|
19729
|
+
/** 绘图窗口 = 声明的域(默认就是画布)。出窗口的图元**报错并跳过**,不静默画到看不见的地方。 */
|
|
19730
|
+
const tolX = Math.abs(x1 - x0) * .01;
|
|
19731
|
+
const tolY = Math.abs(y1 - y0) * .01;
|
|
19732
|
+
const outside = (x, y) => x < x0 - tolX || x > x1 + tolX || y < y0 - tolY || y > y1 + tolY;
|
|
19733
|
+
const windowText = `坐标窗口 x ${r2(x0)}..${r2(x1)} / y ${r2(y0)}..${r2(y1)}(y 向下;不写 domain 时=画布)`;
|
|
19734
|
+
const resolve = (token) => {
|
|
19735
|
+
const t = String(token ?? "").trim();
|
|
19736
|
+
const np = NUM_PAIR_RE.exec(t);
|
|
19737
|
+
if (np) return {
|
|
19738
|
+
x: Number(np[1]),
|
|
19739
|
+
y: Number(np[2]),
|
|
19740
|
+
numeric: true
|
|
19741
|
+
};
|
|
19742
|
+
const mt = ANCHOR_RE.exec(t);
|
|
19743
|
+
if (!mt) return null;
|
|
19744
|
+
const ref = rects.find((r) => r.id === mt[1]);
|
|
19745
|
+
if (!ref) return null;
|
|
19746
|
+
const rx0 = n(ref.x0, 0);
|
|
19747
|
+
const rx1 = n(ref.x1, 0);
|
|
19748
|
+
const ry0 = n(ref.y0, 0);
|
|
19749
|
+
const ry1 = n(ref.y1, 0);
|
|
19750
|
+
const cx = (rx0 + rx1) / 2;
|
|
19751
|
+
const cy = (ry0 + ry1) / 2;
|
|
19752
|
+
const base = {
|
|
19753
|
+
n: [cx, ry0],
|
|
19754
|
+
s: [cx, ry1],
|
|
19755
|
+
e: [rx1, cy],
|
|
19756
|
+
w: [rx0, cy],
|
|
19757
|
+
c: [cx, cy],
|
|
19758
|
+
ne: [rx1, ry0],
|
|
19759
|
+
nw: [rx0, ry0],
|
|
19760
|
+
se: [rx1, ry1],
|
|
19761
|
+
sw: [rx0, ry1]
|
|
19762
|
+
}[mt[2] ?? ""];
|
|
19763
|
+
if (!base) return null;
|
|
19764
|
+
const dx = mt[3] === void 0 ? 0 : Number(mt[3]);
|
|
19765
|
+
const dy = mt[4] === void 0 ? 0 : Number(mt[4]);
|
|
19766
|
+
return {
|
|
19767
|
+
x: base[0] + dx,
|
|
19768
|
+
y: base[1] + dy,
|
|
19769
|
+
numeric: false
|
|
19770
|
+
};
|
|
19771
|
+
};
|
|
19772
|
+
for (let i = 0; i < rects.length; i++) for (let j = i + 1; j < rects.length; j++) {
|
|
19773
|
+
const A = rects[i];
|
|
19774
|
+
const B = rects[j];
|
|
19775
|
+
const ix = Math.min(n(A.x1, 0), n(B.x1, 0)) - Math.max(n(A.x0, 0), n(B.x0, 0));
|
|
19776
|
+
const iy = Math.min(n(A.y1, 0), n(B.y1, 0)) - Math.max(n(A.y0, 0), n(B.y0, 0));
|
|
19777
|
+
if (ix > 0 && iy > 0) lint.push(`块重叠:${A.label ?? A.id} × ${B.label ?? B.id} ⇒ 相交 ${r2(ix)}×${r2(iy)}(面积 ${r2(ix * iy)} 平方数据单位)`);
|
|
19778
|
+
}
|
|
19779
|
+
for (const r of rects) {
|
|
19780
|
+
if (!r.label) continue;
|
|
19781
|
+
const need = textWidth(r.label, n(r.size, DEF_SIZE));
|
|
19782
|
+
const boxPx = Math.abs(n(r.x1, 0) - n(r.x0, 0)) * scaleX;
|
|
19783
|
+
if (need > boxPx - 6) lint.push(`标签溢出:${r.label}(需 ≈${r2(need)}px,块宽 ${r2(boxPx)}px)`);
|
|
19784
|
+
}
|
|
19785
|
+
const headEls = [], rectEls = [], segEls = [];
|
|
19786
|
+
const textEls = [], legendEls = [], l3 = [], l4 = [];
|
|
19787
|
+
let marks = 0;
|
|
19788
|
+
headEls.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${DIAGRAM_FONT_STACK}">`);
|
|
19789
|
+
headEls.push(`<rect width="${W}" height="${H}" fill="#fbfaf7"/>`);
|
|
19790
|
+
marks += 2;
|
|
19791
|
+
if (m.title) headEls.push(`<text x="10" y="34" font-size="21" font-weight="bold" fill="#111">${esc(m.title)}</text>`);
|
|
19792
|
+
if (m.title) marks += 1;
|
|
19793
|
+
if (m.subtitle) headEls.push(`<text x="10" y="58" font-size="13" fill="#666">${esc(m.subtitle)}</text>`);
|
|
19794
|
+
if (m.subtitle) marks += 1;
|
|
19795
|
+
for (const r of rects) {
|
|
19796
|
+
if (outside(n(r.x0, 0), n(r.y0, 0)) || outside(n(r.x1, 0), n(r.y1, 0))) {
|
|
19797
|
+
errors.push(`矩形「${r.label ?? r.id}」在绘图窗口外:box=${r2(n(r.x0, 0))},${r2(n(r.y0, 0))},${r2(n(r.x1, 0))},${r2(n(r.y1, 0))} / ${windowText} ⇒ 该图元已跳过`);
|
|
19798
|
+
continue;
|
|
19799
|
+
}
|
|
19800
|
+
const X = px(n(r.x0, 0));
|
|
19801
|
+
const Y = py(n(r.y0, 0));
|
|
19802
|
+
const bw = r2(px(n(r.x1, 0)) - X);
|
|
19803
|
+
const bh = r2(py(n(r.y1, 0)) - Y);
|
|
19804
|
+
rectEls.push(`<rect x="${X}" y="${Y}" width="${bw}" height="${bh}" rx="2" fill="${esc(col(r.color, DEF_FILL))}" stroke="#ffffff" stroke-width="1.5"/>`);
|
|
19805
|
+
marks += 1;
|
|
19806
|
+
if (!r.label) continue;
|
|
19807
|
+
const cx = px((n(r.x0, 0) + n(r.x1, 0)) / 2);
|
|
19808
|
+
const cy = py((n(r.y0, 0) + n(r.y1, 0)) / 2);
|
|
19809
|
+
rectEls.push(`<text x="${cx}" y="${cy}" font-size="${n(r.size, DEF_SIZE)}" font-weight="bold" fill="#1b1b1b" text-anchor="middle" dominant-baseline="middle">${esc(r.label)}</text>`);
|
|
19810
|
+
marks += 1;
|
|
19811
|
+
}
|
|
19812
|
+
for (let i = 0; i < segs.length; i++) {
|
|
19813
|
+
const s = segs[i];
|
|
19814
|
+
const kind = s.kind === "arrow" ? "arrow" : "line";
|
|
19815
|
+
const a = resolve(s.from);
|
|
19816
|
+
const b = resolve(s.to);
|
|
19817
|
+
if (!a || !b) {
|
|
19818
|
+
const bad = !a ? s.from : s.to;
|
|
19819
|
+
const shown = ids.slice(0, 8).join("、");
|
|
19820
|
+
errors.push(`第 ${lineNo(s, i)} 行 ${kind}:锚点引用解析不了(${esc(bad)});已知 id = ${shown}${ids.length > 8 ? ` …(共 ${ids.length} 个)` : ""};rect 要能被引用请写 id="…"(name="…" 亦可)⇒ 该图元已跳过`);
|
|
19821
|
+
continue;
|
|
19822
|
+
}
|
|
19823
|
+
if (outside(a.x, a.y) || outside(b.x, b.y)) {
|
|
19824
|
+
errors.push(`第 ${lineNo(s, i)} 行 ${kind}:端点在绘图窗口外 (${r2(a.x)},${r2(a.y)})→(${r2(b.x)},${r2(b.y)}) / ${windowText} ⇒ 该图元已跳过`);
|
|
19825
|
+
continue;
|
|
19826
|
+
}
|
|
19827
|
+
const X0 = px(a.x);
|
|
19828
|
+
const Y0 = py(a.y);
|
|
19829
|
+
const X1 = px(b.x);
|
|
19830
|
+
const Y1 = py(b.y);
|
|
19831
|
+
if (s.free !== true) {
|
|
19832
|
+
if (a.numeric && !rects.some((r) => inBox(a, r))) l3.push(`端点悬空:${kind} 起点 (${r2(a.x)}, ${r2(a.y)}) 不在任何块内、也不贴任何块边`);
|
|
19833
|
+
if (b.numeric && !rects.some((r) => inBox(b, r))) l3.push(`端点悬空:${kind} 终点 (${r2(b.x)}, ${r2(b.y)}) 不在任何块内、也不贴任何块边`);
|
|
19834
|
+
}
|
|
19835
|
+
for (const r of rects) {
|
|
19836
|
+
if (!r.label) continue;
|
|
19837
|
+
const size = n(r.size, DEF_SIZE);
|
|
19838
|
+
const hw = textWidth(r.label, size) / 2 + 1;
|
|
19839
|
+
const hh = size / 2 + 1;
|
|
19840
|
+
const bx = px((n(r.x0, 0) + n(r.x1, 0)) / 2);
|
|
19841
|
+
const by = py((n(r.y0, 0) + n(r.y1, 0)) / 2);
|
|
19842
|
+
if (!segHitsBox(X0, Y0, X1, Y1, [
|
|
19843
|
+
bx - hw,
|
|
19844
|
+
by - hh,
|
|
19845
|
+
bx + hw,
|
|
19846
|
+
by + hh
|
|
19847
|
+
])) continue;
|
|
19848
|
+
l4.push(`线压标签:${kind}(${r2(a.x)},${r2(a.y)})→(${r2(b.x)},${r2(b.y)}) 穿过「${r.label}」⇒ 把该线挪开,或用锚点偏移(如 ${r.id}.n+3,0)`);
|
|
19849
|
+
}
|
|
19850
|
+
const stroke = esc(col(s.color, DEF_INK));
|
|
19851
|
+
const dash = typeof s.dash === "string" && s.dash !== "" ? ` stroke-dasharray="${esc(s.dash)}"` : "";
|
|
19852
|
+
if (kind === "arrow") {
|
|
19853
|
+
const ang = Math.atan2(Y1 - Y0, X1 - X0);
|
|
19854
|
+
const L = 13;
|
|
19855
|
+
const Wd = 6.5;
|
|
19856
|
+
const vx = X1 - Math.cos(ang) * L;
|
|
19857
|
+
const vy = Y1 - Math.sin(ang) * L;
|
|
19858
|
+
segEls.push(`<line x1="${X0}" y1="${Y0}" x2="${r2(vx)}" y2="${r2(vy)}" stroke="${stroke}" stroke-width="${n(s.width, 2.2)}"${dash}/>`);
|
|
19859
|
+
segEls.push(`<polygon points="${X1},${Y1} ${r2(vx - Math.sin(ang) * Wd)},${r2(vy + Math.cos(ang) * Wd)} ${r2(vx + Math.sin(ang) * Wd)},${r2(vy - Math.cos(ang) * Wd)}" fill="${stroke}"/>`);
|
|
19860
|
+
marks += 2;
|
|
19861
|
+
} else {
|
|
19862
|
+
segEls.push(`<line x1="${X0}" y1="${Y0}" x2="${X1}" y2="${Y1}" stroke="${stroke}" stroke-width="${n(s.width, 1.5)}"${dash}/>`);
|
|
19863
|
+
marks += 1;
|
|
19864
|
+
}
|
|
19865
|
+
}
|
|
19866
|
+
for (const t of texts) {
|
|
19867
|
+
if (outside(n(t.x, 0), n(t.y, 0))) {
|
|
19868
|
+
errors.push(`文字「${t.text}」在绘图窗口外:(x ${r2(n(t.x, 0))}, y ${r2(n(t.y, 0))}) / ${windowText} ⇒ 该图元已跳过`);
|
|
19869
|
+
continue;
|
|
19870
|
+
}
|
|
19871
|
+
const anchor = t.anchor === "left" || t.anchor === "start" ? "start" : t.anchor === "right" || t.anchor === "end" ? "end" : "middle";
|
|
19872
|
+
textEls.push(`<text x="${px(n(t.x, 0))}" y="${py(n(t.y, 0))}" font-size="${n(t.size, DEF_SIZE)}" fill="${esc(col(t.color, "#4a4a4a"))}" text-anchor="${anchor}" dominant-baseline="middle">${esc(t.text)}</text>`);
|
|
19873
|
+
marks += 1;
|
|
19874
|
+
}
|
|
19875
|
+
if (m.legend) {
|
|
19876
|
+
const size = n(m.legend.size, DEF_SIZE);
|
|
19877
|
+
const LX = px(n(m.legend.x, 0));
|
|
19878
|
+
const LY = py(n(m.legend.y, 0));
|
|
19879
|
+
const entries = Object.entries(colors);
|
|
19880
|
+
for (let i = 0; i < entries.length; i++) {
|
|
19881
|
+
const row = LY + i * (size + 6);
|
|
19882
|
+
legendEls.push(`<rect x="${LX}" y="${r2(row - 8)}" width="12" height="12" rx="2" fill="${esc(entries[i][1])}"/>`);
|
|
19883
|
+
legendEls.push(`<text x="${r2(LX + 18)}" y="${r2(row)}" font-size="${size}" fill="#333" dominant-baseline="middle">${esc(entries[i][0])}</text>`);
|
|
19884
|
+
marks += 2;
|
|
19885
|
+
}
|
|
19886
|
+
}
|
|
19887
|
+
lint.push(...l3, ...l4);
|
|
19888
|
+
return {
|
|
19889
|
+
svg: [
|
|
19890
|
+
...headEls,
|
|
19891
|
+
...rectEls,
|
|
19892
|
+
...segEls,
|
|
19893
|
+
...textEls,
|
|
19894
|
+
...legendEls,
|
|
19895
|
+
"</svg>"
|
|
19896
|
+
].join("\n") + "\n",
|
|
19897
|
+
lint,
|
|
19898
|
+
errors,
|
|
19899
|
+
marks
|
|
19900
|
+
};
|
|
19901
|
+
}
|
|
19902
|
+
//#endregion
|
|
19903
|
+
//#region src/diagram/rasterize.ts
|
|
19904
|
+
/**
|
|
19905
|
+
* diagram 光栅化 —— SVG → PNG(**纯进程内,不起浏览器、不截图**)
|
|
19906
|
+
*
|
|
19907
|
+
* 为什么是 `@resvg/resvg-wasm`(owner 2026-09-29 裁「内置」,判据 = 可行性调研 §7 的 docker 实测):
|
|
19908
|
+
* ① **纯 wasm**:无原生二进制、无平台/架构约束、无 postinstall 构建(公开包最怕那三样);
|
|
19909
|
+
* ② 实测渲染一张图尺寸画布 ≈60 ms / ~55 KB PNG。
|
|
19910
|
+
*
|
|
19911
|
+
* 🔴 **必须喂字体,且这是实测结论不是设计偏好**:docker 里给容器装好中文字体(`fc-list` 认它)
|
|
19912
|
+
* 后,resvg-wasm 渲染中文**仍得到空白画布**,且 `loadSystemFonts:true` 与 `false` **逐字节相同**
|
|
19913
|
+
* ⇒ **wasm 对系统字体完全不可见**。只有 `fontBuffers` 才画得出字。
|
|
19914
|
+
* ⇒ 顺带白拿一个好处:**输出与机器无关**(同一份内置字体 ⇒ 逐字节可复现,S↑ 稳定)。
|
|
19915
|
+
*
|
|
19916
|
+
* 字体资产随包分发(三个,各有各的覆盖,见 `fonts.ts` 的表):汉字 3.8 MB(Apache-2.0)/
|
|
19917
|
+
* 拉丁 0.5 MB(OFL-1.1)/符号 0.75 MB(DejaVu 许可证),各自带许可证与出处。
|
|
19918
|
+
*/
|
|
19919
|
+
/**
|
|
19920
|
+
* 包根 = **向上找到含 `package.json` 的那一级**。
|
|
19921
|
+
*
|
|
19922
|
+
* 🔴 为什么不能照抄 `voyage-page.ts` 的「`..` 一次」:那个文件在 `src/` 根下,一次 `..` 恰好是包根;
|
|
19923
|
+
* 本模块在 `src/diagram/` 里 ⇒ 开发态(vitest 直接跑 TS 源码)一次 `..` 只到 `src/`,
|
|
19924
|
+
* 于是去找 `src/assets/…` —— **实测 ENOENT**(测试全红,且现象是"字体不在",与真因"路径层级算错"
|
|
19925
|
+
* 长得完全不同)。按 `package.json` 上溯对**开发态(src/diagram)与发布态(lib/diagram)都成立**。
|
|
19926
|
+
* @returns 包根绝对路径;四层内找不到 `package.json` 时回落"上一级"(不抛——定位失败由调用方响亮报错)
|
|
19927
|
+
*/
|
|
19928
|
+
function packageRootPath() {
|
|
19929
|
+
const start = dirname(fileURLToPath(import.meta.url));
|
|
19930
|
+
let dir = start;
|
|
19931
|
+
for (let i = 0; i < 4; i++) {
|
|
19932
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
19933
|
+
dir = dirname(dir);
|
|
19934
|
+
}
|
|
19935
|
+
return join(start, "..");
|
|
19936
|
+
}
|
|
19937
|
+
/** 汉字字型绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
|
|
19938
|
+
function diagramFontPath() {
|
|
19939
|
+
return join(packageRootPath(), DIAGRAM_CJK_FONT_ASSET);
|
|
19940
|
+
}
|
|
19941
|
+
/** 拉丁字型绝对路径 */
|
|
19942
|
+
function diagramLatinFontPath() {
|
|
19943
|
+
return join(packageRootPath(), DIAGRAM_LATIN_FONT_ASSET);
|
|
19944
|
+
}
|
|
19945
|
+
/** 符号字型绝对路径 */
|
|
19946
|
+
function diagramSymbolFontPath() {
|
|
19947
|
+
return join(packageRootPath(), DIAGRAM_SYMBOL_FONT_ASSET);
|
|
19948
|
+
}
|
|
19949
|
+
/**
|
|
19950
|
+
* 兜底字型的族名 = **汉字字型**(本工具的图以中文为主)。
|
|
19951
|
+
* 两个内置字型的家族名与 CSS 家族栈统一定义在 `fonts.ts`(单一真相源),此处只透出兜底那一个。
|
|
19952
|
+
*/
|
|
19953
|
+
const DIAGRAM_FONT_FAMILY = DIAGRAM_CJK_FONT_FAMILY;
|
|
19954
|
+
let wasmReady = null;
|
|
19955
|
+
let fontBuf = null;
|
|
19956
|
+
let latinBuf = null;
|
|
19957
|
+
let symbolBuf = null;
|
|
19958
|
+
/**
|
|
19959
|
+
* wasm 模块路径:**先解析 JS 入口再取同目录的 `.wasm`**。
|
|
19960
|
+
* 不直接 resolve `.wasm` 的原因:包的 `exports` 表未必暴露该子路径(解析失败会抛,
|
|
19961
|
+
* 而"抛"与"文件不在"在验收上长得一样 —— 故走这条更稳的路)。
|
|
19962
|
+
*/
|
|
19963
|
+
function wasmPath() {
|
|
19964
|
+
const req = createRequire(import.meta.url);
|
|
19965
|
+
return join(dirname(req.resolve("@resvg/resvg-wasm")), "index_bg.wasm");
|
|
19966
|
+
}
|
|
19967
|
+
function ensureReady() {
|
|
19968
|
+
if (wasmReady === null) wasmReady = initWasm(readFileSync(wasmPath())).catch((err) => {
|
|
19969
|
+
if (/already initialized/iu.test(String(err?.message ?? err))) return;
|
|
19970
|
+
throw err;
|
|
19971
|
+
});
|
|
19972
|
+
return wasmReady;
|
|
19973
|
+
}
|
|
19974
|
+
/** 三个字型各只读一次(3.8 MB + 0.5 MB + 0.75 MB,进程内缓存) */
|
|
19975
|
+
function fontBuffer() {
|
|
19976
|
+
if (fontBuf === null) fontBuf = readFileSync(diagramFontPath());
|
|
19977
|
+
return fontBuf;
|
|
19978
|
+
}
|
|
19979
|
+
function latinFontBuffer() {
|
|
19980
|
+
if (latinBuf === null) latinBuf = readFileSync(diagramLatinFontPath());
|
|
19981
|
+
return latinBuf;
|
|
19982
|
+
}
|
|
19983
|
+
function symbolFontBuffer() {
|
|
19984
|
+
if (symbolBuf === null) symbolBuf = readFileSync(diagramSymbolFontPath());
|
|
19985
|
+
return symbolBuf;
|
|
19986
|
+
}
|
|
19987
|
+
/**
|
|
19988
|
+
* SVG → PNG。
|
|
19989
|
+
* @param width 输出像素宽(矢量无损放大;高度按 SVG 自身比例推出)
|
|
19990
|
+
*/
|
|
19991
|
+
async function rasterizeSvg(svg, width) {
|
|
19992
|
+
await ensureReady();
|
|
19993
|
+
const t0 = Date.now();
|
|
19994
|
+
const img = new Resvg(svg, {
|
|
19995
|
+
fitTo: {
|
|
19996
|
+
mode: "width",
|
|
19997
|
+
value: width
|
|
19998
|
+
},
|
|
19999
|
+
font: {
|
|
20000
|
+
fontBuffers: [
|
|
20001
|
+
latinFontBuffer(),
|
|
20002
|
+
symbolFontBuffer(),
|
|
20003
|
+
fontBuffer()
|
|
20004
|
+
],
|
|
20005
|
+
loadSystemFonts: false,
|
|
20006
|
+
defaultFontFamily: DIAGRAM_FONT_FAMILY
|
|
20007
|
+
}
|
|
20008
|
+
}).render();
|
|
20009
|
+
return {
|
|
20010
|
+
png: img.asPng(),
|
|
20011
|
+
width: img.width,
|
|
20012
|
+
height: img.height,
|
|
20013
|
+
ms: Date.now() - t0
|
|
20014
|
+
};
|
|
20015
|
+
}
|
|
20016
|
+
//#endregion
|
|
20017
|
+
//#region src/tools/diagram.ts
|
|
20018
|
+
/**
|
|
20019
|
+
* diagram.ts — `diagram` 工具:**agent 直接给一段标签格式源码,自动出图**
|
|
20020
|
+
*
|
|
20021
|
+
* owner 2026-09-29 具名令(D104):「做成一个 tool,**ACC 级别**的,配合一个开关(demo 功能嘛,
|
|
20022
|
+
* 默认关,开了才可用);Agent 调用这个 tool **直接给代码**就行,然后 dsh 可以**自动渲染成图片**
|
|
20023
|
+
* (注意**不借助浏览器生成截图**)」。权威设计 = 插件仓 `docs/diagram-tool-feasibility.md`。
|
|
20024
|
+
*
|
|
20025
|
+
* 链路(全部进程内,**不起浏览器**):标签源码 → `parseDsl` → `renderDsl`(SVG + 四项体检)
|
|
20026
|
+
* → `rasterizeSvg`(resvg-wasm + **内置字型**)→ PNG → **图片块进对话**。
|
|
20027
|
+
*
|
|
20028
|
+
* 🔴 **不落文件**(owner 2026-09-29 明确):「这个 diagram 生成文件干啥,没有任何意义啊,
|
|
20029
|
+
* 直接生成给用户看就好了」⇒ 正常路径**一份文件都不写**(图走附件的 durable 引用直进对话记录,
|
|
20030
|
+
* 可回看、可被后续轮次引用、可转微信 —— 这才是"给人看"的落点)。
|
|
20031
|
+
* ⚠️ **唯一的例外**:图**贴不进对话**时(附件服务不在/部署不收 png/当前路由不声明图像输入/
|
|
20032
|
+
* 存字节失败),才把 PNG 落到 `<CCC>/_tmp/diagram/` 并**在回执里给出路径与原因** ——
|
|
20033
|
+
* 否则那一次就真的什么都没有了("降级不吞回执"同一条纪律)。
|
|
20034
|
+
*
|
|
20035
|
+
* 🔴 **为什么 PNG 是必需的**:宿主的对话内图片通路**白名单只有光栅格式**
|
|
20036
|
+
* (`image/png|jpeg|webp|gif`,见 `dsh-attachment/types.d.ts`)⇒ **SVG 进不去**。
|
|
20037
|
+
*
|
|
20038
|
+
* 🔴 **开关**:由 `index.ts` 门控(**关时不注册** ⇒ 工具根本不进模型工具清单),同 skiff/acp 口径。
|
|
20039
|
+
*
|
|
20040
|
+
* ## 🔴 两条**宿主契约**(2026-09-29 现场取证;第 ① 条是本文件第一版写错的地方)
|
|
20041
|
+
*
|
|
20042
|
+
* ① **`output.render` 是「同步投影」,宿主不 await 它**:`@deepseek-ai/dsh-tools` 的
|
|
20043
|
+
* `createSuccessResult()` 直接 `rendered = tool.output.render(args, value)`,紧接着
|
|
20044
|
+
* `snapshotJsonValue(rendered)` —— 返回一个 Promise 会被判「**非 lossless JSON**」⇒
|
|
20045
|
+
* **整条工具调用失败**(图片与文字回执**一起**丢掉)。
|
|
20046
|
+
* ② **图片块带的 `attachment` 必须是附件服务的 durable 引用**(`ImageAttachmentRef`),
|
|
20047
|
+
* 而存字节是 async ⇒ **只能分两段**:`execute` 里 `saveImage`(async,把引用拼进**纯 JSON** 的返回值),
|
|
20048
|
+
* `render` 里**同步**装配 `{ type: 'image', attachment: … }`。
|
|
20049
|
+
* 这正是平台自家 `read_image` 的形态(本机 live 安装 `dsh-tool-fs` 取证),本文件照该形态实现。
|
|
20050
|
+
*
|
|
20051
|
+
* ⚠️ 因此 render 里那个 `as unknown as ContentBlock` 是**必要的**(本包不引 `@deepseek-ai/dsh-attachment`,
|
|
20052
|
+
* 它不是 peerDep ⇒ 拿不到 `AttachmentId` 的 brand),而它**同时让类型检查对字段名变瞎**(判据 ㉜)⇒
|
|
20053
|
+
* 字段名由 `tests/diagram-tool.test.ts` 逐字钉住。
|
|
20054
|
+
*/
|
|
20055
|
+
/** 输出宽度缺省(矢量无损放大;高度按图自身比例推出) */
|
|
20056
|
+
const DEFAULT_WIDTH = 1200;
|
|
20057
|
+
/** 时间戳:ISO → `YYYYMMDD-HHMMSS`(仅降级落盘的文件名用) */
|
|
20058
|
+
function stamp() {
|
|
20059
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
20060
|
+
}
|
|
20061
|
+
/**
|
|
20062
|
+
* 当前模型路由是否**明确声明**收图像输入(正判据 —— 与 `read_image` 同一道门)。
|
|
20063
|
+
*
|
|
20064
|
+
* 🔴 为什么这道门必须过(而不是"先附上试试"):图片块进的是**工具结果内容**,
|
|
20065
|
+
* 若路由不收图,最坏情形是**整轮请求**被装配层拒掉 —— 比"没图"坏得多。
|
|
20066
|
+
* ⚠️ 与 `read_image` 唯一的差别:**这里不抛错**(改走"降级落盘 + 如实说明")。
|
|
20067
|
+
*/
|
|
20068
|
+
async function routeDeclaresImage(ctx, exec) {
|
|
20069
|
+
const agent = exec.agent;
|
|
20070
|
+
const routed = agent?.session?.requestHeader?.()?.config;
|
|
20071
|
+
const provider = routed?.provider ?? agent?.options?.provider;
|
|
20072
|
+
const model = routed?.model ?? agent?.options?.model;
|
|
20073
|
+
const llm = ctx.get?.("llm");
|
|
20074
|
+
if (provider === void 0 || model === void 0 || llm?.resolveModelInfo === void 0) return false;
|
|
20075
|
+
const signal = exec.signal;
|
|
20076
|
+
const modes = (await llm.resolveModelInfo(provider, model, signal))?.inputModalities;
|
|
20077
|
+
return Array.isArray(modes) && modes.includes("image");
|
|
20078
|
+
}
|
|
20079
|
+
/** 降级落盘(**只在"贴不进对话"时走**):PNG 落 `<CCC>/_tmp/diagram/`,返回相对路径或 `null` */
|
|
20080
|
+
function saveFallbackPng(exec, png, base) {
|
|
20081
|
+
const root = cccRootForExec(exec);
|
|
20082
|
+
if (root === null) return null;
|
|
20083
|
+
try {
|
|
20084
|
+
const rel = join("_tmp", "diagram");
|
|
20085
|
+
const dir = join(root, rel);
|
|
20086
|
+
mkdirSync(dir, { recursive: true });
|
|
20087
|
+
writeFileSync(join(dir, `${base}.png`), png);
|
|
20088
|
+
return join(rel, `${base}.png`);
|
|
20089
|
+
} catch {
|
|
20090
|
+
return null;
|
|
20091
|
+
}
|
|
20092
|
+
}
|
|
20093
|
+
/**
|
|
20094
|
+
* 把 PNG 落成**可进对话的图片块**所需的 durable 引用(async 段 —— 见文件头契约 ①)。
|
|
20095
|
+
*
|
|
20096
|
+
* 四条降级**都不抛错**(图的价值不允许"附不上图"把整条回执一起带走),且都**如实带回原因**;
|
|
20097
|
+
* 降级时顺手把 PNG 落盘一次(见文件头"唯一的例外"):
|
|
20098
|
+
* ① 附件服务不在(未挂载) ② 部署不收 `image/png` ③ 当前路由未声明图像输入 ④ `saveImage` 自身报错。
|
|
20099
|
+
*/
|
|
20100
|
+
async function resolveImage(ctx, exec, png, base) {
|
|
20101
|
+
const skip = (reason) => ({
|
|
20102
|
+
image: null,
|
|
20103
|
+
imageSkip: reason,
|
|
20104
|
+
fallbackRel: saveFallbackPng(exec, png, base)
|
|
20105
|
+
});
|
|
20106
|
+
const attachments = ctx.get?.("attachments");
|
|
20107
|
+
if (attachments === void 0 || typeof attachments.saveImage !== "function") return skip("附件服务未挂载(ctx.attachments 不在)");
|
|
20108
|
+
if (!attachments.imageLimits.mediaTypes.includes("image/png")) return skip("本部署不收 image/png");
|
|
20109
|
+
try {
|
|
20110
|
+
if (!await routeDeclaresImage(ctx, exec)) return skip("当前模型路由未声明图像输入(同 read_image 的门)");
|
|
20111
|
+
} catch (err) {
|
|
20112
|
+
return skip(`模型路由未能解析:${String(err?.message ?? err)}`);
|
|
20113
|
+
}
|
|
20114
|
+
try {
|
|
20115
|
+
const ref = await attachments.saveImage({
|
|
20116
|
+
data: png,
|
|
20117
|
+
mediaType: "image/png",
|
|
20118
|
+
name: `${base}.png`
|
|
20119
|
+
});
|
|
20120
|
+
return {
|
|
20121
|
+
image: {
|
|
20122
|
+
attachmentId: ref.attachmentId,
|
|
20123
|
+
mediaType: ref.mediaType,
|
|
20124
|
+
bytes: ref.bytes,
|
|
20125
|
+
width: ref.width,
|
|
20126
|
+
height: ref.height,
|
|
20127
|
+
name: ref.name ?? `${base}.png`
|
|
20128
|
+
},
|
|
20129
|
+
imageSkip: null,
|
|
20130
|
+
fallbackRel: null
|
|
20131
|
+
};
|
|
20132
|
+
} catch (err) {
|
|
20133
|
+
return skip(`附件服务拒收:${String(err?.message ?? err)}`);
|
|
20134
|
+
}
|
|
20135
|
+
}
|
|
20136
|
+
function createDiagramTool(ctx) {
|
|
20137
|
+
return defineTool({
|
|
20138
|
+
name: "diagram",
|
|
20139
|
+
description: "Draw a diagram and show it to the user IN THE CONVERSATION (in-process, no browser, no files written). Pass the source directly as tag format. Geometry: coordinates are canvas pixels with y growing DOWNWARD; the drawing window defaults to the canvas (0,0 to width,height) — only pass domain=\"x0,y0,x1,y1\" if you want your own window. Tags: <rect id=\"A\" box=\"x0,y0,x1,y1\" label=\"...\" fill=\"#hex|colorName\"/> (name= works as an alias of id), <arrow from=\"A.e\" to=\"B.w\"/> or <line>, endpoints use anchors n/s/e/w/ne/nw/se/sw/c and may be offset like A.n+3,0, or a raw \"x,y\" pair; <text x=\"..\" y=\"..\" align=\"left|center|right\">text</text>, <color name=\"x\" value=\"#hex\"/>, <legend x=\"..\" y=\"..\"/>, <title>/<subtitle>. Returns the image itself plus any authoring errors and four layout warnings (overlapping boxes / label overflow / dangling endpoints / a line crossing a label). Elements whose coordinates fall outside the drawing window are reported as errors and skipped; the rest still render. Example:\n<diagram width=\"1000\" height=\"600\" title=\"三层\">\n <rect id=\"a\" box=\"100,60,400,140\" label=\"接入层\" fill=\"#dbe7f6\"/>\n <rect id=\"b\" box=\"100,200,400,280\" label=\"服务层\" fill=\"#dbe7f6\"/>\n <arrow from=\"a.s\" to=\"b.n\"/>\n</diagram>",
|
|
20140
|
+
parameters: {
|
|
20141
|
+
source: {
|
|
20142
|
+
type: "string",
|
|
20143
|
+
required: true,
|
|
20144
|
+
description: "Tag-format diagram source"
|
|
20145
|
+
},
|
|
20146
|
+
width: {
|
|
20147
|
+
type: "integer",
|
|
20148
|
+
description: `Output pixel width (default ${DEFAULT_WIDTH}; vector, so scaling is lossless)`
|
|
20149
|
+
},
|
|
20150
|
+
name: {
|
|
20151
|
+
type: "string",
|
|
20152
|
+
description: "Optional short name (attachment label; also used if a degraded fallback file is written)"
|
|
20153
|
+
}
|
|
20154
|
+
},
|
|
20155
|
+
output: {
|
|
20156
|
+
schema: { type: "json" },
|
|
20157
|
+
render: (_args, value) => {
|
|
20158
|
+
const v = value;
|
|
20159
|
+
const lines = [`图已生成(${v.marks} 个图元 / ${v.pxW}×${v.pxH} px / ${v.bytes} B PNG / 光栅化 ${v.ms} ms)`];
|
|
20160
|
+
if (v.imageSkip !== null) {
|
|
20161
|
+
lines.push(`⚠️ 未能把图贴进对话:${v.imageSkip}`);
|
|
20162
|
+
lines.push(v.fallbackRel === null ? "(本会话解析不到 CCC 根,连降级落盘也没做 ⇒ 这一次没有可看的东西)" : `已降级落盘:${v.fallbackRel}(可直接打开,或用 read_image 读它)`);
|
|
20163
|
+
}
|
|
20164
|
+
if (v.errors.length > 0) {
|
|
20165
|
+
lines.push(`✗ 源码错误 ${v.errors.length} 条(图已尽力渲染;下列图元已跳过)`);
|
|
20166
|
+
for (const e of v.errors) lines.push(` [err] ${e}`);
|
|
20167
|
+
}
|
|
20168
|
+
if (v.lint.length === 0) lines.push("[lint] 体检干净(0 告警)");
|
|
20169
|
+
else for (const w of v.lint) lines.push(` [lint] ${w}`);
|
|
20170
|
+
const blocks = [{
|
|
20171
|
+
type: "text",
|
|
20172
|
+
text: lines.join("\n")
|
|
20173
|
+
}];
|
|
20174
|
+
if (v.image !== null) blocks.push({
|
|
20175
|
+
type: "image",
|
|
20176
|
+
attachment: { ...v.image }
|
|
20177
|
+
});
|
|
20178
|
+
return blocks;
|
|
20179
|
+
}
|
|
20180
|
+
},
|
|
20181
|
+
async execute(args, exec) {
|
|
20182
|
+
const src = typeof args.source === "string" ? args.source : "";
|
|
20183
|
+
const name = typeof args.name === "string" && args.name.trim() !== "" ? args.name.trim() : "diagram";
|
|
20184
|
+
const width = typeof args.width === "number" && Number.isFinite(args.width) && args.width > 0 ? Math.round(args.width) : DEFAULT_WIDTH;
|
|
20185
|
+
const parsed = parseDsl(src);
|
|
20186
|
+
const rendered = renderDsl(parsed.model);
|
|
20187
|
+
const { png, ms, width: pxW, height: pxH } = await rasterizeSvg(rendered.svg, width);
|
|
20188
|
+
const base = `${stamp()}-${name.replace(/[^\w\u4e00-\u9fa5-]/g, "_")}`;
|
|
20189
|
+
const { image, imageSkip, fallbackRel } = await resolveImage(ctx, exec, png, base);
|
|
20190
|
+
return {
|
|
20191
|
+
name: base,
|
|
20192
|
+
lint: rendered.lint,
|
|
20193
|
+
errors: [...parsed.errors, ...rendered.errors],
|
|
20194
|
+
marks: rendered.marks,
|
|
20195
|
+
ms,
|
|
20196
|
+
bytes: png.length,
|
|
20197
|
+
pxW,
|
|
20198
|
+
pxH,
|
|
20199
|
+
image,
|
|
20200
|
+
imageSkip,
|
|
20201
|
+
fallbackRel
|
|
20202
|
+
};
|
|
20203
|
+
}
|
|
20204
|
+
});
|
|
20205
|
+
}
|
|
20206
|
+
//#endregion
|
|
19273
20207
|
//#region src/host/storage-domain.ts
|
|
19274
20208
|
/**
|
|
19275
20209
|
* host/storage-domain.ts — 宿主存储域上的「会话 ↔ 轨迹」绑定表(§0L,S142 2026-09-19)
|
|
@@ -19482,6 +20416,7 @@ const Config = z.object({
|
|
|
19482
20416
|
webFetch: z.object({ enabled: z.boolean().default(true) }),
|
|
19483
20417
|
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
|
|
19484
20418
|
visionPatch: z.object({ enabled: z.boolean().default(true) }),
|
|
20419
|
+
diagram: z.object({ enabled: z.boolean().default(false) }),
|
|
19485
20420
|
humanChannel: z.object({
|
|
19486
20421
|
accounts: z.array(humanChannelAccountSchema).default([]),
|
|
19487
20422
|
subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
|
|
@@ -19496,7 +20431,8 @@ const Config = z.object({
|
|
|
19496
20431
|
acpHttpPort: z.number().min(1024).max(65535).volatile(),
|
|
19497
20432
|
publicAskEnabled: z.boolean().volatile(),
|
|
19498
20433
|
croEnabled: z.boolean().volatile(),
|
|
19499
|
-
unattendedEnabled: z.boolean().volatile()
|
|
20434
|
+
unattendedEnabled: z.boolean().volatile(),
|
|
20435
|
+
diagramEnabled: z.boolean().volatile()
|
|
19500
20436
|
});
|
|
19501
20437
|
function apply(ctx, config) {
|
|
19502
20438
|
try {
|
|
@@ -19545,6 +20481,7 @@ function apply(ctx, config) {
|
|
|
19545
20481
|
registerVoyageApi(ctx);
|
|
19546
20482
|
}
|
|
19547
20483
|
registerSettingsSection(ctx, config);
|
|
20484
|
+
if (config.tools) registerDiagramTool(ctx);
|
|
19548
20485
|
registerGateway(ctx);
|
|
19549
20486
|
registerRebuildTurnHook(ctx);
|
|
19550
20487
|
registerOutputGuardHook(ctx);
|
|
@@ -19565,6 +20502,57 @@ function apply(ctx, config) {
|
|
|
19565
20502
|
if (config.visionPatch?.enabled !== false) registerDeepseekVisionPatch(ctx, true);
|
|
19566
20503
|
}
|
|
19567
20504
|
/**
|
|
20505
|
+
* diagram 工具装配(**D104**,2026-09-29 owner 具名令):**默认关**的实验性工具 ⇒ 关时**不注册**。
|
|
20506
|
+
*
|
|
20507
|
+
* 归属与边界(E↑):**机制归 ACC**(工具面 + 开关 + 光栅化),**图的语义归调用方**
|
|
20508
|
+
* (标签语法与四项体检在 `src/diagram/`,权威设计 = 插件仓 `docs/diagram-tool-feasibility.md`)。
|
|
20509
|
+
*
|
|
20510
|
+
* 🔴 **v1.51.1 修的真缺陷(owner 实测:「我把开关开了,工具没出来」)**:
|
|
20511
|
+
* 面板拨开关**只改配置、不重跑 `apply`**(铁证 = 插件重启才刷新"武装于";`cordis.patch.yml` 里
|
|
20512
|
+
* `diagramEnabled: true` 已落盘而工具没注册)⇒ 旧实现"apply 时判一次"等于**开关要重启才生效**。
|
|
20513
|
+
* ⇒ 现在改成**反应式**:
|
|
20514
|
+
* ① 听宿主的**真事件** `settings/document-updated`(ns = 我们那张设置页)——顺带说明为什么不用
|
|
20515
|
+
* 我们自己的 `serenity/settings-changed`:那个事件**全仓没有任何 `emit` 点**(只有 `ctx.on` 侧
|
|
20516
|
+
* 在用)=**死通道**,照着它接线等于接了个永远不响的铃;
|
|
20517
|
+
* ② 兜底再挂 `agent/created` / `session/created`(与 skiff/acp 同款做法,那两条在实测里确实会 fire)
|
|
20518
|
+
* —— 宿主事件面若换名,至少还有这一条能自愈;
|
|
20519
|
+
* ③ 启动时同步一次(apply 时按当前配置定初始态)。
|
|
20520
|
+
* 🔴 **关的时候要注销**(不是"留着但不响应"):与"关时不注册"同一口径 —— 工具不出现在模型清单里。
|
|
20521
|
+
* @param ctx - 插件上下文
|
|
20522
|
+
*/
|
|
20523
|
+
function registerDiagramTool(ctx) {
|
|
20524
|
+
/** 当前注册的 disposer(`null` = 未注册)。宿主 `ctx.tools.register` **返回注销器**。 */
|
|
20525
|
+
let dispose = null;
|
|
20526
|
+
const sync = () => {
|
|
20527
|
+
const on = readSimpleSettings().diagramEnabled;
|
|
20528
|
+
if (on && dispose === null) {
|
|
20529
|
+
try {
|
|
20530
|
+
dispose = ctx.tools.register(createDiagramTool(ctx));
|
|
20531
|
+
} catch (err) {
|
|
20532
|
+
console.error(`[serenity-hooks] ✗ diagram 工具注册失败: ${String(err?.message ?? err)}`);
|
|
20533
|
+
}
|
|
20534
|
+
return;
|
|
20535
|
+
}
|
|
20536
|
+
if (!on && dispose !== null) {
|
|
20537
|
+
try {
|
|
20538
|
+
dispose();
|
|
20539
|
+
} catch {}
|
|
20540
|
+
dispose = null;
|
|
20541
|
+
}
|
|
20542
|
+
};
|
|
20543
|
+
try {
|
|
20544
|
+
ctx.on("settings/document-updated", (ns) => {
|
|
20545
|
+
if (String(ns) === "serenity-hooks") sync();
|
|
20546
|
+
});
|
|
20547
|
+
} catch {}
|
|
20548
|
+
for (const eventName of ["agent/created", "session/created"]) try {
|
|
20549
|
+
ctx.on(eventName, () => {
|
|
20550
|
+
sync();
|
|
20551
|
+
});
|
|
20552
|
+
} catch {}
|
|
20553
|
+
sync();
|
|
20554
|
+
}
|
|
20555
|
+
/**
|
|
19568
20556
|
* F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
|
|
19569
20557
|
* settings-changed 事件触发同步(skiffEnabled 开 → 启动调试服务;关 → 停止)。
|
|
19570
20558
|
* 角色配置(skiff.roles)从当前 CCC 根读取(含 skiff.roles 的 CCC 优先,进程 cwd 次之)。
|