@shgroup/dsh-serenity-hooks 1.50.0 → 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 +5 -4
- 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/host/contract.d.ts +40 -7
- package/lib/human-channel.d.ts +131 -2
- package/lib/index.d.ts +21 -0
- package/lib/index.js +1452 -94
- 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/status.d.ts +29 -2
- package/lib/tools/diagram.d.ts +2 -0
- package/lib/{weixin-api-CtFYm53S.js → weixin-api-BrRcPiQs.js} +1 -1
- package/lib/weixin-bridge.d.ts +60 -5
- package/lib/{weixin-route-CWMRa36W.js → weixin-route-7BtOiy4X.js} +1 -1
- package/package.json +23 -17
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: {
|
|
@@ -9889,12 +9893,64 @@ function registerCompactRetention(ctx, opts = {}) {
|
|
|
9889
9893
|
* WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / handyman 模型。
|
|
9890
9894
|
* setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
|
|
9891
9895
|
*/
|
|
9896
|
+
/** 宿主包名 —— 从运行入口反推包根时的**身份核对**用(见 {@link dshVersionFromEntry}) */
|
|
9897
|
+
const HOST_PACKAGE_NAME = "@deepseek-ai/dsh";
|
|
9898
|
+
/** 上溯层数上限:入口脚本在包内的深度是 `lib/`(1 层)或 `lib/x/y/`(≤3 层)⇒ 6 层足够,且**保证有界** */
|
|
9899
|
+
const MAX_ENTRY_ASCENT = 6;
|
|
9900
|
+
/**
|
|
9901
|
+
* 🔴 **布局无关档**:从**正在跑的那个入口脚本**反推宿主包根,读它的 `version`。
|
|
9902
|
+
*
|
|
9903
|
+
* 为什么必须补这一档(R↓:一处**静默失效**的实际缺陷;2026-09-28 docker bench 实测):
|
|
9904
|
+
* 下面那条静态候选链({@link readDshVersion} 的 ②)是**猜安装位置**——`npm_config_prefix` /
|
|
9905
|
+
* `APPDATA\npm` / `~/.npm-global`。一旦宿主装在**别处**(典型 = 容器镜像里的
|
|
9906
|
+
* `/usr/local/lib/node_modules/@deepseek-ai/dsh`),**三个候选全不中** ⇒ 返回 `null`
|
|
9907
|
+
* ⇒ `checkHostVersion(null)` 只给一条 **`required: false`** 的 issue ⇒ 报告 `ok` 仍为 `true`
|
|
9908
|
+
* ⇒ **版本门完全静默**(bench 逐字:`host contract degraded (1 optional): host version unknown`)。
|
|
9909
|
+
* ⚠️ 这类坏法**比"偶尔报警"更危险**:一条**永远为假**的判据看起来像"没报警 = 没问题"。
|
|
9910
|
+
*
|
|
9911
|
+
* 而"我们正跑在哪个宿主里"**本来就有直接证据**:`process.argv[1]`(= 启动本进程的那个脚本)。
|
|
9912
|
+
* 三条判据要点(每条都对应一个实测过的坑):
|
|
9913
|
+
* ① **先 `realpath`** —— 全局安装的 `dsh` 是**符号链接**(`/usr/local/bin/dsh` → `…/@deepseek-ai/dsh/lib/bin.js`),
|
|
9914
|
+
* 而 Node 的 `argv[1]` **保留调用路径、不解析符号链接** ⇒ 不 realpath 就永远上溯不到包根。
|
|
9915
|
+
* ② **核对包名**(`name === '@deepseek-ai/dsh'`)—— 否则在 vitest / npx 等入口下会误取**别人的**
|
|
9916
|
+
* `package.json`(这是本档**唯一**的假阳性来源)。
|
|
9917
|
+
* ③ **上溯有界**({@link MAX_ENTRY_ASCENT})—— 不设界的话,一个不在任何 dsh 包内的入口会一路爬到文件系统根。
|
|
9918
|
+
*
|
|
9919
|
+
* @param entry `process.argv[1]`(单测直接注入,无需替身)
|
|
9920
|
+
*/
|
|
9921
|
+
function dshVersionFromEntry(entry) {
|
|
9922
|
+
if (typeof entry !== "string" || entry === "") return null;
|
|
9923
|
+
let dir;
|
|
9924
|
+
try {
|
|
9925
|
+
const real = realpathSync(entry);
|
|
9926
|
+
dir = statSync(real).isDirectory() ? real : dirname(real);
|
|
9927
|
+
} catch {
|
|
9928
|
+
return null;
|
|
9929
|
+
}
|
|
9930
|
+
for (let i = 0; i < MAX_ENTRY_ASCENT; i++) {
|
|
9931
|
+
try {
|
|
9932
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
9933
|
+
if (pkg.name === HOST_PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
|
|
9934
|
+
} catch {}
|
|
9935
|
+
const parent = dirname(dir);
|
|
9936
|
+
if (parent === dir) break;
|
|
9937
|
+
dir = parent;
|
|
9938
|
+
}
|
|
9939
|
+
return null;
|
|
9940
|
+
}
|
|
9892
9941
|
/**
|
|
9893
9942
|
* 读取已安装 DSH CLI 版本;读不到返回 null。
|
|
9894
|
-
*
|
|
9895
|
-
*
|
|
9943
|
+
*
|
|
9944
|
+
* **两档,顺序有理由**:
|
|
9945
|
+
* ① 🔴 **布局无关档**({@link dshVersionFromEntry})= **正在跑的就是它** —— 直接证据优先于"猜安装位置"。
|
|
9946
|
+
* ② **静态候选链**(跨平台历史档,保留):`npm_config_prefix` / `APPDATA\npm` / `~/.npm-global`
|
|
9947
|
+
* (Windows npm 全局装在 `%APPDATA%\npm`,见审计问题 13)。
|
|
9948
|
+
* ⚠️ 保留它是因为**本进程不一定是 dsh CLI**(单测、嵌入式、将来别的宿主进程)——
|
|
9949
|
+
* 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
|
|
9896
9950
|
*/
|
|
9897
9951
|
function readDshVersion() {
|
|
9952
|
+
const fromEntry = dshVersionFromEntry(process.argv[1]);
|
|
9953
|
+
if (fromEntry !== null) return fromEntry;
|
|
9898
9954
|
const candidates = [];
|
|
9899
9955
|
if (process.env.npm_config_prefix) candidates.push(join(process.env.npm_config_prefix, "lib", "node_modules", "@deepseek-ai", "dsh"));
|
|
9900
9956
|
if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", "@deepseek-ai", "dsh"));
|
|
@@ -10302,7 +10358,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10302
10358
|
return;
|
|
10303
10359
|
}
|
|
10304
10360
|
const settings = readAdvancedSettings();
|
|
10305
|
-
const { readSimpleSettings } = await import("./settings-section-
|
|
10361
|
+
const { readSimpleSettings } = await import("./settings-section-cq0eUSd-.js").then((n) => n.r);
|
|
10306
10362
|
const simple = readSimpleSettings();
|
|
10307
10363
|
const allowed = settings.publicAsk.allowed;
|
|
10308
10364
|
const port = simple.acpHttpPort ?? ACP_HTTP_PORT;
|
|
@@ -10337,11 +10393,11 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10337
10393
|
sendJson$4(res, 400, { error: chk.error });
|
|
10338
10394
|
return;
|
|
10339
10395
|
}
|
|
10340
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
10396
|
+
const { readWeixinSettings } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10341
10397
|
const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10342
10398
|
const settings = readWeixinSettings(chk.root);
|
|
10343
10399
|
const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
|
|
10344
|
-
const { readWeixinCredential } = await import("./weixin-route-
|
|
10400
|
+
const { readWeixinCredential } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10345
10401
|
const accounts = (settings.accounts ?? []).map((a) => ({
|
|
10346
10402
|
accountId: a.accountId,
|
|
10347
10403
|
name: a.name ?? void 0,
|
|
@@ -10370,10 +10426,10 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10370
10426
|
}
|
|
10371
10427
|
const root = chk.root;
|
|
10372
10428
|
if (body.action === "login-start") {
|
|
10373
|
-
const { fetchQRCode } = await import("./weixin-api-
|
|
10429
|
+
const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
|
|
10374
10430
|
const { randomUUID } = await import("node:crypto");
|
|
10375
10431
|
for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
|
|
10376
|
-
const { readWeixinSettings } = await import("./weixin-route-
|
|
10432
|
+
const { readWeixinSettings } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10377
10433
|
const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
|
|
10378
10434
|
const loginKey = randomUUID();
|
|
10379
10435
|
weixinLogins.set(loginKey, {
|
|
@@ -10394,7 +10450,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10394
10450
|
sendJson$4(res, 400, { error: "missing accountId" });
|
|
10395
10451
|
return;
|
|
10396
10452
|
}
|
|
10397
|
-
const { removeWeixinAccount } = await import("./weixin-route-
|
|
10453
|
+
const { removeWeixinAccount } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10398
10454
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10399
10455
|
removeWeixinAccount(root, body.accountId);
|
|
10400
10456
|
syncCccBridge(ctx, root);
|
|
@@ -10407,8 +10463,8 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10407
10463
|
sendJson$4(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
|
|
10408
10464
|
return;
|
|
10409
10465
|
}
|
|
10410
|
-
const { saveWeixinRoutes } = await import("./weixin-route-
|
|
10411
|
-
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);
|
|
10412
10468
|
const roles = readSkiffRoles(root);
|
|
10413
10469
|
for (const r of routes) if (!roles.has(r.role)) {
|
|
10414
10470
|
sendJson$4(res, 400, { error: `unknown role: ${r.role} (not in ${root} skiff.roles)` });
|
|
@@ -10420,7 +10476,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10420
10476
|
}
|
|
10421
10477
|
if (body.action === "set-enabled") {
|
|
10422
10478
|
const enabled = body.enabled === true;
|
|
10423
|
-
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-
|
|
10479
|
+
const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10424
10480
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10425
10481
|
const settings = readWeixinSettings(root);
|
|
10426
10482
|
if (enabled && (settings.accounts ?? []).length === 0) {
|
|
@@ -10463,8 +10519,8 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10463
10519
|
sendJson$4(res, 200, { status: "expired" });
|
|
10464
10520
|
return;
|
|
10465
10521
|
}
|
|
10466
|
-
const { pollQRStatus } = await import("./weixin-api-
|
|
10467
|
-
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);
|
|
10468
10524
|
const settings = readWeixinSettings(login.root);
|
|
10469
10525
|
const status = await pollQRStatus({
|
|
10470
10526
|
baseUrl: void 0,
|
|
@@ -10479,7 +10535,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
10479
10535
|
});
|
|
10480
10536
|
return;
|
|
10481
10537
|
}
|
|
10482
|
-
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-
|
|
10538
|
+
const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-7BtOiy4X.js").then((n) => n.u);
|
|
10483
10539
|
const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
|
|
10484
10540
|
const accountId = nextWeixinAccountId(settings);
|
|
10485
10541
|
upsertWeixinAccount(login.root, {
|
|
@@ -10594,12 +10650,12 @@ const VOYAGE_PATH = "/serenity/voyage";
|
|
|
10594
10650
|
*/
|
|
10595
10651
|
const VOYAGE_ASSET = "assets/serenity-voyage.html";
|
|
10596
10652
|
/** 包根 = 本模块所在目录的上一级(构建产物在 `lib/`,与 `assets/` 同级;同 constants.ts 定位法) */
|
|
10597
|
-
function packageRootPath() {
|
|
10653
|
+
function packageRootPath$1() {
|
|
10598
10654
|
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10599
10655
|
}
|
|
10600
10656
|
/** 资产绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
|
|
10601
10657
|
function voyageAssetPath() {
|
|
10602
|
-
return join(packageRootPath(), VOYAGE_ASSET);
|
|
10658
|
+
return join(packageRootPath$1(), VOYAGE_ASSET);
|
|
10603
10659
|
}
|
|
10604
10660
|
/** 缓存(830 KB,避免每次展开卡片都读盘;进程内只读一次) */
|
|
10605
10661
|
let cachedHtml = null;
|
|
@@ -11017,12 +11073,43 @@ const HOST_SERVICES = [
|
|
|
11017
11073
|
required: false
|
|
11018
11074
|
}
|
|
11019
11075
|
];
|
|
11020
|
-
/**
|
|
11021
|
-
|
|
11022
|
-
|
|
11023
|
-
|
|
11024
|
-
|
|
11025
|
-
|
|
11076
|
+
/**
|
|
11077
|
+
* dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
|
|
11078
|
+
*
|
|
11079
|
+
* 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
|
|
11080
|
+
* - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
|
|
11081
|
+
* = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
|
|
11082
|
+
* - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
|
|
11083
|
+
* 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
|
|
11084
|
+
* (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
|
|
11085
|
+
* ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
|
|
11086
|
+
* ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
|
|
11087
|
+
* `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
|
|
11088
|
+
*
|
|
11089
|
+
* 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
|
|
11090
|
+
* - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
|
|
11091
|
+
* (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
|
|
11092
|
+
* - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
|
|
11093
|
+
* ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
|
|
11094
|
+
* - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
|
|
11095
|
+
* 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
|
|
11096
|
+
* `0.2.0` 正式版与后续 `0.2.0-rc.N`。
|
|
11097
|
+
*/
|
|
11098
|
+
const VERIFIED_HOST_BANDS = [{
|
|
11099
|
+
floor: "0.1.7-rc.1",
|
|
11100
|
+
ceiling: "0.2.0"
|
|
11101
|
+
}, {
|
|
11102
|
+
floor: "0.2.0-rc.1",
|
|
11103
|
+
ceiling: "0.3.0"
|
|
11104
|
+
}];
|
|
11105
|
+
/**
|
|
11106
|
+
* 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
|
|
11107
|
+
*
|
|
11108
|
+
* 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
|
|
11109
|
+
* ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
|
|
11110
|
+
* 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
|
|
11111
|
+
*/
|
|
11112
|
+
const REQUIRED_HOST_RANGE = VERIFIED_HOST_BANDS.map((b) => `^${b.floor}`).join(" || ");
|
|
11026
11113
|
/** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
|
|
11027
11114
|
function compareSemver(a, b) {
|
|
11028
11115
|
const parse = (v) => {
|
|
@@ -11047,37 +11134,54 @@ function compareSemver(a, b) {
|
|
|
11047
11134
|
return ra < rb ? -1 : 1;
|
|
11048
11135
|
}
|
|
11049
11136
|
/**
|
|
11050
|
-
* 宿主版本是否落在 dsp
|
|
11137
|
+
* 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
|
|
11051
11138
|
* 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
|
|
11052
11139
|
*
|
|
11053
|
-
*
|
|
11054
|
-
*
|
|
11055
|
-
*
|
|
11056
|
-
*
|
|
11140
|
+
* 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
|
|
11141
|
+
* 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
|
|
11142
|
+
* **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
|
|
11143
|
+
* 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
|
|
11144
|
+
* ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
|
|
11057
11145
|
*/
|
|
11058
11146
|
function checkHostVersion(version) {
|
|
11059
11147
|
if (version === null) return {
|
|
11060
11148
|
ok: false,
|
|
11061
11149
|
detail: `host version unknown (required ${REQUIRED_HOST_RANGE})`
|
|
11062
11150
|
};
|
|
11063
|
-
|
|
11064
|
-
|
|
11065
|
-
|
|
11151
|
+
let matched = false;
|
|
11152
|
+
let belowEveryFloor = true;
|
|
11153
|
+
let unparseable = false;
|
|
11154
|
+
for (const band of VERIFIED_HOST_BANDS) {
|
|
11155
|
+
const floor = compareSemver(version, band.floor);
|
|
11156
|
+
const ceiling = compareSemver(version, band.ceiling);
|
|
11157
|
+
if (floor === null || ceiling === null) {
|
|
11158
|
+
unparseable = true;
|
|
11159
|
+
break;
|
|
11160
|
+
}
|
|
11161
|
+
if (floor >= 0) {
|
|
11162
|
+
belowEveryFloor = false;
|
|
11163
|
+
if (ceiling < 0) {
|
|
11164
|
+
matched = true;
|
|
11165
|
+
break;
|
|
11166
|
+
}
|
|
11167
|
+
}
|
|
11168
|
+
}
|
|
11169
|
+
if (unparseable) return {
|
|
11066
11170
|
ok: false,
|
|
11067
11171
|
detail: `host version "${version}" not parseable (required ${REQUIRED_HOST_RANGE})`
|
|
11068
11172
|
};
|
|
11069
|
-
if (
|
|
11173
|
+
if (matched) return {
|
|
11174
|
+
ok: true,
|
|
11175
|
+
detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
|
|
11176
|
+
};
|
|
11177
|
+
if (belowEveryFloor) return {
|
|
11070
11178
|
ok: false,
|
|
11071
11179
|
detail: `host ${version} is below the verified floor (required ${REQUIRED_HOST_RANGE})`
|
|
11072
11180
|
};
|
|
11073
|
-
|
|
11181
|
+
return {
|
|
11074
11182
|
ok: false,
|
|
11075
11183
|
detail: `host ${version} is outside the verified range (required ${REQUIRED_HOST_RANGE})`
|
|
11076
11184
|
};
|
|
11077
|
-
return {
|
|
11078
|
-
ok: true,
|
|
11079
|
-
detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
|
|
11080
|
-
};
|
|
11081
11185
|
}
|
|
11082
11186
|
/** 结构化读取宿主服务(lazy → ctx.get;injected → 直接属性,属性缺失再试 ctx.get) */
|
|
11083
11187
|
function readService(ctx, name, access) {
|
|
@@ -15923,8 +16027,13 @@ qInput.focus()
|
|
|
15923
16027
|
* `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
|
|
15924
16028
|
*
|
|
15925
16029
|
* ## 边界
|
|
15926
|
-
*
|
|
15927
|
-
*
|
|
16030
|
+
* 本模块做三件事(**都属机制**,与"哪个渠道"无关):
|
|
16031
|
+
* ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
|
|
16032
|
+
* ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
|
|
16033
|
+
* ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
|
|
16034
|
+
* 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
|
|
16035
|
+
* **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
|
|
16036
|
+
* ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
|
|
15928
16037
|
*/
|
|
15929
16038
|
/**
|
|
15930
16039
|
* 算账号的**机器级身份**。
|
|
@@ -16018,6 +16127,135 @@ function releasePollersOf(root) {
|
|
|
16018
16127
|
function releaseAllPollers() {
|
|
16019
16128
|
owners.clear();
|
|
16020
16129
|
}
|
|
16130
|
+
/** 订阅行的四个字段(顺序即报错里的字段顺序) */
|
|
16131
|
+
const SUBSCRIPTION_FIELDS = [
|
|
16132
|
+
"account",
|
|
16133
|
+
"user",
|
|
16134
|
+
"ccc",
|
|
16135
|
+
"role"
|
|
16136
|
+
];
|
|
16137
|
+
/**
|
|
16138
|
+
* 订阅表查询(**纯函数**)。
|
|
16139
|
+
*
|
|
16140
|
+
* 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
|
|
16141
|
+
* ① `account` **精确** ∧ `user` **精确**
|
|
16142
|
+
* ② `account` **精确** ∧ `user === '*'`(通配兜底)
|
|
16143
|
+
* 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
|
|
16144
|
+
* 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
|
|
16145
|
+
* 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
|
|
16146
|
+
* 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
|
|
16147
|
+
*
|
|
16148
|
+
* @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
|
|
16149
|
+
* @param account 收到的**账号 id**(poller 身份;不是 token)
|
|
16150
|
+
* @param user 发信人 id
|
|
16151
|
+
*/
|
|
16152
|
+
function lookupSubscription(subscriptions, account, user) {
|
|
16153
|
+
const defects = [];
|
|
16154
|
+
const usable = [];
|
|
16155
|
+
subscriptions.forEach((row, index) => {
|
|
16156
|
+
const raw = {
|
|
16157
|
+
account: row?.account,
|
|
16158
|
+
user: row?.user,
|
|
16159
|
+
ccc: row?.ccc,
|
|
16160
|
+
role: row?.role
|
|
16161
|
+
};
|
|
16162
|
+
const present = {};
|
|
16163
|
+
const missing = [];
|
|
16164
|
+
for (const field of SUBSCRIPTION_FIELDS) {
|
|
16165
|
+
const v = raw[field];
|
|
16166
|
+
if (typeof v === "string" && v.trim() !== "") present[field] = v.trim();
|
|
16167
|
+
else missing.push(field);
|
|
16168
|
+
}
|
|
16169
|
+
if (missing.length > 0) {
|
|
16170
|
+
defects.push({
|
|
16171
|
+
index,
|
|
16172
|
+
missing,
|
|
16173
|
+
present
|
|
16174
|
+
});
|
|
16175
|
+
return;
|
|
16176
|
+
}
|
|
16177
|
+
usable.push({
|
|
16178
|
+
account: present.account,
|
|
16179
|
+
user: present.user,
|
|
16180
|
+
ccc: present.ccc,
|
|
16181
|
+
role: present.role
|
|
16182
|
+
});
|
|
16183
|
+
});
|
|
16184
|
+
return {
|
|
16185
|
+
hit: usable.find((r) => r.account === account && r.user === user) ?? usable.find((r) => r.account === account && r.user === "*") ?? null,
|
|
16186
|
+
defects,
|
|
16187
|
+
usable: usable.length
|
|
16188
|
+
};
|
|
16189
|
+
}
|
|
16190
|
+
/**
|
|
16191
|
+
* 入站路由决策(**纯函数**)。
|
|
16192
|
+
*
|
|
16193
|
+
* 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
|
|
16194
|
+
*
|
|
16195
|
+
* 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
|
|
16196
|
+
* 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
|
|
16197
|
+
* ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
|
|
16198
|
+
* @param input.subscriptions 机器级订阅表
|
|
16199
|
+
* @param input.account 收到的账号 id
|
|
16200
|
+
* @param input.user 发信人 id
|
|
16201
|
+
* @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
|
|
16202
|
+
*/
|
|
16203
|
+
function planInboundRoute(input) {
|
|
16204
|
+
const { hit, defects } = lookupSubscription(input.subscriptions, input.account, input.user);
|
|
16205
|
+
if (hit) return {
|
|
16206
|
+
target: {
|
|
16207
|
+
ccc: hit.ccc,
|
|
16208
|
+
role: hit.role
|
|
16209
|
+
},
|
|
16210
|
+
source: "machine",
|
|
16211
|
+
skipReason: null,
|
|
16212
|
+
defects
|
|
16213
|
+
};
|
|
16214
|
+
if (input.fallback) return {
|
|
16215
|
+
target: {
|
|
16216
|
+
ccc: input.fallback.ccc,
|
|
16217
|
+
role: input.fallback.role
|
|
16218
|
+
},
|
|
16219
|
+
source: "ccc-fallback",
|
|
16220
|
+
skipReason: null,
|
|
16221
|
+
defects
|
|
16222
|
+
};
|
|
16223
|
+
return {
|
|
16224
|
+
target: null,
|
|
16225
|
+
source: null,
|
|
16226
|
+
skipReason: input.subscriptions.length === 0 ? "no-subscription" : "no-match",
|
|
16227
|
+
defects
|
|
16228
|
+
};
|
|
16229
|
+
}
|
|
16230
|
+
/**
|
|
16231
|
+
* 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
|
|
16232
|
+
*
|
|
16233
|
+
* 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
|
|
16234
|
+
* 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
|
|
16235
|
+
* (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
|
|
16236
|
+
* env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
|
|
16237
|
+
*/
|
|
16238
|
+
function humanChannelAuditPath() {
|
|
16239
|
+
const override = process.env.SERENITY_HUMAN_CHANNEL_AUDIT;
|
|
16240
|
+
if (override && override !== "") return override;
|
|
16241
|
+
const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
|
|
16242
|
+
return join(dshHome, "human-channel-audit.jsonl");
|
|
16243
|
+
}
|
|
16244
|
+
/**
|
|
16245
|
+
* 追加一条留痕。**永不抛** ——
|
|
16246
|
+
* 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
|
|
16247
|
+
* 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
|
|
16248
|
+
*/
|
|
16249
|
+
function appendHumanChannelAudit(event) {
|
|
16250
|
+
try {
|
|
16251
|
+
const path = humanChannelAuditPath();
|
|
16252
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
16253
|
+
appendFileSync(path, `${JSON.stringify({
|
|
16254
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16255
|
+
...event
|
|
16256
|
+
})}\n`, "utf-8");
|
|
16257
|
+
} catch {}
|
|
16258
|
+
}
|
|
16021
16259
|
const manualSessions = /* @__PURE__ */ new Map();
|
|
16022
16260
|
/**
|
|
16023
16261
|
* 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
|
|
@@ -16306,6 +16544,7 @@ function invokeWeixinHook(root, hookRel, event) {
|
|
|
16306
16544
|
//#region src/weixin-bridge.ts
|
|
16307
16545
|
var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
|
|
16308
16546
|
handleIncoming: () => handleIncoming,
|
|
16547
|
+
handleMachineIncoming: () => handleMachineIncoming,
|
|
16309
16548
|
manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
|
|
16310
16549
|
registerWeixinBridge: () => registerWeixinBridge,
|
|
16311
16550
|
resolveWeixinAccount: () => resolveWeixinAccount,
|
|
@@ -16313,9 +16552,21 @@ var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
|
|
|
16313
16552
|
stopAllBridges: () => stopAllBridges,
|
|
16314
16553
|
stopCccBridge: () => stopCccBridge,
|
|
16315
16554
|
syncCccBridge: () => syncCccBridge,
|
|
16555
|
+
syncHumanChannel: () => syncHumanChannel,
|
|
16316
16556
|
weixinBridgeStatus: () => weixinBridgeStatus
|
|
16317
16557
|
});
|
|
16318
16558
|
const bridges = /* @__PURE__ */ new Map();
|
|
16559
|
+
/**
|
|
16560
|
+
* 🔴 **机器级 poller 的"根"哨兵**(P0-3 A)—— 它**不是一个目录**。
|
|
16561
|
+
*
|
|
16562
|
+
* 机器级 poller **不属于任何 CCC**(这正是"上移 ACC 层"的含义:谁轮询不再由 CCC 数量决定)。
|
|
16563
|
+
* 用途有二:① `runAccountLoop` 的 `root` 形参在机器级模式下不被使用,传它以示"无根";
|
|
16564
|
+
* ② 归属表(`human-channel.ts`)按 root 区分拥有者 ⇒ 机器级用这个哨兵值,
|
|
16565
|
+
* 与任何**真实** CCC 根都不会撞(真根一律是绝对路径)。
|
|
16566
|
+
*/
|
|
16567
|
+
const MACHINE_SCOPE = "<machine>";
|
|
16568
|
+
/** 机器级 poller(账号 id → 循环)。与 `bridges` **互斥**:同一时刻只有一种模式在跑。 */
|
|
16569
|
+
const machineLoops = /* @__PURE__ */ new Map();
|
|
16319
16570
|
/** 轮询间隔(getupdates 失败后重试延迟;成功 = 立即续轮询) */
|
|
16320
16571
|
const POLL_RETRY_MS = 3e3;
|
|
16321
16572
|
/** 媒体大小上限(M6:20MB,对齐 vlm-describe;超限降级告知不落盘) */
|
|
@@ -16369,7 +16620,7 @@ async function sendTypingStop(cred, accountId, fromUserId) {
|
|
|
16369
16620
|
}
|
|
16370
16621
|
}
|
|
16371
16622
|
/** 单账号轮询循环:getupdates 长轮询 → 逐消息分发 → 回复回写 */
|
|
16372
|
-
async function runAccountLoop(ctx, root, accountId, cred, loop) {
|
|
16623
|
+
async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false) {
|
|
16373
16624
|
let buf = "";
|
|
16374
16625
|
while (!loop.stopped) {
|
|
16375
16626
|
loop.lastPollAt = Date.now();
|
|
@@ -16384,7 +16635,8 @@ async function runAccountLoop(ctx, root, accountId, cred, loop) {
|
|
|
16384
16635
|
for (const msg of resp.msgs ?? []) {
|
|
16385
16636
|
if (loop.stopped) break;
|
|
16386
16637
|
if (msg.message_type !== 1) continue;
|
|
16387
|
-
await
|
|
16638
|
+
if (machine) await handleMachineIncoming(ctx, accountId, cred, msg);
|
|
16639
|
+
else await handleIncoming(ctx, root, accountId, cred, msg);
|
|
16388
16640
|
}
|
|
16389
16641
|
} catch (err) {
|
|
16390
16642
|
loop.lastError = err instanceof Error ? err.message : String(err);
|
|
@@ -16427,6 +16679,106 @@ function manualOutputFallbackNeeded(input) {
|
|
|
16427
16679
|
return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
|
|
16428
16680
|
}
|
|
16429
16681
|
/**
|
|
16682
|
+
* 订阅表里的 `ccc` **别名 → CCC 根**(P0-3)。
|
|
16683
|
+
*
|
|
16684
|
+
* 别名取值与面板下拉**同源**(方案 §2.3:`listCccs({withRoles:true})` 的下拉)⇒
|
|
16685
|
+
* 先按 **`CccEntry.name`**(= CCC 根目录名,`basename(root)`)匹配;再退一步接受**绝对路径**
|
|
16686
|
+
* (手写配置时人会直接粘路径)。两条都试 ⇒ 两种写法都可用,且**不引入第二张名字表**。
|
|
16687
|
+
* @returns 命中的 CCC 根;枚举里没有 ⇒ `null`(调用方**响亮报错**,不静默丢弃)
|
|
16688
|
+
*/
|
|
16689
|
+
async function resolveCccRootByAlias(ctx, alias) {
|
|
16690
|
+
try {
|
|
16691
|
+
const { listCccs } = await import("./ccc-roots-DtiMVRPo.js").then((n) => n.a);
|
|
16692
|
+
const entries = await listCccs(ctx);
|
|
16693
|
+
const byName = entries.find((e) => e.name === alias);
|
|
16694
|
+
if (byName) return byName.root;
|
|
16695
|
+
const byRoot = entries.find((e) => e.root === alias);
|
|
16696
|
+
return byRoot ? byRoot.root : null;
|
|
16697
|
+
} catch {
|
|
16698
|
+
return null;
|
|
16699
|
+
}
|
|
16700
|
+
}
|
|
16701
|
+
/**
|
|
16702
|
+
* **入站路由的单一解析处**(P0-3):机器级订阅表优先 → CCC 级回退;三档语义都在这里落地。
|
|
16703
|
+
*
|
|
16704
|
+
* 🔴 为什么抽成一个函数而不是写两遍:**两种模式(CCC 起桥 / 机器级起 poller)走的是同一套
|
|
16705
|
+
* 判据**,差别只有一格 —— **有没有 CCC 级回退**(`cccFallback`)。写两遍必然分叉。
|
|
16706
|
+
*
|
|
16707
|
+
* 三档(方案 §3.1,**刻意分档**):
|
|
16708
|
+
* - **未命中** ⇒ 不投递 + **留痕**(`console.warn`,静默档:这是"表里没有你",不是错误)
|
|
16709
|
+
* - **表残缺** ⇒ **响亮出声**(`console.error`;且**独立于是否命中**)
|
|
16710
|
+
* - **目标不存在**(CCC / 角色)⇒ **响亮报错**(`console.error`;不静默丢弃)
|
|
16711
|
+
*
|
|
16712
|
+
* @param input.cccFallback 过渡期回退(方案 Q5);**机器级模式传 `null`** ⇒ 未命中即不投递
|
|
16713
|
+
*/
|
|
16714
|
+
async function resolveInboundTarget(ctx, input) {
|
|
16715
|
+
const route = planInboundRoute({
|
|
16716
|
+
subscriptions: readSimpleSettings().humanChannel.subscriptions,
|
|
16717
|
+
account: input.account,
|
|
16718
|
+
user: input.user,
|
|
16719
|
+
fallback: input.cccFallback ? {
|
|
16720
|
+
ccc: input.cccFallback.root,
|
|
16721
|
+
role: input.cccFallback.role
|
|
16722
|
+
} : null
|
|
16723
|
+
});
|
|
16724
|
+
for (const defect of route.defects) {
|
|
16725
|
+
console.error(`[serenity-hooks] ✗ human-channel: 订阅表第 ${defect.index + 1} 行**残缺**(缺 ${defect.missing.join(" / ")})|已有字段 ${JSON.stringify(defect.present)}|该行**不生效** —— 请补全或删掉(机器级 humanChannel.subscriptions)`);
|
|
16726
|
+
appendHumanChannelAudit({
|
|
16727
|
+
kind: "subscription-defect",
|
|
16728
|
+
account: input.account,
|
|
16729
|
+
index: defect.index,
|
|
16730
|
+
missing: defect.missing,
|
|
16731
|
+
present: defect.present
|
|
16732
|
+
});
|
|
16733
|
+
}
|
|
16734
|
+
const target = route.target;
|
|
16735
|
+
if (!target) {
|
|
16736
|
+
const detail = route.skipReason === "no-subscription" ? "机器级订阅表为空(且无 CCC 级回退)" : "订阅表里没有匹配 (账号, 用户) 的行";
|
|
16737
|
+
console.warn(`[serenity-hooks] ⚠ human-channel: 入站未命中订阅 ⇒ **不投递**(account=${input.account} user=${input.user}):${detail}`);
|
|
16738
|
+
appendHumanChannelAudit({
|
|
16739
|
+
kind: "inbound-unrouted",
|
|
16740
|
+
account: input.account,
|
|
16741
|
+
user: input.user,
|
|
16742
|
+
reason: route.skipReason ?? "no-match",
|
|
16743
|
+
detail
|
|
16744
|
+
});
|
|
16745
|
+
return null;
|
|
16746
|
+
}
|
|
16747
|
+
const root = route.source === "ccc-fallback" ? target.ccc : await resolveCccRootByAlias(ctx, target.ccc);
|
|
16748
|
+
if (root === null) {
|
|
16749
|
+
const detail = `订阅表指向的 CCC "${target.ccc}" 在本机 CCC 枚举里不存在`;
|
|
16750
|
+
console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表`);
|
|
16751
|
+
appendHumanChannelAudit({
|
|
16752
|
+
kind: "inbound-target-missing",
|
|
16753
|
+
account: input.account,
|
|
16754
|
+
user: input.user,
|
|
16755
|
+
ccc: target.ccc,
|
|
16756
|
+
role: target.role,
|
|
16757
|
+
detail
|
|
16758
|
+
});
|
|
16759
|
+
return null;
|
|
16760
|
+
}
|
|
16761
|
+
const role = readSkiffRoles(root).get(target.role);
|
|
16762
|
+
if (!role) {
|
|
16763
|
+
const detail = `目标 CCC "${target.ccc}" 未定义 skiff 角色 "${target.role}"`;
|
|
16764
|
+
console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表或该 CCC 的 skiff.roles`);
|
|
16765
|
+
appendHumanChannelAudit({
|
|
16766
|
+
kind: "inbound-target-missing",
|
|
16767
|
+
account: input.account,
|
|
16768
|
+
user: input.user,
|
|
16769
|
+
ccc: target.ccc,
|
|
16770
|
+
role: target.role,
|
|
16771
|
+
detail
|
|
16772
|
+
});
|
|
16773
|
+
return null;
|
|
16774
|
+
}
|
|
16775
|
+
return {
|
|
16776
|
+
root,
|
|
16777
|
+
roleName: target.role,
|
|
16778
|
+
role
|
|
16779
|
+
};
|
|
16780
|
+
}
|
|
16781
|
+
/**
|
|
16430
16782
|
* 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
|
|
16431
16783
|
*
|
|
16432
16784
|
* 会话语义:`weixinSessionIdFor(fromUserId)` 固定可重建——同用户长期同一会话;
|
|
@@ -16443,16 +16795,35 @@ function manualOutputFallbackNeeded(input) {
|
|
|
16443
16795
|
* `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
|
|
16444
16796
|
* 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
|
|
16445
16797
|
* 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
|
|
16798
|
+
*
|
|
16799
|
+
* 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
|
|
16800
|
+
* **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
|
|
16801
|
+
* **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
|
|
16802
|
+
* **不是**"投给默认角色"。
|
|
16446
16803
|
*/
|
|
16447
|
-
async function handleIncoming(ctx, root, accountId, cred, msg) {
|
|
16804
|
+
async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
|
|
16448
16805
|
const fromUserId = msg.from_user_id;
|
|
16449
16806
|
if (!fromUserId) return;
|
|
16450
16807
|
const text = extractWeixinText(msg);
|
|
16451
16808
|
const mediaRefs = extractWeixinMedia(msg);
|
|
16452
16809
|
const settings = readWeixinSettings(root);
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
|
|
16810
|
+
let resolved;
|
|
16811
|
+
if (preset) resolved = preset.target;
|
|
16812
|
+
else {
|
|
16813
|
+
if (!settings.enabled) return;
|
|
16814
|
+
const cccRole = matchWeixinRoute(settings.routes ?? [], fromUserId);
|
|
16815
|
+
resolved = await resolveInboundTarget(ctx, {
|
|
16816
|
+
account: accountId,
|
|
16817
|
+
user: fromUserId,
|
|
16818
|
+
cccFallback: cccRole ? {
|
|
16819
|
+
root,
|
|
16820
|
+
role: cccRole
|
|
16821
|
+
} : null
|
|
16822
|
+
});
|
|
16823
|
+
}
|
|
16824
|
+
if (!resolved) return;
|
|
16825
|
+
const { roleName, role } = resolved;
|
|
16826
|
+
root = resolved.root;
|
|
16456
16827
|
if (!text && mediaRefs.length === 0) {
|
|
16457
16828
|
if (hasVoiceItem(msg)) await sendTextMessage({
|
|
16458
16829
|
baseUrl: cred.baseUrl,
|
|
@@ -16464,11 +16835,6 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
|
|
|
16464
16835
|
return;
|
|
16465
16836
|
}
|
|
16466
16837
|
try {
|
|
16467
|
-
const role = readSkiffRoles(root).get(roleName);
|
|
16468
|
-
if (!role) {
|
|
16469
|
-
console.log(`[serenity-hooks] weixin-bridge: 路由命中角色 "${roleName}" 但该 CCC 未定义(检查 skiff.roles)`);
|
|
16470
|
-
return;
|
|
16471
|
-
}
|
|
16472
16838
|
const sessionId = weixinSessionIdFor(fromUserId);
|
|
16473
16839
|
const existing = getSkiffAgent(sessionId);
|
|
16474
16840
|
const hc = readHandymanConfig(root);
|
|
@@ -16628,6 +16994,31 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
|
|
|
16628
16994
|
}
|
|
16629
16995
|
}
|
|
16630
16996
|
/**
|
|
16997
|
+
* **机器级 poller 的单条消息入口**(P0-3 A)。
|
|
16998
|
+
*
|
|
16999
|
+
* 与 `handleIncoming` 的唯一差别:**目标由机器级订阅表逐条解析,且没有 CCC 级回退**
|
|
17000
|
+
* (方案 §3.1:机器级模式下**未命中 = 不投递**,不是"投给默认角色")。
|
|
17001
|
+
* 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset` ⇒ 它不再自行路由、
|
|
17002
|
+
* 也不看目标 CCC 的 `weixin.enabled`)。
|
|
17003
|
+
*
|
|
17004
|
+
* 🔴 为什么"起 poller"与"路由"必须同批(本件即 P0-2 的后半):机器级 poller 收到消息时
|
|
17005
|
+
* **不知道它属于哪个 CCC** —— 那个答案是订阅表给的。没有订阅表 ⇒ **能收不能投**。
|
|
17006
|
+
*
|
|
17007
|
+
* 🔵 **导出供测试直接驱动**(与 `handleIncoming` 同款:它俩都是"单条消息的入口",
|
|
17008
|
+
* 生产路径 `runAccountLoop` 只是循环壳)。
|
|
17009
|
+
*/
|
|
17010
|
+
async function handleMachineIncoming(ctx, accountId, cred, msg) {
|
|
17011
|
+
const fromUserId = msg.from_user_id;
|
|
17012
|
+
if (!fromUserId) return;
|
|
17013
|
+
const target = await resolveInboundTarget(ctx, {
|
|
17014
|
+
account: accountId,
|
|
17015
|
+
user: fromUserId,
|
|
17016
|
+
cccFallback: null
|
|
17017
|
+
});
|
|
17018
|
+
if (!target) return;
|
|
17019
|
+
await handleIncoming(ctx, target.root, accountId, cred, msg, { target });
|
|
17020
|
+
}
|
|
17021
|
+
/**
|
|
16631
17022
|
* 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
|
|
16632
17023
|
* 已存在(配置变化热重建)→ 先停旧循环再启动新的。
|
|
16633
17024
|
*
|
|
@@ -16689,13 +17080,118 @@ function stopCccBridge(root) {
|
|
|
16689
17080
|
bridges.delete(root);
|
|
16690
17081
|
}
|
|
16691
17082
|
/**
|
|
16692
|
-
*
|
|
17083
|
+
* 停掉**全部 CCC 级桥**(不碰机器级 poller)+ 释放它们各自的账号归属。
|
|
17084
|
+
* 🔴 模式切换时必须先调它:否则同一个 bot 会被**两处**轮询(方案 §2.6 要消灭的正是这个)。
|
|
17085
|
+
*/
|
|
17086
|
+
function stopCccBridges() {
|
|
17087
|
+
for (const root of [...bridges.keys()]) stopCccBridge(root);
|
|
17088
|
+
}
|
|
17089
|
+
/** 停掉**机器级 poller**(不碰 CCC 级桥)+ 释放机器级归属 */
|
|
17090
|
+
function stopMachineLoops() {
|
|
17091
|
+
for (const loop of machineLoops.values()) loop.stopped = true;
|
|
17092
|
+
machineLoops.clear();
|
|
17093
|
+
releasePollersOf(MACHINE_SCOPE);
|
|
17094
|
+
}
|
|
17095
|
+
/**
|
|
17096
|
+
* 停止全部桥(插件 dispose):CCC 级 + 机器级
|
|
16693
17097
|
*/
|
|
16694
17098
|
function stopAllBridges() {
|
|
16695
|
-
|
|
16696
|
-
|
|
17099
|
+
stopCccBridges();
|
|
17100
|
+
stopMachineLoops();
|
|
16697
17101
|
releaseAllPollers();
|
|
16698
17102
|
}
|
|
17103
|
+
/** 已告警过的"未实现渠道"(避免每次 session/created 都刷一行) */
|
|
17104
|
+
const warnedChannels = /* @__PURE__ */ new Set();
|
|
17105
|
+
/**
|
|
17106
|
+
* **机器级模式**:按**账号**起 poller(方案 §2.6「一账号一 poller」的正面形态)。
|
|
17107
|
+
*
|
|
17108
|
+
* 与 CCC 模式的差别:poller **不属于任何 CCC** —— 消息投到哪由**订阅表逐条决定**
|
|
17109
|
+
* (见 `handleMachineIncoming`)。凭据来自机器级配置本身(方案 Q3:凭据落插件 Config)。
|
|
17110
|
+
*
|
|
17111
|
+
* 🔴 进入本函数即**先停掉全部 CCC 级桥**:两种模式**互斥**(同时跑 = 同一 bot 两处轮询)。
|
|
17112
|
+
*/
|
|
17113
|
+
function syncMachinePollers(ctx, hc) {
|
|
17114
|
+
stopCccBridges();
|
|
17115
|
+
const requests = [];
|
|
17116
|
+
const creds = /* @__PURE__ */ new Map();
|
|
17117
|
+
for (const account of hc.accounts) {
|
|
17118
|
+
const id = account.id;
|
|
17119
|
+
if (account.enabled === false) continue;
|
|
17120
|
+
if (!id) {
|
|
17121
|
+
console.warn("[serenity-hooks] ⚠ human-channel: 机器级账号表有一行**缺 `id`** ⇒ 跳过(该行无法被订阅表引用)");
|
|
17122
|
+
continue;
|
|
17123
|
+
}
|
|
17124
|
+
if (account.channel !== "weixin") {
|
|
17125
|
+
if (!warnedChannels.has(`${id}:${account.channel ?? ""}`)) {
|
|
17126
|
+
warnedChannels.add(`${id}:${account.channel ?? ""}`);
|
|
17127
|
+
console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} 的渠道 "${account.channel ?? "(未填)"}" **本机未实现** ⇒ 不起 poller(当前只有 weixin)`);
|
|
17128
|
+
}
|
|
17129
|
+
continue;
|
|
17130
|
+
}
|
|
17131
|
+
const token = (account.token ?? "").trim();
|
|
17132
|
+
if (token === "") {
|
|
17133
|
+
console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} **无 token**(未绑定)⇒ 不起 poller`);
|
|
17134
|
+
continue;
|
|
17135
|
+
}
|
|
17136
|
+
creds.set(id, {
|
|
17137
|
+
token,
|
|
17138
|
+
baseUrl: "",
|
|
17139
|
+
userId: account.userId
|
|
17140
|
+
});
|
|
17141
|
+
requests.push({
|
|
17142
|
+
root: MACHINE_SCOPE,
|
|
17143
|
+
accountId: id,
|
|
17144
|
+
identity: accountIdentity(MACHINE_SCOPE, id, token)
|
|
17145
|
+
});
|
|
17146
|
+
}
|
|
17147
|
+
const plan = planPollerClaims(requests, pollerOwners());
|
|
17148
|
+
for (const { request, ownedBy } of plan.skipped) console.warn(`[serenity-hooks] ⚠ human-channel: 机器级账号 ${request.accountId} 与 ${ownedBy.accountId} **绑的是同一个 bot** ⇒ 本处不重复起循环(一账号一 poller;方案 §2.6)`);
|
|
17149
|
+
stopMachineLoops();
|
|
17150
|
+
for (const claim of plan.claims) {
|
|
17151
|
+
const cred = creds.get(claim.accountId);
|
|
17152
|
+
if (!cred) continue;
|
|
17153
|
+
const loop = {
|
|
17154
|
+
stopped: false,
|
|
17155
|
+
lastPollAt: 0
|
|
17156
|
+
};
|
|
17157
|
+
machineLoops.set(claim.accountId, loop);
|
|
17158
|
+
runAccountLoop(ctx, MACHINE_SCOPE, claim.accountId, cred, loop, true);
|
|
17159
|
+
}
|
|
17160
|
+
commitPollerClaims(plan);
|
|
17161
|
+
if (machineLoops.size > 0) console.log(`[serenity-hooks] ✓ human-channel: 机器级 poller accounts=${[...machineLoops.keys()].join(",")}(与 CCC 无关)`);
|
|
17162
|
+
}
|
|
17163
|
+
/**
|
|
17164
|
+
* 人机信道的**装配入口**(P0-3 A)—— 决定用哪种模式起 poller。
|
|
17165
|
+
*
|
|
17166
|
+
* 🔴 **模式由机器级账号表决定**(方案 §11.3 **Q5** 的过渡期语义):
|
|
17167
|
+
* - 账号表**非空** ⇒ **机器级模式**:按账号起 poller;路由**只认订阅表**
|
|
17168
|
+
* - 账号表**为空** ⇒ **回退模式**(今天的形态):按 CCC 起桥;路由**先查订阅表,未命中回退
|
|
17169
|
+
* 到 CCC 级 routes** ⇒ **不打断在用的微信**
|
|
17170
|
+
* ⚠️ 两种模式**互斥**(各自进入时先停掉另一种),否则同一个 bot 会被两处轮询。
|
|
17171
|
+
*/
|
|
17172
|
+
function syncHumanChannel(ctx) {
|
|
17173
|
+
let hc;
|
|
17174
|
+
try {
|
|
17175
|
+
hc = readSimpleSettings().humanChannel;
|
|
17176
|
+
} catch (err) {
|
|
17177
|
+
console.warn(`[serenity-hooks] ✗ human-channel: 机器级配置读取失败 ⇒ 按"未配置"处理(回退按 CCC 起桥): ${String(err?.message ?? err)}`);
|
|
17178
|
+
}
|
|
17179
|
+
if (hc && hc.accounts.length > 0) {
|
|
17180
|
+
try {
|
|
17181
|
+
syncMachinePollers(ctx, hc);
|
|
17182
|
+
} catch (err) {
|
|
17183
|
+
console.warn(`[serenity-hooks] ✗ human-channel: 机器级 poller 装配失败: ${String(err?.message ?? err)}`);
|
|
17184
|
+
}
|
|
17185
|
+
return;
|
|
17186
|
+
}
|
|
17187
|
+
(async () => {
|
|
17188
|
+
try {
|
|
17189
|
+
stopMachineLoops();
|
|
17190
|
+
const { listCccs } = await import("./ccc-roots-DtiMVRPo.js").then((n) => n.a);
|
|
17191
|
+
for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
|
|
17192
|
+
} catch {}
|
|
17193
|
+
})();
|
|
17194
|
+
}
|
|
16699
17195
|
/**
|
|
16700
17196
|
* 解析该 CCC 的发送账号 + 凭据(v1.31.0:文本与文件发送的**单一账号选择真相源**)。
|
|
16701
17197
|
* 顺序:桥启用 → 有启用账号 → 指定账号存在 → 凭据已绑定(扫码)。
|
|
@@ -16778,35 +17274,37 @@ async function sendProactiveText(input) {
|
|
|
16778
17274
|
role
|
|
16779
17275
|
};
|
|
16780
17276
|
}
|
|
16781
|
-
/**
|
|
17277
|
+
/**
|
|
17278
|
+
* 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康。
|
|
17279
|
+
* 🔴 **P0-3 A 起多一行**:机器级 poller 以 `ccc: '<machine>'`(`MACHINE_SCOPE`)出现 ——
|
|
17280
|
+
* 它**不属于任何 CCC**,故**不伪装成某个 CCC 的行**(面板按原样显示这个哨兵值即可)。
|
|
17281
|
+
*/
|
|
16782
17282
|
function weixinBridgeStatus() {
|
|
16783
17283
|
const out = [];
|
|
17284
|
+
const toAccounts = (loops) => [...loops.entries()].map(([accountId, loop]) => ({
|
|
17285
|
+
accountId,
|
|
17286
|
+
lastPollAt: loop.lastPollAt,
|
|
17287
|
+
...loop.lastError ? { lastError: loop.lastError } : {}
|
|
17288
|
+
}));
|
|
16784
17289
|
for (const [root, bridge] of bridges) out.push({
|
|
16785
17290
|
ccc: root,
|
|
16786
|
-
accounts:
|
|
16787
|
-
|
|
16788
|
-
|
|
16789
|
-
|
|
16790
|
-
|
|
17291
|
+
accounts: toAccounts(bridge.loops)
|
|
17292
|
+
});
|
|
17293
|
+
if (machineLoops.size > 0) out.push({
|
|
17294
|
+
ccc: MACHINE_SCOPE,
|
|
17295
|
+
accounts: toAccounts(machineLoops)
|
|
16791
17296
|
});
|
|
16792
17297
|
return out;
|
|
16793
17298
|
}
|
|
16794
17299
|
/**
|
|
16795
|
-
* 装配(index.ts
|
|
16796
|
-
*
|
|
17300
|
+
* 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
|
|
17301
|
+
* 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
|
|
17302
|
+
* 事件驱动(对齐 autotrajectory v1.26.15)。
|
|
16797
17303
|
*/
|
|
16798
17304
|
function registerWeixinBridge(ctx) {
|
|
16799
|
-
|
|
16800
|
-
(async () => {
|
|
16801
|
-
try {
|
|
16802
|
-
const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
|
|
16803
|
-
for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
|
|
16804
|
-
} catch {}
|
|
16805
|
-
})();
|
|
16806
|
-
};
|
|
16807
|
-
syncFromLive();
|
|
17305
|
+
syncHumanChannel(ctx);
|
|
16808
17306
|
try {
|
|
16809
|
-
ctx.on("session/created", () =>
|
|
17307
|
+
ctx.on("session/created", () => syncHumanChannel(ctx));
|
|
16810
17308
|
} catch {}
|
|
16811
17309
|
}
|
|
16812
17310
|
//#endregion
|
|
@@ -16925,7 +17423,7 @@ function matchCcc(input, candidates) {
|
|
|
16925
17423
|
}
|
|
16926
17424
|
/** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
|
|
16927
17425
|
async function collectCandidates(ctx) {
|
|
16928
|
-
const { listCccs } = await import("./ccc-roots-
|
|
17426
|
+
const { listCccs } = await import("./ccc-roots-DtiMVRPo.js").then((n) => n.a);
|
|
16929
17427
|
const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
|
|
16930
17428
|
const seen = /* @__PURE__ */ new Set();
|
|
16931
17429
|
const out = [];
|
|
@@ -18776,6 +19274,843 @@ function createAccDiagTool(ctx) {
|
|
|
18776
19274
|
});
|
|
18777
19275
|
}
|
|
18778
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
|
|
18779
20114
|
//#region src/host/storage-domain.ts
|
|
18780
20115
|
/**
|
|
18781
20116
|
* host/storage-domain.ts — 宿主存储域上的「会话 ↔ 轨迹」绑定表(§0L,S142 2026-09-19)
|
|
@@ -18988,6 +20323,7 @@ const Config = z.object({
|
|
|
18988
20323
|
webFetch: z.object({ enabled: z.boolean().default(true) }),
|
|
18989
20324
|
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
|
|
18990
20325
|
visionPatch: z.object({ enabled: z.boolean().default(true) }),
|
|
20326
|
+
diagram: z.object({ enabled: z.boolean().default(false) }),
|
|
18991
20327
|
humanChannel: z.object({
|
|
18992
20328
|
accounts: z.array(humanChannelAccountSchema).default([]),
|
|
18993
20329
|
subscriptions: z.array(humanChannelSubscriptionSchema).default([]),
|
|
@@ -19002,7 +20338,8 @@ const Config = z.object({
|
|
|
19002
20338
|
acpHttpPort: z.number().min(1024).max(65535).volatile(),
|
|
19003
20339
|
publicAskEnabled: z.boolean().volatile(),
|
|
19004
20340
|
croEnabled: z.boolean().volatile(),
|
|
19005
|
-
unattendedEnabled: z.boolean().volatile()
|
|
20341
|
+
unattendedEnabled: z.boolean().volatile(),
|
|
20342
|
+
diagramEnabled: z.boolean().volatile()
|
|
19006
20343
|
});
|
|
19007
20344
|
function apply(ctx, config) {
|
|
19008
20345
|
try {
|
|
@@ -19051,6 +20388,7 @@ function apply(ctx, config) {
|
|
|
19051
20388
|
registerVoyageApi(ctx);
|
|
19052
20389
|
}
|
|
19053
20390
|
registerSettingsSection(ctx, config);
|
|
20391
|
+
if (config.tools) registerDiagramTool(ctx);
|
|
19054
20392
|
registerGateway(ctx);
|
|
19055
20393
|
registerRebuildTurnHook(ctx);
|
|
19056
20394
|
registerOutputGuardHook(ctx);
|
|
@@ -19071,6 +20409,26 @@ function apply(ctx, config) {
|
|
|
19071
20409
|
if (config.visionPatch?.enabled !== false) registerDeepseekVisionPatch(ctx, true);
|
|
19072
20410
|
}
|
|
19073
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
|
+
/**
|
|
19074
20432
|
* F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
|
|
19075
20433
|
* settings-changed 事件触发同步(skiffEnabled 开 → 启动调试服务;关 → 停止)。
|
|
19076
20434
|
* 角色配置(skiff.roles)从当前 CCC 根读取(含 skiff.roles 的 CCC 优先,进程 cwd 次之)。
|