@shgroup/dsh-serenity-hooks 1.50.1 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/assets/diagram/DroidSansFallbackFull.ttf +0 -0
- package/assets/diagram/LICENSE-Apache-2.0.txt +202 -0
- package/assets/diagram/PROVENANCE.md +30 -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/rasterize.d.ts +28 -0
- package/lib/handyman-ops.d.ts +1 -1
- package/lib/index.d.ts +21 -0
- package/lib/index.js +904 -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-cq0eUSd-.js} +3 -1
- 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 +8 -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 FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-
|
|
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 FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-cq0eUSd-.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-cq0eUSd-.js").then((n) => n.r);
|
|
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,843 @@ 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
|
+
const model = {
|
|
19334
|
+
W: 1e3,
|
|
19335
|
+
H: 780,
|
|
19336
|
+
x0: 0,
|
|
19337
|
+
y0: -19,
|
|
19338
|
+
x1: 100,
|
|
19339
|
+
y1: 72,
|
|
19340
|
+
pad: 10,
|
|
19341
|
+
title: "",
|
|
19342
|
+
subtitle: "",
|
|
19343
|
+
colors: {},
|
|
19344
|
+
rects: [],
|
|
19345
|
+
segments: [],
|
|
19346
|
+
texts: []
|
|
19347
|
+
};
|
|
19348
|
+
const ids = /* @__PURE__ */ new Set();
|
|
19349
|
+
let rectOrdinal = 0;
|
|
19350
|
+
let tagIndex = 0;
|
|
19351
|
+
let i = 0;
|
|
19352
|
+
const fail = (n, ln, msg) => {
|
|
19353
|
+
errors.push(`第 ${n} 个标签(第 ${ln} 行):${msg}`);
|
|
19354
|
+
};
|
|
19355
|
+
while (i < src.length) {
|
|
19356
|
+
const lt = src.indexOf("<", i);
|
|
19357
|
+
if (lt < 0) break;
|
|
19358
|
+
let j = lt + 1;
|
|
19359
|
+
let inQuote = false;
|
|
19360
|
+
while (j < src.length) {
|
|
19361
|
+
const c = src[j];
|
|
19362
|
+
if (c === "\"") inQuote = !inQuote;
|
|
19363
|
+
else if (c === ">" && !inQuote) break;
|
|
19364
|
+
j++;
|
|
19365
|
+
}
|
|
19366
|
+
const ln = lineAt(src, lt);
|
|
19367
|
+
if (j >= src.length) {
|
|
19368
|
+
tagIndex++;
|
|
19369
|
+
fail(tagIndex, ln, "标签未闭合(属性值引号未闭合)");
|
|
19370
|
+
const gt = src.indexOf(">", lt + 1);
|
|
19371
|
+
if (gt < 0) break;
|
|
19372
|
+
i = gt + 1;
|
|
19373
|
+
continue;
|
|
19374
|
+
}
|
|
19375
|
+
const raw = src.slice(lt + 1, j);
|
|
19376
|
+
if (/^\s*\//.test(raw)) {
|
|
19377
|
+
i = j + 1;
|
|
19378
|
+
continue;
|
|
19379
|
+
}
|
|
19380
|
+
tagIndex++;
|
|
19381
|
+
const selfClosing = /\/\s*$/.test(raw);
|
|
19382
|
+
const body = raw.replace(/\/\s*$/, "");
|
|
19383
|
+
const head = /^\s*([A-Za-z_][\w-]*)/.exec(body);
|
|
19384
|
+
const tag = head ? head[1] : void 0;
|
|
19385
|
+
if (tag === void 0) {
|
|
19386
|
+
fail(tagIndex, ln, "标签名缺失或非法");
|
|
19387
|
+
i = j + 1;
|
|
19388
|
+
continue;
|
|
19389
|
+
}
|
|
19390
|
+
const attrs = {};
|
|
19391
|
+
const attrRe = /([A-Za-z_][\w-]*)\s*=\s*"([^"]*)"/g;
|
|
19392
|
+
let a;
|
|
19393
|
+
while ((a = attrRe.exec(body)) !== null) {
|
|
19394
|
+
const [k, val] = [a[1], a[2]];
|
|
19395
|
+
if (k !== void 0 && val !== void 0) attrs[k] = val;
|
|
19396
|
+
}
|
|
19397
|
+
let content = "";
|
|
19398
|
+
let next = j + 1;
|
|
19399
|
+
if (!selfClosing && TEXT_TAGS.indexOf(tag) >= 0) {
|
|
19400
|
+
const close = src.indexOf(`</${tag}`, next);
|
|
19401
|
+
if (close < 0) {
|
|
19402
|
+
fail(tagIndex, ln, `缺少 </${tag}> 闭合标签`);
|
|
19403
|
+
i = next;
|
|
19404
|
+
continue;
|
|
19405
|
+
}
|
|
19406
|
+
content = src.slice(next, close).trim();
|
|
19407
|
+
const gt = src.indexOf(">", close);
|
|
19408
|
+
next = gt < 0 ? src.length : gt + 1;
|
|
19409
|
+
}
|
|
19410
|
+
i = next;
|
|
19411
|
+
if (tag === "diagram") {
|
|
19412
|
+
const w = num(attrs.width);
|
|
19413
|
+
const h = num(attrs.height);
|
|
19414
|
+
if (w !== void 0) model.W = w;
|
|
19415
|
+
if (h !== void 0) model.H = h;
|
|
19416
|
+
if (attrs.domain !== void 0) {
|
|
19417
|
+
const d = quad(attrs.domain);
|
|
19418
|
+
if (d) {
|
|
19419
|
+
model.x0 = d.x0;
|
|
19420
|
+
model.y0 = d.y0;
|
|
19421
|
+
model.x1 = d.x1;
|
|
19422
|
+
model.y1 = d.y1;
|
|
19423
|
+
} else fail(tagIndex, ln, `domain 不是 4 个数字:${attrs.domain}(已忽略)`);
|
|
19424
|
+
}
|
|
19425
|
+
continue;
|
|
19426
|
+
}
|
|
19427
|
+
if (tag === "title" || tag === "subtitle") {
|
|
19428
|
+
if (tag === "title") model.title = content;
|
|
19429
|
+
else model.subtitle = content;
|
|
19430
|
+
continue;
|
|
19431
|
+
}
|
|
19432
|
+
if (tag === "color") {
|
|
19433
|
+
const name = attrs.name;
|
|
19434
|
+
const value = attrs.value;
|
|
19435
|
+
if (!name || value === void 0) {
|
|
19436
|
+
fail(tagIndex, ln, "color 缺 name 或 value");
|
|
19437
|
+
continue;
|
|
19438
|
+
}
|
|
19439
|
+
model.colors[name] = value;
|
|
19440
|
+
continue;
|
|
19441
|
+
}
|
|
19442
|
+
if (tag === "legend") {
|
|
19443
|
+
model.legend = {
|
|
19444
|
+
x: num(attrs.x) ?? 0,
|
|
19445
|
+
y: num(attrs.y) ?? 0,
|
|
19446
|
+
size: num(attrs.size) ?? 12
|
|
19447
|
+
};
|
|
19448
|
+
continue;
|
|
19449
|
+
}
|
|
19450
|
+
if (tag === "rect") {
|
|
19451
|
+
rectOrdinal++;
|
|
19452
|
+
if (attrs.box === void 0) {
|
|
19453
|
+
fail(tagIndex, ln, "rect 缺 box");
|
|
19454
|
+
continue;
|
|
19455
|
+
}
|
|
19456
|
+
const b = quad(attrs.box);
|
|
19457
|
+
if (!b) {
|
|
19458
|
+
fail(tagIndex, ln, `box 不是 4 个数字:${attrs.box}`);
|
|
19459
|
+
continue;
|
|
19460
|
+
}
|
|
19461
|
+
const label = attrs.label;
|
|
19462
|
+
const id = attrs.id || label || `rect${rectOrdinal}`;
|
|
19463
|
+
if (ids.has(id)) {
|
|
19464
|
+
fail(tagIndex, ln, `rect id 重复:${id}`);
|
|
19465
|
+
continue;
|
|
19466
|
+
}
|
|
19467
|
+
ids.add(id);
|
|
19468
|
+
const rect = {
|
|
19469
|
+
id,
|
|
19470
|
+
x0: Math.min(b.x0, b.x1),
|
|
19471
|
+
y0: Math.min(b.y0, b.y1),
|
|
19472
|
+
x1: Math.max(b.x0, b.x1),
|
|
19473
|
+
y1: Math.max(b.y0, b.y1),
|
|
19474
|
+
size: num(attrs.size) ?? 13,
|
|
19475
|
+
fill: attrs.fill ?? ""
|
|
19476
|
+
};
|
|
19477
|
+
if (label !== void 0) rect.label = label;
|
|
19478
|
+
model.rects.push(rect);
|
|
19479
|
+
continue;
|
|
19480
|
+
}
|
|
19481
|
+
if (tag === "line" || tag === "arrow") {
|
|
19482
|
+
const from = attrs.from;
|
|
19483
|
+
const to = attrs.to;
|
|
19484
|
+
if (!from || !to) {
|
|
19485
|
+
fail(tagIndex, ln, `${tag} 缺 from 或 to`);
|
|
19486
|
+
continue;
|
|
19487
|
+
}
|
|
19488
|
+
const seg = {
|
|
19489
|
+
kind: tag,
|
|
19490
|
+
from,
|
|
19491
|
+
to,
|
|
19492
|
+
free: attrs.free === "1",
|
|
19493
|
+
ln
|
|
19494
|
+
};
|
|
19495
|
+
if (attrs.color !== void 0) seg.color = attrs.color;
|
|
19496
|
+
const w = num(attrs.width);
|
|
19497
|
+
if (w !== void 0) seg.width = w;
|
|
19498
|
+
if (attrs.dash !== void 0) seg.dash = attrs.dash;
|
|
19499
|
+
model.segments.push(seg);
|
|
19500
|
+
continue;
|
|
19501
|
+
}
|
|
19502
|
+
if (tag === "text") {
|
|
19503
|
+
const x = num(attrs.x);
|
|
19504
|
+
const y = num(attrs.y);
|
|
19505
|
+
if (x === void 0 || y === void 0) {
|
|
19506
|
+
fail(tagIndex, ln, "text 缺 x 或 y");
|
|
19507
|
+
continue;
|
|
19508
|
+
}
|
|
19509
|
+
const al = attrs.align;
|
|
19510
|
+
const t = {
|
|
19511
|
+
x,
|
|
19512
|
+
y,
|
|
19513
|
+
text: content,
|
|
19514
|
+
align: al === "center" || al === "right" ? al : "left",
|
|
19515
|
+
size: num(attrs.size) ?? 12.5,
|
|
19516
|
+
ln
|
|
19517
|
+
};
|
|
19518
|
+
if (attrs.color !== void 0) t.color = attrs.color;
|
|
19519
|
+
model.texts.push(t);
|
|
19520
|
+
continue;
|
|
19521
|
+
}
|
|
19522
|
+
fail(tagIndex, ln, `未知标签:<${tag}>`);
|
|
19523
|
+
}
|
|
19524
|
+
return {
|
|
19525
|
+
model,
|
|
19526
|
+
errors
|
|
19527
|
+
};
|
|
19528
|
+
}
|
|
19529
|
+
//#endregion
|
|
19530
|
+
//#region src/diagram/dsl-render.ts
|
|
19531
|
+
const TITLE_BAND = 68;
|
|
19532
|
+
const EPS = .35;
|
|
19533
|
+
const DEF_W = 960;
|
|
19534
|
+
const DEF_H = 540;
|
|
19535
|
+
const DEF_PAD = 24;
|
|
19536
|
+
const DEF_SIZE = 14;
|
|
19537
|
+
const DEF_FILL = "#cccccc";
|
|
19538
|
+
const DEF_INK = "#111111";
|
|
19539
|
+
const n = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
19540
|
+
const r2 = (v) => Math.round(v * 100) / 100;
|
|
19541
|
+
const esc = (v) => String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
19542
|
+
const textWidth = (s, size) => {
|
|
19543
|
+
let w = 0;
|
|
19544
|
+
for (const ch of s) w += (ch.codePointAt(0) ?? 0) > 11904 ? size : size * .55;
|
|
19545
|
+
return w;
|
|
19546
|
+
};
|
|
19547
|
+
const lineNo = (s, i) => typeof s.line === "number" && Number.isFinite(s.line) ? s.line : i + 1;
|
|
19548
|
+
const ANCHOR_RE = /^(.+)\.(ne|nw|se|sw|n|s|e|w|c)(?:([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?))?$/;
|
|
19549
|
+
const NUM_PAIR_RE = /^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/;
|
|
19550
|
+
/** 点是否在块内或贴块边(eps = EPS) */
|
|
19551
|
+
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;
|
|
19552
|
+
/** Liang-Barsky:线段与轴对齐盒是否相交 */
|
|
19553
|
+
const segHitsBox = (ax, ay, bx, by, box) => {
|
|
19554
|
+
const p = [
|
|
19555
|
+
ax - bx,
|
|
19556
|
+
bx - ax,
|
|
19557
|
+
ay - by,
|
|
19558
|
+
by - ay
|
|
19559
|
+
];
|
|
19560
|
+
const q = [
|
|
19561
|
+
ax - box[0],
|
|
19562
|
+
box[2] - ax,
|
|
19563
|
+
ay - box[1],
|
|
19564
|
+
box[3] - ay
|
|
19565
|
+
];
|
|
19566
|
+
let t0 = 0;
|
|
19567
|
+
let t1 = 1;
|
|
19568
|
+
for (let i = 0; i < 4; i++) {
|
|
19569
|
+
const pi = p[i] ?? 0;
|
|
19570
|
+
const qi = q[i] ?? 0;
|
|
19571
|
+
if (pi === 0) {
|
|
19572
|
+
if (qi < 0) return false;
|
|
19573
|
+
continue;
|
|
19574
|
+
}
|
|
19575
|
+
const r = qi / pi;
|
|
19576
|
+
if (pi < 0) {
|
|
19577
|
+
if (r > t1) return false;
|
|
19578
|
+
if (r > t0) t0 = r;
|
|
19579
|
+
} else if (r < t0) return false;
|
|
19580
|
+
else if (r < t1) t1 = r;
|
|
19581
|
+
}
|
|
19582
|
+
return true;
|
|
19583
|
+
};
|
|
19584
|
+
/** 把模型渲染成 SVG,并做四项体检。绝不抛异常:出错的图元跳过、记 errors、其余照画。 */
|
|
19585
|
+
/**
|
|
19586
|
+
* 🔴 **适配层(2026-09-29 加,修一处真缺陷)**
|
|
19587
|
+
*
|
|
19588
|
+
* 本文件此前用 `model as unknown as Model` 把 `DslModel` **硬 cast** 成自己的内部形状
|
|
19589
|
+
* ⇒ **编译器对字段名彻底变瞎**。而两侧字段名实际不同:
|
|
19590
|
+
* `W/H` vs `width/height` / `fill` vs `color` / `ln` vs `line` / `align` vs `anchor`
|
|
19591
|
+
* ⇒ 运行期整片取到 `undefined`,**而 `typecheck` 照样绿**(那个绿是假信号)。
|
|
19592
|
+
*
|
|
19593
|
+
* 现在改为**逐字段显式搬运**:类型系统会核对每一个名字 ⇒ 再对不上就**编译不过**。
|
|
19594
|
+
* 🆕 判据:**"typecheck 通过"≠"两侧接口一致"** —— 一个 cast 就足以让类型检查失效;
|
|
19595
|
+
* 这一层在,`typecheck` 才是证据。
|
|
19596
|
+
*/
|
|
19597
|
+
function adapt(model) {
|
|
19598
|
+
return {
|
|
19599
|
+
title: model.title,
|
|
19600
|
+
subtitle: model.subtitle,
|
|
19601
|
+
width: model.W,
|
|
19602
|
+
height: model.H,
|
|
19603
|
+
pad: model.pad,
|
|
19604
|
+
x0: model.x0,
|
|
19605
|
+
x1: model.x1,
|
|
19606
|
+
y0: model.y0,
|
|
19607
|
+
y1: model.y1,
|
|
19608
|
+
colors: model.colors,
|
|
19609
|
+
legend: model.legend,
|
|
19610
|
+
rects: model.rects.map((r) => ({
|
|
19611
|
+
id: r.id,
|
|
19612
|
+
label: r.label,
|
|
19613
|
+
x0: r.x0,
|
|
19614
|
+
y0: r.y0,
|
|
19615
|
+
x1: r.x1,
|
|
19616
|
+
y1: r.y1,
|
|
19617
|
+
color: r.fill,
|
|
19618
|
+
size: r.size
|
|
19619
|
+
})),
|
|
19620
|
+
segs: model.segments.map((s) => ({
|
|
19621
|
+
kind: s.kind,
|
|
19622
|
+
from: s.from,
|
|
19623
|
+
to: s.to,
|
|
19624
|
+
color: s.color,
|
|
19625
|
+
width: s.width,
|
|
19626
|
+
dash: s.dash,
|
|
19627
|
+
free: s.free,
|
|
19628
|
+
line: s.ln
|
|
19629
|
+
})),
|
|
19630
|
+
texts: model.texts.map((t) => ({
|
|
19631
|
+
x: t.x,
|
|
19632
|
+
y: t.y,
|
|
19633
|
+
text: t.text,
|
|
19634
|
+
size: t.size,
|
|
19635
|
+
color: t.color,
|
|
19636
|
+
anchor: t.align
|
|
19637
|
+
}))
|
|
19638
|
+
};
|
|
19639
|
+
}
|
|
19640
|
+
function renderDsl(model) {
|
|
19641
|
+
const m = adapt(model);
|
|
19642
|
+
const lint = [];
|
|
19643
|
+
const errors = [];
|
|
19644
|
+
const rects = m.rects ?? [];
|
|
19645
|
+
const segs = m.segs ?? [];
|
|
19646
|
+
const texts = m.texts ?? [];
|
|
19647
|
+
const colors = m.colors ?? {};
|
|
19648
|
+
const ids = rects.map((r) => r.id);
|
|
19649
|
+
const col = (v, d) => {
|
|
19650
|
+
const s = typeof v === "string" ? v : "";
|
|
19651
|
+
if (s === "") return d;
|
|
19652
|
+
if (s.charAt(0) === "#") return s;
|
|
19653
|
+
return colors[s] ?? s;
|
|
19654
|
+
};
|
|
19655
|
+
const W = n(m.width, DEF_W);
|
|
19656
|
+
const H = n(m.height, DEF_H);
|
|
19657
|
+
const pad = n(m.pad, DEF_PAD);
|
|
19658
|
+
const gx = [];
|
|
19659
|
+
const gy = [];
|
|
19660
|
+
for (const r of rects) gx.push(n(r.x0, 0), n(r.x1, 0));
|
|
19661
|
+
for (const r of rects) gy.push(n(r.y0, 0), n(r.y1, 0));
|
|
19662
|
+
for (const t of texts) gx.push(n(t.x, 0));
|
|
19663
|
+
for (const t of texts) gy.push(n(t.y, 0));
|
|
19664
|
+
const hasBox = gx.length > 0 && gy.length > 0;
|
|
19665
|
+
const x0 = n(m.x0, hasBox ? Math.min(...gx) : 0);
|
|
19666
|
+
const x1 = n(m.x1, hasBox ? Math.max(...gx) : 100);
|
|
19667
|
+
const y0 = n(m.y0, hasBox ? Math.min(...gy) : 0);
|
|
19668
|
+
const y1 = n(m.y1, hasBox ? Math.max(...gy) : 100);
|
|
19669
|
+
const scaleX = (W - 2 * pad) / (x1 - x0 || 1);
|
|
19670
|
+
const scaleY = (H - TITLE_BAND - pad) / (y1 - y0 || 1);
|
|
19671
|
+
const px = (x) => r2(pad + (x - x0) * scaleX);
|
|
19672
|
+
const py = (y) => r2(TITLE_BAND + (y1 - y) * scaleY);
|
|
19673
|
+
const resolve = (token) => {
|
|
19674
|
+
const t = String(token ?? "").trim();
|
|
19675
|
+
const np = NUM_PAIR_RE.exec(t);
|
|
19676
|
+
if (np) return {
|
|
19677
|
+
x: Number(np[1]),
|
|
19678
|
+
y: Number(np[2]),
|
|
19679
|
+
numeric: true
|
|
19680
|
+
};
|
|
19681
|
+
const mt = ANCHOR_RE.exec(t);
|
|
19682
|
+
if (!mt) return null;
|
|
19683
|
+
const ref = rects.find((r) => r.id === mt[1]);
|
|
19684
|
+
if (!ref) return null;
|
|
19685
|
+
const rx0 = n(ref.x0, 0);
|
|
19686
|
+
const rx1 = n(ref.x1, 0);
|
|
19687
|
+
const ry0 = n(ref.y0, 0);
|
|
19688
|
+
const ry1 = n(ref.y1, 0);
|
|
19689
|
+
const cx = (rx0 + rx1) / 2;
|
|
19690
|
+
const cy = (ry0 + ry1) / 2;
|
|
19691
|
+
const base = {
|
|
19692
|
+
n: [cx, ry1],
|
|
19693
|
+
s: [cx, ry0],
|
|
19694
|
+
e: [rx1, cy],
|
|
19695
|
+
w: [rx0, cy],
|
|
19696
|
+
c: [cx, cy],
|
|
19697
|
+
ne: [rx1, ry1],
|
|
19698
|
+
nw: [rx0, ry1],
|
|
19699
|
+
se: [rx1, ry0],
|
|
19700
|
+
sw: [rx0, ry0]
|
|
19701
|
+
}[mt[2] ?? ""];
|
|
19702
|
+
if (!base) return null;
|
|
19703
|
+
const dx = mt[3] === void 0 ? 0 : Number(mt[3]);
|
|
19704
|
+
const dy = mt[4] === void 0 ? 0 : Number(mt[4]);
|
|
19705
|
+
return {
|
|
19706
|
+
x: base[0] + dx,
|
|
19707
|
+
y: base[1] + dy,
|
|
19708
|
+
numeric: false
|
|
19709
|
+
};
|
|
19710
|
+
};
|
|
19711
|
+
for (let i = 0; i < rects.length; i++) for (let j = i + 1; j < rects.length; j++) {
|
|
19712
|
+
const A = rects[i];
|
|
19713
|
+
const B = rects[j];
|
|
19714
|
+
const ix = Math.min(n(A.x1, 0), n(B.x1, 0)) - Math.max(n(A.x0, 0), n(B.x0, 0));
|
|
19715
|
+
const iy = Math.min(n(A.y1, 0), n(B.y1, 0)) - Math.max(n(A.y0, 0), n(B.y0, 0));
|
|
19716
|
+
if (ix > 0 && iy > 0) lint.push(`块重叠:${A.label ?? A.id} × ${B.label ?? B.id} ⇒ 相交 ${r2(ix)}×${r2(iy)}(面积 ${r2(ix * iy)} 平方数据单位)`);
|
|
19717
|
+
}
|
|
19718
|
+
for (const r of rects) {
|
|
19719
|
+
if (!r.label) continue;
|
|
19720
|
+
const need = textWidth(r.label, n(r.size, DEF_SIZE));
|
|
19721
|
+
const boxPx = Math.abs(n(r.x1, 0) - n(r.x0, 0)) * scaleX;
|
|
19722
|
+
if (need > boxPx - 6) lint.push(`标签溢出:${r.label}(需 ≈${r2(need)}px,块宽 ${r2(boxPx)}px)`);
|
|
19723
|
+
}
|
|
19724
|
+
const headEls = [], rectEls = [], segEls = [];
|
|
19725
|
+
const textEls = [], legendEls = [], l3 = [], l4 = [];
|
|
19726
|
+
let marks = 0;
|
|
19727
|
+
headEls.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="-apple-system, 'Noto Sans CJK SC', 'Source Han Sans SC', 'Microsoft YaHei', sans-serif">`);
|
|
19728
|
+
headEls.push(`<rect width="${W}" height="${H}" fill="#fbfaf7"/>`);
|
|
19729
|
+
marks += 2;
|
|
19730
|
+
if (m.title) headEls.push(`<text x="10" y="34" font-size="21" font-weight="bold" fill="#111">${esc(m.title)}</text>`);
|
|
19731
|
+
if (m.title) marks += 1;
|
|
19732
|
+
if (m.subtitle) headEls.push(`<text x="10" y="58" font-size="13" fill="#666">${esc(m.subtitle)}</text>`);
|
|
19733
|
+
if (m.subtitle) marks += 1;
|
|
19734
|
+
for (const r of rects) {
|
|
19735
|
+
const X = px(n(r.x0, 0));
|
|
19736
|
+
const Y = py(n(r.y1, 0));
|
|
19737
|
+
const bw = r2(px(n(r.x1, 0)) - X);
|
|
19738
|
+
const bh = r2(py(n(r.y0, 0)) - Y);
|
|
19739
|
+
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"/>`);
|
|
19740
|
+
marks += 1;
|
|
19741
|
+
if (!r.label) continue;
|
|
19742
|
+
const cx = px((n(r.x0, 0) + n(r.x1, 0)) / 2);
|
|
19743
|
+
const cy = py((n(r.y0, 0) + n(r.y1, 0)) / 2);
|
|
19744
|
+
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>`);
|
|
19745
|
+
marks += 1;
|
|
19746
|
+
}
|
|
19747
|
+
for (let i = 0; i < segs.length; i++) {
|
|
19748
|
+
const s = segs[i];
|
|
19749
|
+
const kind = s.kind === "arrow" ? "arrow" : "line";
|
|
19750
|
+
const a = resolve(s.from);
|
|
19751
|
+
const b = resolve(s.to);
|
|
19752
|
+
if (!a || !b) {
|
|
19753
|
+
const bad = !a ? s.from : s.to;
|
|
19754
|
+
errors.push(`第 ${lineNo(s, i)} 行 ${kind}:锚点引用解析不了(${esc(bad)});已知 id = ${ids.join("、")} ⇒ 该图元已跳过`);
|
|
19755
|
+
continue;
|
|
19756
|
+
}
|
|
19757
|
+
const X0 = px(a.x);
|
|
19758
|
+
const Y0 = py(a.y);
|
|
19759
|
+
const X1 = px(b.x);
|
|
19760
|
+
const Y1 = py(b.y);
|
|
19761
|
+
if (s.free !== true) {
|
|
19762
|
+
if (a.numeric && !rects.some((r) => inBox(a, r))) l3.push(`端点悬空:${kind} 起点 (${r2(a.x)}, ${r2(a.y)}) 不在任何块内、也不贴任何块边`);
|
|
19763
|
+
if (b.numeric && !rects.some((r) => inBox(b, r))) l3.push(`端点悬空:${kind} 终点 (${r2(b.x)}, ${r2(b.y)}) 不在任何块内、也不贴任何块边`);
|
|
19764
|
+
}
|
|
19765
|
+
for (const r of rects) {
|
|
19766
|
+
if (!r.label) continue;
|
|
19767
|
+
const size = n(r.size, DEF_SIZE);
|
|
19768
|
+
const hw = textWidth(r.label, size) / 2 + 1;
|
|
19769
|
+
const hh = size / 2 + 1;
|
|
19770
|
+
const bx = px((n(r.x0, 0) + n(r.x1, 0)) / 2);
|
|
19771
|
+
const by = py((n(r.y0, 0) + n(r.y1, 0)) / 2);
|
|
19772
|
+
if (!segHitsBox(X0, Y0, X1, Y1, [
|
|
19773
|
+
bx - hw,
|
|
19774
|
+
by - hh,
|
|
19775
|
+
bx + hw,
|
|
19776
|
+
by + hh
|
|
19777
|
+
])) continue;
|
|
19778
|
+
l4.push(`线压标签:${kind}(${r2(a.x)},${r2(a.y)})→(${r2(b.x)},${r2(b.y)}) 穿过「${r.label}」⇒ 把该线挪开,或用锚点偏移(如 ${r.id}.n+3,0)`);
|
|
19779
|
+
}
|
|
19780
|
+
const stroke = esc(col(s.color, DEF_INK));
|
|
19781
|
+
const dash = typeof s.dash === "string" && s.dash !== "" ? ` stroke-dasharray="${esc(s.dash)}"` : "";
|
|
19782
|
+
if (kind === "arrow") {
|
|
19783
|
+
const ang = Math.atan2(Y1 - Y0, X1 - X0);
|
|
19784
|
+
const L = 13;
|
|
19785
|
+
const Wd = 6.5;
|
|
19786
|
+
const vx = X1 - Math.cos(ang) * L;
|
|
19787
|
+
const vy = Y1 - Math.sin(ang) * L;
|
|
19788
|
+
segEls.push(`<line x1="${X0}" y1="${Y0}" x2="${r2(vx)}" y2="${r2(vy)}" stroke="${stroke}" stroke-width="${n(s.width, 2.2)}"${dash}/>`);
|
|
19789
|
+
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}"/>`);
|
|
19790
|
+
marks += 2;
|
|
19791
|
+
} else {
|
|
19792
|
+
segEls.push(`<line x1="${X0}" y1="${Y0}" x2="${X1}" y2="${Y1}" stroke="${stroke}" stroke-width="${n(s.width, 1.5)}"${dash}/>`);
|
|
19793
|
+
marks += 1;
|
|
19794
|
+
}
|
|
19795
|
+
}
|
|
19796
|
+
for (const t of texts) {
|
|
19797
|
+
const anchor = t.anchor === "start" || t.anchor === "end" ? t.anchor : "middle";
|
|
19798
|
+
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>`);
|
|
19799
|
+
marks += 1;
|
|
19800
|
+
}
|
|
19801
|
+
if (m.legend) {
|
|
19802
|
+
const size = n(m.legend.size, DEF_SIZE);
|
|
19803
|
+
const LX = r2(n(m.legend.x, 0));
|
|
19804
|
+
const LY = r2(n(m.legend.y, 0));
|
|
19805
|
+
legendEls.push(`<text x="${LX}" y="${LY}" font-size="${size + 1}" font-weight="bold" fill="#333">阵营</text>`);
|
|
19806
|
+
marks += 1;
|
|
19807
|
+
const entries = Object.entries(colors);
|
|
19808
|
+
for (let i = 0; i < entries.length; i++) {
|
|
19809
|
+
const row = LY + 20 + i * (size + 6);
|
|
19810
|
+
legendEls.push(`<rect x="${LX}" y="${r2(row - 8)}" width="12" height="12" rx="2" fill="${esc(entries[i][1])}"/>`);
|
|
19811
|
+
legendEls.push(`<text x="${r2(LX + 18)}" y="${r2(row)}" font-size="${size}" fill="#333" dominant-baseline="middle">${esc(entries[i][0])}</text>`);
|
|
19812
|
+
marks += 2;
|
|
19813
|
+
}
|
|
19814
|
+
}
|
|
19815
|
+
lint.push(...l3, ...l4);
|
|
19816
|
+
return {
|
|
19817
|
+
svg: [
|
|
19818
|
+
...headEls,
|
|
19819
|
+
...rectEls,
|
|
19820
|
+
...segEls,
|
|
19821
|
+
...textEls,
|
|
19822
|
+
...legendEls,
|
|
19823
|
+
"</svg>"
|
|
19824
|
+
].join("\n") + "\n",
|
|
19825
|
+
lint,
|
|
19826
|
+
errors,
|
|
19827
|
+
marks
|
|
19828
|
+
};
|
|
19829
|
+
}
|
|
19830
|
+
//#endregion
|
|
19831
|
+
//#region src/diagram/rasterize.ts
|
|
19832
|
+
/**
|
|
19833
|
+
* diagram 光栅化 —— SVG → PNG(**纯进程内,不起浏览器、不截图**)
|
|
19834
|
+
*
|
|
19835
|
+
* 为什么是 `@resvg/resvg-wasm`(owner 2026-09-29 裁「内置」,判据 = 可行性调研 §7 的 docker 实测):
|
|
19836
|
+
* ① **纯 wasm**:无原生二进制、无平台/架构约束、无 postinstall 构建(公开包最怕那三样);
|
|
19837
|
+
* ② 实测渲染一张图尺寸画布 ≈60 ms / ~55 KB PNG。
|
|
19838
|
+
*
|
|
19839
|
+
* 🔴 **必须喂字体,且这是实测结论不是设计偏好**:docker 里给容器装好中文字体(`fc-list` 认它)
|
|
19840
|
+
* 后,resvg-wasm 渲染中文**仍得到空白画布**,且 `loadSystemFonts:true` 与 `false` **逐字节相同**
|
|
19841
|
+
* ⇒ **wasm 对系统字体完全不可见**。只有 `fontBuffers` 才画得出字。
|
|
19842
|
+
* ⇒ 顺带白拿一个好处:**输出与机器无关**(同一份内置字体 ⇒ 逐字节可复现,S↑ 稳定)。
|
|
19843
|
+
*
|
|
19844
|
+
* 字体资产随包分发:`assets/diagram/DroidSansFallbackFull.ttf`(3.8 MB,Apache-2.0,含许可证与出处)。
|
|
19845
|
+
*/
|
|
19846
|
+
/**
|
|
19847
|
+
* 包根 = **向上找到含 `package.json` 的那一级**。
|
|
19848
|
+
*
|
|
19849
|
+
* 🔴 为什么不能照抄 `voyage-page.ts` 的「`..` 一次」:那个文件在 `src/` 根下,一次 `..` 恰好是包根;
|
|
19850
|
+
* 本模块在 `src/diagram/` 里 ⇒ 开发态(vitest 直接跑 TS 源码)一次 `..` 只到 `src/`,
|
|
19851
|
+
* 于是去找 `src/assets/…` —— **实测 ENOENT**(测试全红,且现象是"字体不在",与真因"路径层级算错"
|
|
19852
|
+
* 长得完全不同)。按 `package.json` 上溯对**开发态(src/diagram)与发布态(lib/diagram)都成立**。
|
|
19853
|
+
* @returns 包根绝对路径;四层内找不到 `package.json` 时回落"上一级"(不抛——定位失败由调用方响亮报错)
|
|
19854
|
+
*/
|
|
19855
|
+
function packageRootPath() {
|
|
19856
|
+
const start = dirname(fileURLToPath(import.meta.url));
|
|
19857
|
+
let dir = start;
|
|
19858
|
+
for (let i = 0; i < 4; i++) {
|
|
19859
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
19860
|
+
dir = dirname(dir);
|
|
19861
|
+
}
|
|
19862
|
+
return join(start, "..");
|
|
19863
|
+
}
|
|
19864
|
+
/** 字体资产相对包根的路径(**必须同时出现在 package.json 的 files 白名单里**,pack-check 会核对) */
|
|
19865
|
+
const DIAGRAM_FONT_ASSET = "assets/diagram/DroidSansFallbackFull.ttf";
|
|
19866
|
+
/** 字体绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
|
|
19867
|
+
function diagramFontPath() {
|
|
19868
|
+
return join(packageRootPath(), DIAGRAM_FONT_ASSET);
|
|
19869
|
+
}
|
|
19870
|
+
/**
|
|
19871
|
+
* 内置字体的族名。SVG 里声明的 `Noto Sans CJK SC` / `Source Han Sans SC` / `Microsoft YaHei`
|
|
19872
|
+
* 在本机**一个都不存在**(本机 CJK 覆盖只有 AR PL UMing + Droid Sans Fallback)
|
|
19873
|
+
* ⇒ 光栅化时必须由这里兜底,否则中文会退化成缺字方框。
|
|
19874
|
+
*/
|
|
19875
|
+
const DIAGRAM_FONT_FAMILY = "Droid Sans Fallback";
|
|
19876
|
+
let wasmReady = null;
|
|
19877
|
+
let fontBuf = null;
|
|
19878
|
+
/**
|
|
19879
|
+
* wasm 模块路径:**先解析 JS 入口再取同目录的 `.wasm`**。
|
|
19880
|
+
* 不直接 resolve `.wasm` 的原因:包的 `exports` 表未必暴露该子路径(解析失败会抛,
|
|
19881
|
+
* 而"抛"与"文件不在"在验收上长得一样 —— 故走这条更稳的路)。
|
|
19882
|
+
*/
|
|
19883
|
+
function wasmPath() {
|
|
19884
|
+
const req = createRequire(import.meta.url);
|
|
19885
|
+
return join(dirname(req.resolve("@resvg/resvg-wasm")), "index_bg.wasm");
|
|
19886
|
+
}
|
|
19887
|
+
function ensureReady() {
|
|
19888
|
+
if (wasmReady === null) wasmReady = initWasm(readFileSync(wasmPath())).catch((err) => {
|
|
19889
|
+
if (/already initialized/iu.test(String(err?.message ?? err))) return;
|
|
19890
|
+
throw err;
|
|
19891
|
+
});
|
|
19892
|
+
return wasmReady;
|
|
19893
|
+
}
|
|
19894
|
+
/** 字体只读一次(3.8 MB,进程内缓存) */
|
|
19895
|
+
function fontBuffer() {
|
|
19896
|
+
if (fontBuf === null) fontBuf = readFileSync(diagramFontPath());
|
|
19897
|
+
return fontBuf;
|
|
19898
|
+
}
|
|
19899
|
+
/**
|
|
19900
|
+
* SVG → PNG。
|
|
19901
|
+
* @param width 输出像素宽(矢量无损放大;高度按 SVG 自身比例推出)
|
|
19902
|
+
*/
|
|
19903
|
+
async function rasterizeSvg(svg, width) {
|
|
19904
|
+
await ensureReady();
|
|
19905
|
+
const t0 = Date.now();
|
|
19906
|
+
const img = new Resvg(svg, {
|
|
19907
|
+
fitTo: {
|
|
19908
|
+
mode: "width",
|
|
19909
|
+
value: width
|
|
19910
|
+
},
|
|
19911
|
+
font: {
|
|
19912
|
+
fontBuffers: [fontBuffer()],
|
|
19913
|
+
loadSystemFonts: false,
|
|
19914
|
+
defaultFontFamily: DIAGRAM_FONT_FAMILY
|
|
19915
|
+
}
|
|
19916
|
+
}).render();
|
|
19917
|
+
return {
|
|
19918
|
+
png: img.asPng(),
|
|
19919
|
+
width: img.width,
|
|
19920
|
+
height: img.height,
|
|
19921
|
+
ms: Date.now() - t0
|
|
19922
|
+
};
|
|
19923
|
+
}
|
|
19924
|
+
//#endregion
|
|
19925
|
+
//#region src/tools/diagram.ts
|
|
19926
|
+
/**
|
|
19927
|
+
* diagram.ts — `diagram` 工具:**agent 直接给一段标签格式源码,自动出图**
|
|
19928
|
+
*
|
|
19929
|
+
* owner 2026-09-29 具名令(D104):「做成一个 tool,**ACC 级别**的,配合一个开关(demo 功能嘛,
|
|
19930
|
+
* 默认关,开了才可用);Agent 调用这个 tool **直接给代码**就行,然后 dsh 可以**自动渲染成图片**
|
|
19931
|
+
* (注意**不借助浏览器生成截图**)」。权威设计 = 插件仓 `docs/diagram-tool-feasibility.md`。
|
|
19932
|
+
*
|
|
19933
|
+
* 链路(全部进程内,**不起浏览器**):标签源码 → `parseDsl` → `renderDsl`(SVG + 四项体检)
|
|
19934
|
+
* → `rasterizeSvg`(resvg-wasm + **内置字体**)→ PNG → 落 CCC `_tmp/diagram/` + 图片块进对话。
|
|
19935
|
+
*
|
|
19936
|
+
* 🔴 **为什么 PNG 是必需的**:宿主的对话内图片通路**白名单只有光栅格式**
|
|
19937
|
+
* (`image/png|jpeg|webp|gif`,见 `dsh-attachment/types.d.ts`)⇒ **SVG 进不去**,
|
|
19938
|
+
* "产出 SVG 就自动显示"确定不成立。右侧栏走 SVG 是二期方案。
|
|
19939
|
+
*
|
|
19940
|
+
* 🔴 **开关**:由 `index.ts` 在 registration 处门控(**关时不注册** ⇒ 工具根本不进模型工具清单,
|
|
19941
|
+
* 不占 token、不会被误调)—— 同 `skiff`/`acp` 的"未开启零资源占用"口径。
|
|
19942
|
+
*
|
|
19943
|
+
* ## 🔴 两条**宿主契约**(2026-09-29 现场取证;第 ① 条是本文件第一版写错的地方)
|
|
19944
|
+
*
|
|
19945
|
+
* ① **`output.render` 是「同步投影」,宿主不 await 它**:`@deepseek-ai/dsh-tools` 的
|
|
19946
|
+
* `createSuccessResult()` 直接 `rendered = tool.output.render(args, value)`,紧接着
|
|
19947
|
+
* `snapshotJsonValue(rendered)` —— 返回一个 Promise 会被判「**非 lossless JSON**」⇒
|
|
19948
|
+
* **整条工具调用失败**(图片与文字回执**一起**丢掉)。⚠️ 第一版把 async 实现 cast 成同步,
|
|
19949
|
+
* 于是"拿不到图片块仍返回文字回执"那段自兜底**根本走不到**(它以为的失败模式不是真失败模式)。
|
|
19950
|
+
* ② **图片块带的 `attachment` 必须是附件服务的 durable 引用**(`ImageAttachmentRef`),
|
|
19951
|
+
* 而存字节是 async ⇒ **只能分两段**:`execute` 里 `saveImage`(async,把引用拼进**纯 JSON** 的返回值),
|
|
19952
|
+
* `render` 里**同步**装配 `{ type: 'image', attachment: … }`。
|
|
19953
|
+
* 这正是平台自家 `read_image` 的形态(本机 live 安装 `dsh-tool-fs` 取证:`imageRefFromValue` +
|
|
19954
|
+
* `render: (_args, value) => imageReadContent(value)`),本文件照该形态实现。
|
|
19955
|
+
*
|
|
19956
|
+
* ⚠️ 因此 render 里那个 `as unknown as ContentBlock` 是**必要的**(本包不引 `@deepseek-ai/dsh-attachment`,
|
|
19957
|
+
* 它不是 peerDep ⇒ 拿不到 `AttachmentId` 的 brand),而它**同时让类型检查对字段名变瞎**(判据 ㉜)⇒
|
|
19958
|
+
* 字段名由 `tests/diagram-tool.test.ts` 逐字钉住。
|
|
19959
|
+
*/
|
|
19960
|
+
/** 输出宽度缺省(矢量无损放大;高度按图自身比例推出) */
|
|
19961
|
+
const DEFAULT_WIDTH = 1200;
|
|
19962
|
+
/** 时间戳:ISO → `YYYYMMDD-HHMMSS`(本地时区不参与,仅用于文件名排序) */
|
|
19963
|
+
function stamp() {
|
|
19964
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
19965
|
+
}
|
|
19966
|
+
/**
|
|
19967
|
+
* 当前模型路由是否**明确声明**收图像输入(正判据 —— 与 `read_image` 同一道门)。
|
|
19968
|
+
*
|
|
19969
|
+
* 🔴 为什么这道门必须过(而不是"先附上试试"):图片块进的是**工具结果内容**,
|
|
19970
|
+
* 若路由不收图,最坏情形是**整轮请求**被装配层拒掉 —— 比"没图"坏得多。
|
|
19971
|
+
* ⚠️ 与 `read_image` 唯一的差别:**这里不抛错**(图已落盘,改用文字给路径)。
|
|
19972
|
+
*/
|
|
19973
|
+
async function routeDeclaresImage(ctx, exec) {
|
|
19974
|
+
const agent = exec.agent;
|
|
19975
|
+
const routed = agent?.session?.requestHeader?.()?.config;
|
|
19976
|
+
const provider = routed?.provider ?? agent?.options?.provider;
|
|
19977
|
+
const model = routed?.model ?? agent?.options?.model;
|
|
19978
|
+
const llm = ctx.get?.("llm");
|
|
19979
|
+
if (provider === void 0 || model === void 0 || llm?.resolveModelInfo === void 0) return false;
|
|
19980
|
+
const signal = exec.signal;
|
|
19981
|
+
const modes = (await llm.resolveModelInfo(provider, model, signal))?.inputModalities;
|
|
19982
|
+
return Array.isArray(modes) && modes.includes("image");
|
|
19983
|
+
}
|
|
19984
|
+
/**
|
|
19985
|
+
* 落成**可进对话的图片块**所需的 durable 引用(async 段 —— 见文件头契约 ①)。
|
|
19986
|
+
*
|
|
19987
|
+
* 三条降级**都不抛错、只把原因带回**(图已落盘,文字回执里给路径:
|
|
19988
|
+
* 工具的价值不允许"附不上图"把整条回执一起带走):
|
|
19989
|
+
* ① 附件服务不在(未挂载) ② 部署不收 `image/png` ③ 当前路由未声明图像输入 ④ `saveImage` 自身报错。
|
|
19990
|
+
*/
|
|
19991
|
+
async function attachPng(ctx, exec, png, name) {
|
|
19992
|
+
const attachments = ctx.get?.("attachments");
|
|
19993
|
+
if (attachments === void 0 || typeof attachments.saveImage !== "function") return {
|
|
19994
|
+
image: null,
|
|
19995
|
+
imageSkip: "附件服务未挂载(ctx.attachments 不在)"
|
|
19996
|
+
};
|
|
19997
|
+
if (!attachments.imageLimits.mediaTypes.includes("image/png")) return {
|
|
19998
|
+
image: null,
|
|
19999
|
+
imageSkip: "本部署不收 image/png"
|
|
20000
|
+
};
|
|
20001
|
+
try {
|
|
20002
|
+
if (!await routeDeclaresImage(ctx, exec)) return {
|
|
20003
|
+
image: null,
|
|
20004
|
+
imageSkip: "当前模型路由未声明图像输入(同 read_image 的门)"
|
|
20005
|
+
};
|
|
20006
|
+
} catch (err) {
|
|
20007
|
+
return {
|
|
20008
|
+
image: null,
|
|
20009
|
+
imageSkip: `模型路由未能解析:${String(err?.message ?? err)}`
|
|
20010
|
+
};
|
|
20011
|
+
}
|
|
20012
|
+
try {
|
|
20013
|
+
const ref = await attachments.saveImage({
|
|
20014
|
+
data: png,
|
|
20015
|
+
mediaType: "image/png",
|
|
20016
|
+
name: `${name}.png`
|
|
20017
|
+
});
|
|
20018
|
+
return {
|
|
20019
|
+
image: {
|
|
20020
|
+
attachmentId: ref.attachmentId,
|
|
20021
|
+
mediaType: ref.mediaType,
|
|
20022
|
+
bytes: ref.bytes,
|
|
20023
|
+
width: ref.width,
|
|
20024
|
+
height: ref.height,
|
|
20025
|
+
name: ref.name ?? `${name}.png`
|
|
20026
|
+
},
|
|
20027
|
+
imageSkip: null
|
|
20028
|
+
};
|
|
20029
|
+
} catch (err) {
|
|
20030
|
+
return {
|
|
20031
|
+
image: null,
|
|
20032
|
+
imageSkip: `附件服务拒收:${String(err?.message ?? err)}`
|
|
20033
|
+
};
|
|
20034
|
+
}
|
|
20035
|
+
}
|
|
20036
|
+
function createDiagramTool(ctx) {
|
|
20037
|
+
return defineTool({
|
|
20038
|
+
name: "diagram",
|
|
20039
|
+
description: "Render a diagram from tag-format source into an image (in-process, no browser). Pass the source directly: a <diagram> root with <rect box=\"x0,y0,x1,y1\" label fill/>, <line>/<arrow from to> (endpoints may reference anchors like 德国.e or 波兰.n+3,0), <text x y align>, <color name value/>, <legend>. Returns the image in the conversation plus any authoring errors and four layout warnings (overlapping boxes / label overflow / dangling endpoints / a line crossing a label). A warning never blocks the image; an errored element is skipped and the rest still renders. Both the SVG and the PNG are written under _tmp/diagram/ in this container.",
|
|
20040
|
+
parameters: {
|
|
20041
|
+
source: {
|
|
20042
|
+
type: "string",
|
|
20043
|
+
required: true,
|
|
20044
|
+
description: "Tag-format diagram source"
|
|
20045
|
+
},
|
|
20046
|
+
width: {
|
|
20047
|
+
type: "integer",
|
|
20048
|
+
description: `Output pixel width (default ${DEFAULT_WIDTH}; vector, so scaling is lossless)`
|
|
20049
|
+
},
|
|
20050
|
+
name: {
|
|
20051
|
+
type: "string",
|
|
20052
|
+
description: "Optional short name used in the output file name"
|
|
20053
|
+
}
|
|
20054
|
+
},
|
|
20055
|
+
output: {
|
|
20056
|
+
schema: { type: "json" },
|
|
20057
|
+
render: (_args, value) => {
|
|
20058
|
+
const v = value;
|
|
20059
|
+
const lines = [`图已生成(${v.marks} 个图元 / ${v.pxW}×${v.pxH} px / ${v.bytes} B PNG / 光栅化 ${v.ms} ms)`];
|
|
20060
|
+
lines.push(`SVG: ${v.svgRel}`);
|
|
20061
|
+
lines.push(`PNG: ${v.pngRel}`);
|
|
20062
|
+
if (v.imageSkip !== null) lines.push(`⚠️ 未附图片块:${v.imageSkip}(PNG 已落盘,路径见上)`);
|
|
20063
|
+
if (v.errors.length > 0) {
|
|
20064
|
+
lines.push(`✗ 源码错误 ${v.errors.length} 条(图已尽力渲染;下列图元已跳过)`);
|
|
20065
|
+
for (const e of v.errors) lines.push(` [err] ${e}`);
|
|
20066
|
+
}
|
|
20067
|
+
if (v.lint.length === 0) lines.push("[lint] 体检干净(0 告警)");
|
|
20068
|
+
else for (const w of v.lint) lines.push(` [lint] ${w}`);
|
|
20069
|
+
const blocks = [{
|
|
20070
|
+
type: "text",
|
|
20071
|
+
text: lines.join("\n")
|
|
20072
|
+
}];
|
|
20073
|
+
if (v.image !== null) blocks.push({
|
|
20074
|
+
type: "image",
|
|
20075
|
+
attachment: { ...v.image }
|
|
20076
|
+
});
|
|
20077
|
+
return blocks;
|
|
20078
|
+
}
|
|
20079
|
+
},
|
|
20080
|
+
async execute(args, exec) {
|
|
20081
|
+
const root = cccRootForExec(exec);
|
|
20082
|
+
if (root === null) throw new Error("diagram: 找不到 CCC 根(本会话缺 header.cwd)—— 产物必须落在容器内");
|
|
20083
|
+
const src = typeof args.source === "string" ? args.source : "";
|
|
20084
|
+
const name = typeof args.name === "string" && args.name.trim() !== "" ? args.name.trim() : "diagram";
|
|
20085
|
+
const width = typeof args.width === "number" && Number.isFinite(args.width) && args.width > 0 ? Math.round(args.width) : DEFAULT_WIDTH;
|
|
20086
|
+
const parsed = parseDsl(src);
|
|
20087
|
+
const rendered = renderDsl(parsed.model);
|
|
20088
|
+
const { png, ms, width: pxW, height: pxH } = await rasterizeSvg(rendered.svg, width);
|
|
20089
|
+
const rel = join("_tmp", "diagram");
|
|
20090
|
+
const dir = join(root, rel);
|
|
20091
|
+
mkdirSync(dir, { recursive: true });
|
|
20092
|
+
const base = `${stamp()}-${name.replace(/[^\w\u4e00-\u9fa5-]/g, "_")}`;
|
|
20093
|
+
writeFileSync(join(dir, `${base}.svg`), rendered.svg);
|
|
20094
|
+
writeFileSync(join(dir, `${base}.png`), png);
|
|
20095
|
+
const { image, imageSkip } = await attachPng(ctx, exec, png, base);
|
|
20096
|
+
return {
|
|
20097
|
+
name: base,
|
|
20098
|
+
svgRel: join(rel, `${base}.svg`),
|
|
20099
|
+
pngRel: join(rel, `${base}.png`),
|
|
20100
|
+
lint: rendered.lint,
|
|
20101
|
+
errors: [...parsed.errors, ...rendered.errors],
|
|
20102
|
+
marks: rendered.marks,
|
|
20103
|
+
ms,
|
|
20104
|
+
bytes: png.length,
|
|
20105
|
+
pxW,
|
|
20106
|
+
pxH,
|
|
20107
|
+
image,
|
|
20108
|
+
imageSkip
|
|
20109
|
+
};
|
|
20110
|
+
}
|
|
20111
|
+
});
|
|
20112
|
+
}
|
|
20113
|
+
//#endregion
|
|
19273
20114
|
//#region src/host/storage-domain.ts
|
|
19274
20115
|
/**
|
|
19275
20116
|
* host/storage-domain.ts — 宿主存储域上的「会话 ↔ 轨迹」绑定表(§0L,S142 2026-09-19)
|
|
@@ -19482,6 +20323,7 @@ const Config = z.object({
|
|
|
19482
20323
|
webFetch: z.object({ enabled: z.boolean().default(true) }),
|
|
19483
20324
|
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
|
|
19484
20325
|
visionPatch: z.object({ enabled: z.boolean().default(true) }),
|
|
20326
|
+
diagram: z.object({ enabled: z.boolean().default(false) }),
|
|
19485
20327
|
humanChannel: z.object({
|
|
19486
20328
|
accounts: z.array(humanChannelAccountSchema).default([]),
|
|
19487
20329
|
subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
|
|
@@ -19496,7 +20338,8 @@ const Config = z.object({
|
|
|
19496
20338
|
acpHttpPort: z.number().min(1024).max(65535).volatile(),
|
|
19497
20339
|
publicAskEnabled: z.boolean().volatile(),
|
|
19498
20340
|
croEnabled: z.boolean().volatile(),
|
|
19499
|
-
unattendedEnabled: z.boolean().volatile()
|
|
20341
|
+
unattendedEnabled: z.boolean().volatile(),
|
|
20342
|
+
diagramEnabled: z.boolean().volatile()
|
|
19500
20343
|
});
|
|
19501
20344
|
function apply(ctx, config) {
|
|
19502
20345
|
try {
|
|
@@ -19545,6 +20388,7 @@ function apply(ctx, config) {
|
|
|
19545
20388
|
registerVoyageApi(ctx);
|
|
19546
20389
|
}
|
|
19547
20390
|
registerSettingsSection(ctx, config);
|
|
20391
|
+
if (config.tools) registerDiagramTool(ctx);
|
|
19548
20392
|
registerGateway(ctx);
|
|
19549
20393
|
registerRebuildTurnHook(ctx);
|
|
19550
20394
|
registerOutputGuardHook(ctx);
|
|
@@ -19565,6 +20409,26 @@ function apply(ctx, config) {
|
|
|
19565
20409
|
if (config.visionPatch?.enabled !== false) registerDeepseekVisionPatch(ctx, true);
|
|
19566
20410
|
}
|
|
19567
20411
|
/**
|
|
20412
|
+
* diagram 工具装配(**D104**,2026-09-29 owner 具名令):**默认关**的实验性工具 ⇒ 关时**不注册**。
|
|
20413
|
+
*
|
|
20414
|
+
* 归属与边界(E↑):**机制归 ACC**(工具面 + 开关 + 光栅化),**图的语义归调用方**
|
|
20415
|
+
* (标签语法与四项体检在 `src/diagram/`,权威设计 = 插件仓 `docs/diagram-tool-feasibility.md`)。
|
|
20416
|
+
*
|
|
20417
|
+
* 🔴 **两条被实测钉住的接线前提**(改这里之前先读):
|
|
20418
|
+
* ① 必须**晚于** `registerSettingsSection`(开关值经 `readSimpleSettings()`,源在那里接上);
|
|
20419
|
+
* ② 注册失败**不得**成为启动单点(同 `hostContractReport` 的口径),但要**响亮**——
|
|
20420
|
+
* 静默失败会让"拨了开关没反应"看起来像开关坏了(判据:**失败必须可见**)。
|
|
20421
|
+
* @param ctx - 插件上下文
|
|
20422
|
+
*/
|
|
20423
|
+
function registerDiagramTool(ctx) {
|
|
20424
|
+
if (!readSimpleSettings().diagramEnabled) return;
|
|
20425
|
+
try {
|
|
20426
|
+
ctx.tools.register(createDiagramTool(ctx));
|
|
20427
|
+
} catch (err) {
|
|
20428
|
+
console.error(`[serenity-hooks] ✗ diagram 工具注册失败: ${String(err?.message ?? err)}`);
|
|
20429
|
+
}
|
|
20430
|
+
}
|
|
20431
|
+
/**
|
|
19568
20432
|
* F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
|
|
19569
20433
|
* settings-changed 事件触发同步(skiffEnabled 开 → 启动调试服务;关 → 停止)。
|
|
19570
20434
|
* 角色配置(skiff.roles)从当前 CCC 根读取(含 skiff.roles 的 CCC 优先,进程 cwd 次之)。
|