文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
This commit is contained in:
1 parent
3d8f50e366
commit
e6207aa691
239 files changed
+34477
-14633
No files matched your search
@@ -0,0 +1,8 @@
|
||||
// @dsh-local/im-plugin-mud — bundle patch (host row only)
|
||||
//
|
||||
// D 单示例插件之二(MUD)。走**实时档**(档案 108 游戏口径,jitter 预算 20 ms),
|
||||
// 发言规则 = 限速。⛔ 无 client 面。
|
||||
|
||||
- insert:
|
||||
- id: im-plugin-mud
|
||||
name: '@dsh-local/im-plugin-mud'
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"@dsh-local/im-plugin-mud","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @dsh-local/im-plugin-office — bundle patch (host row only)
|
||||
//
|
||||
// D 单示例插件之一(办公群)。⛔ 无 client 面:本插件只在**服务端**把房间类型 +
|
||||
// 任务卡载荷 + 面板插槽声明给 IM SDK,UI 成品在 E 单。
|
||||
|
||||
- insert:
|
||||
- id: im-plugin-office
|
||||
name: '@dsh-local/im-plugin-office'
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dsh-local/im-plugin-office",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @dsh-local/im-plugin-trpg — bundle patch (host row only)
|
||||
//
|
||||
// D 单示例插件之三(跑团)。发言规则 = **回合制**(非当前回合者发言被拒),
|
||||
// 含角色卡表与 DM/玩家业务角色。⛔ 无 client 面。
|
||||
|
||||
- insert:
|
||||
- id: im-plugin-trpg
|
||||
name: '@dsh-local/im-plugin-trpg'
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"@dsh-local/im-plugin-trpg","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
|
||||
@@ -0,0 +1,23 @@
|
||||
# @dsh-local/carbon-mcp-probe — bundle patch(host 半区)
|
||||
#
|
||||
# 本 POC 的**全部挂载动作**都在这两行里:
|
||||
# ① 载入本包自己的 host 半区(lib/index.js,探针)
|
||||
# ② insert 一条**内核** mcp-client entry,用 streamable-http 连远程 MCP server
|
||||
#
|
||||
# name 解析:profile 文档规定 bundle 名**先从 dsh 安装目录解析**,再从 profile 目录
|
||||
# ⇒ 业务包无需自带 @deepseek-ai/dsh-mcp-client 依赖即可引用内核插件。
|
||||
#
|
||||
# ⛔ 生产形态里 url 不该硬编码在这里(应来自实例 env / 平台注入);
|
||||
# 本文件只服务 POC,值是本机 loopback 上的 stub。
|
||||
|
||||
- insert:
|
||||
- id: carbon-mcp-probe
|
||||
name: '@dsh-local/carbon-mcp-probe'
|
||||
- id: carbon-mcp-probe-server
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
transport: streamable-http
|
||||
serverName: carbonprobe
|
||||
url: 'http://127.0.0.1:39877/mcp'
|
||||
toolCallTimeoutMs: 15000
|
||||
failOnStartupError: true
|
||||
@@ -0,0 +1,7 @@
|
||||
// @dsh-local/carbon-mcp-probe — client 半区(POC)
|
||||
// 本轮问题**只涉及 host 半区**(MCP 工具能否暴露给 agent),
|
||||
// 故 client 侧刻意留空 apply:不做任何界面、不注入任何槽位。
|
||||
|
||||
export function apply() {
|
||||
// 空 apply —— 见文件头说明
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// @dsh-local/carbon-mcp-probe — host 半区(POC 探针)
|
||||
//
|
||||
// 本包在 host 侧只做一件事:**取证**。
|
||||
// 真正的 MCP 挂载不在这里写代码,而是由同目录的 cordis.patch.yml
|
||||
// insert 一条内核 `@deepseek-ai/dsh-mcp-client` entry 完成 —— 这正是本轮要验证的契约。
|
||||
//
|
||||
// 探针把「boot 后 ctx.tools 注册表里到底有没有 MCP 工具」落成 JSON 证据文件,
|
||||
// 使验收不依赖 agent / 模型 / 界面。
|
||||
|
||||
import { appendFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const DUMP = process.env.CARBON_PROBE_DUMP || "/tmp/carbon-probe-dump.json";
|
||||
const LOG = process.env.CARBON_PROBE_LOG || "/tmp/carbon-probe-host.log";
|
||||
const DELAY_MS = Number(process.env.CARBON_PROBE_DELAY_MS || 8000);
|
||||
|
||||
function log(line) {
|
||||
const row = `${new Date().toISOString()} ${line}\n`;
|
||||
try { appendFileSync(LOG, row); } catch { /* 日志不可写不致命 */ }
|
||||
process.stdout.write(`[carbon-mcp-probe] ${row}`);
|
||||
}
|
||||
|
||||
/** 在有界预算内 BFS 一个对象图,收集所有含 needle 的键/字符串路径。 */
|
||||
function bfsFind(root, needle, maxDepth = 5, maxNodes = 600) {
|
||||
const seen = new Set();
|
||||
const hits = [];
|
||||
const queue = [[root, "$", 0]];
|
||||
let visited = 0;
|
||||
while (queue.length && visited < maxNodes) {
|
||||
const [value, path, depth] = queue.shift();
|
||||
if (value === null || value === undefined) continue;
|
||||
const t = typeof value;
|
||||
if (t !== "object" && t !== "function") {
|
||||
if (t === "string" && value.includes(needle)) hits.push({ path, kind: "string", value: value.slice(0, 160) });
|
||||
continue;
|
||||
}
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
visited += 1;
|
||||
|
||||
let keys = [];
|
||||
try { keys = Object.getOwnPropertyNames(value); } catch { continue; }
|
||||
for (const k of keys) {
|
||||
if (k.includes(needle)) hits.push({ path: `${path}.${k}`, kind: "KEY" });
|
||||
if (depth + 1 > maxDepth) continue;
|
||||
let child;
|
||||
try { child = value[k]; } catch { continue; } // getter 抛错就跳过,绝不冒泡
|
||||
const childPath = `${path}.${k}`;
|
||||
if (child instanceof Map) {
|
||||
let i = 0;
|
||||
for (const [mk, mv] of child) {
|
||||
const mp = `${childPath}{Map}["${String(mk)}"]`;
|
||||
if (String(mk).includes(needle)) hits.push({ path: mp, kind: "MAPKEY" });
|
||||
if (i++ > 250) break;
|
||||
queue.push([mv, mp, depth + 1]);
|
||||
}
|
||||
} else if (Array.isArray(child)) {
|
||||
child.slice(0, 60).forEach((v, i) => queue.push([v, `${childPath}[${i}]`, depth + 1]));
|
||||
} else if (typeof child === "string") {
|
||||
if (child.includes(needle)) hits.push({ path: childPath, kind: "string", value: child.slice(0, 160) });
|
||||
} else if (child !== null && typeof child === "object") {
|
||||
queue.push([child, childPath, depth + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hits, visited, truncated: queue.length > 0 };
|
||||
}
|
||||
|
||||
function probeTools(ctx) {
|
||||
const rec = { ts: new Date().toISOString(), pid: process.pid };
|
||||
|
||||
// 通道 A:直接属性(未声明 inject 时应报错 —— 这本身是契约事实)
|
||||
try {
|
||||
const direct = ctx.tools;
|
||||
rec.channelA = { ok: true, type: typeof direct };
|
||||
} catch (e) {
|
||||
rec.channelA = { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
// 通道 B:ctx.get(name)
|
||||
let tools = null;
|
||||
try {
|
||||
tools = typeof ctx.get === "function" ? ctx.get("tools") : null;
|
||||
rec.channelB = { ok: !!tools, name: tools && tools.name };
|
||||
} catch (e) {
|
||||
rec.channelB = { ok: false, error: String(e) };
|
||||
}
|
||||
if (!tools) return rec;
|
||||
|
||||
// 通道 C:已知候选方法(不预设签名,逐个试)
|
||||
rec.channelC = {};
|
||||
for (const m of ["list", "view", "schemas", "sdkSchemas", "get", "names"]) {
|
||||
try {
|
||||
if (typeof tools[m] !== "function") { rec.channelC[m] = { skipped: "not a function" }; continue; }
|
||||
const v = typeof tools[m] === "function" ? tools[m]() : undefined;
|
||||
if (v && typeof v.then === "function") { rec.channelC[m] = { async: true }; continue; }
|
||||
rec.channelC[m] = { type: typeof v, preview: JSON.stringify(v).slice(0, 400) };
|
||||
} catch (e) {
|
||||
rec.channelC[m] = { threw: String(e).slice(0, 200) };
|
||||
}
|
||||
}
|
||||
|
||||
// 通道 D:有界 BFS —— 不依赖任何 API 形状,直接找 mcp__ 命名
|
||||
try {
|
||||
rec.channelD = bfsFind(tools, "mcp__");
|
||||
rec.hasMcpQualified = rec.channelD.hits.length > 0;
|
||||
} catch (e) {
|
||||
rec.channelD = { error: String(e) };
|
||||
rec.hasMcpQualified = false;
|
||||
}
|
||||
|
||||
// 通道 E:同上,只要 carbonprobe 片段(防命名前缀差异)
|
||||
try {
|
||||
rec.channelE = bfsFind(tools, "carbonprobe", 5, 600);
|
||||
} catch (e) {
|
||||
rec.channelE = { error: String(e) };
|
||||
}
|
||||
|
||||
return rec;
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
log(`apply pid=${process.pid} node=${process.version} dump=${DUMP}`);
|
||||
const timer = setTimeout(() => {
|
||||
let rec;
|
||||
try { rec = probeTools(ctx); }
|
||||
catch (e) { rec = { ts: new Date().toISOString(), fatal: String(e) }; }
|
||||
try { writeFileSync(DUMP, JSON.stringify(rec, null, 2)); log(`dump written -> ${DUMP}`); }
|
||||
catch (e) { log(`dump write failed: ${e}`); }
|
||||
log(`SUMMARY hasMcpQualified=${rec.hasMcpQualified} D.hits=${JSON.stringify(rec.channelD?.hits || []).slice(0, 800)}`);
|
||||
}, DELAY_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@dsh-local/carbon-mcp-probe",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "POC(非正式包):验证 dsh 0.1.5-rc.1 业务插件的 host 半区能否把远程 HTTP MCP server 的工具暴露给 agent。仅用于回答契约问题,不承载任何业务功能。",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": []
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh": "^0.1.5-rc.1"
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* carbon MCP probe — stub MCP server(零依赖单文件)
|
||||
*
|
||||
* 用途:只回答「dsh 的 mcp-client 能否连上并同步工具」这一个问题。
|
||||
* 不实现任何业务语义,不做任何外部访问。
|
||||
*
|
||||
* 用法:
|
||||
* node stub-mcp-server.mjs # stdio(默认)
|
||||
* node stub-mcp-server.mjs --http 39877 # Streamable HTTP
|
||||
*
|
||||
* 环境变量:
|
||||
* PROBE_LOG 请求日志落点(默认 ./stub-mcp.log)
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import { appendFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOG = process.env.PROBE_LOG || "./stub-mcp.log";
|
||||
const TOOL_NAME = "ping";
|
||||
const PONG_PREFIX = "pong-carbon-probe";
|
||||
|
||||
try { writeFileSync(LOG, ""); } catch { /* 日志不可写不致命 */ }
|
||||
|
||||
function log(line) {
|
||||
const row = `${new Date().toISOString()} ${line}\n`;
|
||||
try { appendFileSync(LOG, row); } catch { /* ignore */ }
|
||||
process.stderr.write(row);
|
||||
}
|
||||
|
||||
const PING_TOOL = {
|
||||
name: TOOL_NAME,
|
||||
description: "carbon MCP probe stub — 返回固定串与服务端时间",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
};
|
||||
|
||||
function callTool(params) {
|
||||
if (!params || params.name !== TOOL_NAME) {
|
||||
return { content: [{ type: "text", text: `unknown tool: ${params && params.name}` }], isError: true };
|
||||
}
|
||||
const ts = new Date().toISOString();
|
||||
return { content: [{ type: "text", text: `${PONG_PREFIX} ${ts}` }] };
|
||||
}
|
||||
|
||||
/** 处理一条 JSON-RPC message,返回 response 或 null(notification)。 */
|
||||
function handle(msg) {
|
||||
const method = msg && msg.method;
|
||||
const id = msg && msg.id;
|
||||
log(`rpc method=${method} id=${id === undefined ? "-" : id}`);
|
||||
if (method === "initialize") {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: msg.params && msg.params.protocolVersion ? msg.params.protocolVersion : "2025-06-18",
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "carbon-probe-stub", version: "0.0.1" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "notifications/initialized" || String(method || "").startsWith("notifications/")) return null;
|
||||
if (method === "ping") return { jsonrpc: "2.0", id, result: {} };
|
||||
if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: [PING_TOOL] } };
|
||||
if (method === "tools/call") return { jsonrpc: "2.0", id, result: callTool(msg.params) };
|
||||
if (id === undefined) return null;
|
||||
return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
|
||||
}
|
||||
|
||||
function dispatch(payload) {
|
||||
const msgs = Array.isArray(payload) ? payload : [payload];
|
||||
const out = [];
|
||||
for (const m of msgs) {
|
||||
const r = handle(m);
|
||||
if (r !== null) out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------------- Streamable HTTP ---------------- */
|
||||
|
||||
function startHttp(port) {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url.startsWith("/health")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, server: "carbon-probe-stub" }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
req.on("data", (c) => { body += c.toString("utf8"); });
|
||||
req.on("end", () => {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch (e) {
|
||||
log(`http parse-error ${e.message} raw=${body.slice(0, 200)}`);
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }));
|
||||
return;
|
||||
}
|
||||
const out = dispatch(payload);
|
||||
if (out.length === 0) {
|
||||
log("http -> 202 (notification only)");
|
||||
res.writeHead(202, { "Mcp-Session-Id": "probe-session-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const payloadOut = out.length === 1 ? out[0] : out;
|
||||
log(`http -> 200 ${JSON.stringify(payloadOut).slice(0, 300)}`);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Mcp-Session-Id": "probe-session-1",
|
||||
});
|
||||
res.end(JSON.stringify(payloadOut));
|
||||
});
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
log(`stub MCP server listening on http://127.0.0.1:${port}/mcp (transport=streamable-http)`);
|
||||
});
|
||||
server.on("error", (e) => { log(`listen error ${e.message}`); process.exit(1); });
|
||||
}
|
||||
|
||||
/* ---------------- stdio ---------------- */
|
||||
|
||||
function startStdio() {
|
||||
log("stub MCP server on stdio (transport=stdio)");
|
||||
let buf = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); } catch (e) { log(`stdio parse-error ${e.message}`); continue; }
|
||||
const out = dispatch(msg);
|
||||
for (const r of out) process.stdout.write(JSON.stringify(r) + "\n");
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", () => process.exit(0));
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const httpFlag = argv.indexOf("--http");
|
||||
if (httpFlag >= 0) {
|
||||
const port = Number(argv[httpFlag + 1] || 39877);
|
||||
startHttp(port);
|
||||
} else {
|
||||
startStdio();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# @dsh-local/im-agent-bridge — bundle patch (host row only)
|
||||
#
|
||||
# C 单(`交接单/IM群组-C-Agent接入插件.md §三`):实例侧接入 —— 拨出连接、触发过滤、
|
||||
# 上下文组装、预算/静默期、回写消息、重连退避。
|
||||
#
|
||||
# ⚠️ 本插件**只在服务端**(无 client 面):agent 是一个实例内的 node 进程里跑的连接,
|
||||
# 没有 UI。故 `dsh.bundle.patch` 即可,⛔ 不声明 `dsh.client`。
|
||||
#
|
||||
# 🔴 **天然开关**:`apply()` 首件事是读 `DSH_PLATFORM_IM_URL` 与 `DSH_IM_INSTANCE_TOKEN`;
|
||||
# 任一缺失 ⇒ 直接 return(日志无连接尝试)。⇒ 平台侧回滚 = 删掉注入的两项 env。
|
||||
|
||||
- insert:
|
||||
- id: im-agent-bridge
|
||||
name: '@dsh-local/im-agent-bridge'
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* @dsh-local/im-agent-bridge — host 插件(C 单 · `交接单/IM群组-C-Agent接入插件.md`)。
|
||||
*
|
||||
* ## 它做什么
|
||||
* 实例内起一条**只拨出**的 WS 到平台 IM 端点,收到触发后组装上下文、交给本地 dsh
|
||||
* 会话、把回答写回房间。四条硬约束与两种形态的实现全在**平台侧**的
|
||||
* `src/im/agent-bridge.ts`(纯逻辑,可在平台测试里直接断言);本文件只负责
|
||||
* 「**连接 + 搬运**」:
|
||||
*
|
||||
* ```
|
||||
* 平台 ──(WS 推送 /messages)──▶ 本插件 ──(组装上下文)──▶ 本地 dsh 会话
|
||||
* ▲ │
|
||||
* └────(op:'send' 回写, via='agent')◀───────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* ## 🔴 五条纪律
|
||||
* ① **只拨出**(E3):本文件**不开任何监听端口**(`ss -lntp` 无新增);
|
||||
* ② **不引入依赖**:WS 握手与帧编解码在此文件手写(与平台 `src/im/ws.ts` 同一套最小子集);
|
||||
* ③ **天然开关**:`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN` 任一缺失 ⇒ 不起连接;
|
||||
* ④ **故障隔离**(§五-7):任何异常都只影响本插件自己的连接,⛔ 不向上抛;
|
||||
* ⑤ **重连退避**(§五-2):断线后指数退避(1s→2s→…→上限 30s,带抖动),30 s 内自动恢复。
|
||||
*
|
||||
* ⚠️ 本文件目前把「本地 dsh 会话」抽象成可注入的 `askLocalSession()` —— 默认实现是
|
||||
* **回显式占位**(返回一条说明文本),因为真正的对接点(把 prompt 塞进哪个 dsh 会话)
|
||||
* 属 **D 单**的扩展点契约面(§八-5 明确问"是否需先做 D 单")。⇒ 本单保证的是
|
||||
* **触发 / 上下文 / 预算 / 回写 / 隔离**五条判据可验;LLM 那一跳留给 D 单接线。
|
||||
*
|
||||
* @module @dsh-local/im-agent-bridge
|
||||
*/
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { connect } from 'node:net'
|
||||
|
||||
/** 平台 IM 端点(env:`DSH_PLATFORM_IM_URL`,形如 `https://ai1net.com`)。 */
|
||||
const ENV_URL = 'DSH_PLATFORM_IM_URL'
|
||||
/** 实例凭据(env:`DSH_IM_INSTANCE_TOKEN`,`per-instance` 短期 token)。 */
|
||||
const ENV_TOKEN = 'DSH_IM_INSTANCE_TOKEN'
|
||||
/** 本 agent 的引用标识(env:`DSH_IM_AGENT_REF`;缺省 = 用实例 id 兜底)。 */
|
||||
const ENV_AGENT_REF = 'DSH_IM_AGENT_REF'
|
||||
|
||||
/** 重连退避:首延迟 / 上限 / 抖动系数(§五-2 要求"30 s 内自动恢复")。 */
|
||||
export const RECONNECT_FIRST_MS = 1000
|
||||
export const RECONNECT_MAX_MS = 30_000
|
||||
/** 排空 / 停机的宽限(`close()` 时给在途帧的时间)。 */
|
||||
const CLOSE_GRACE_MS = 200
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
const OP_TEXT = 0x1
|
||||
const OP_CLOSE = 0x8
|
||||
const OP_PING = 0x9
|
||||
const OP_PONG = 0xa
|
||||
|
||||
/** 插件内日志(写 `<DSH_HOME>/.dsh-im-agent.log`;⛔ 绝不写 token)。 */
|
||||
function makeLog(home) {
|
||||
const file = join(home, '.dsh-im-agent.log')
|
||||
return (line) => {
|
||||
try {
|
||||
appendFileSync(file, `${new Date().toISOString()} ${line}\n`)
|
||||
} catch {
|
||||
// 日志写不动⛔绝不拖垮插件
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── WS 最小子集(客户端侧:帧**必须掩码**)────────────────────────────────── */
|
||||
|
||||
function encodeClientFrame(opcode, payload) {
|
||||
const len = payload.length
|
||||
let header
|
||||
if (len < 126) {
|
||||
header = Buffer.alloc(2)
|
||||
header[1] = 0x80 | len
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4)
|
||||
header[1] = 0x80 | 126
|
||||
header.writeUInt16BE(len, 2)
|
||||
} else {
|
||||
header = Buffer.alloc(10)
|
||||
header[1] = 0x80 | 127
|
||||
header.writeBigUInt64BE(BigInt(len), 2)
|
||||
}
|
||||
header[0] = 0x80 | opcode
|
||||
const mask = randomBytes(4)
|
||||
const masked = Buffer.from(payload)
|
||||
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4]
|
||||
return Buffer.concat([header, mask, masked])
|
||||
}
|
||||
|
||||
function textFrame(text) {
|
||||
return encodeClientFrame(OP_TEXT, Buffer.from(text, 'utf8'))
|
||||
}
|
||||
|
||||
/** 服务端帧解析(**不带掩码**)。 */
|
||||
class ClientDecoder {
|
||||
constructor() {
|
||||
this.buf = Buffer.alloc(0)
|
||||
}
|
||||
push(chunk) {
|
||||
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk])
|
||||
const out = []
|
||||
for (;;) {
|
||||
if (this.buf.length < 2) break
|
||||
const b0 = this.buf[0]
|
||||
const b1 = this.buf[1]
|
||||
const opcode = b0 & 0x0f
|
||||
let len = b1 & 0x7f
|
||||
let offset = 2
|
||||
if (len === 126) {
|
||||
if (this.buf.length < 4) break
|
||||
len = this.buf.readUInt16BE(2)
|
||||
offset = 4
|
||||
} else if (len === 127) {
|
||||
if (this.buf.length < 10) break
|
||||
len = Number(this.buf.readBigUInt64BE(2))
|
||||
offset = 10
|
||||
}
|
||||
if (this.buf.length < offset + len) break
|
||||
const payload = Buffer.from(this.buf.subarray(offset, offset + len))
|
||||
this.buf = this.buf.subarray(offset + len)
|
||||
out.push({ opcode, payload, fin: (b0 & 0x80) !== 0 })
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 URL 成 `{host, port, path, tls}`(⛔ 不引 `node:url` 的 WHATWG 也行,但用它更稳)。
|
||||
* 只支持 `http(s)` / `ws(s)`:`https`/`wss` ⇒ TLS。
|
||||
*/
|
||||
export function parseEndpoint(rawUrl) {
|
||||
const u = new URL(rawUrl)
|
||||
const tls = u.protocol === 'https:' || u.protocol === 'wss:'
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: u.port !== '' ? Number(u.port) : tls ? 443 : 80,
|
||||
path: '/api/im/ws',
|
||||
tls,
|
||||
origin: `${tls ? 'https' : 'http'}://${u.host}`,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 主类 ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 拨出桥(一个实例一份)。
|
||||
*
|
||||
* ⛔ **只拨出**:`start()` 只 `connect()`,不 `listen()`。
|
||||
*/
|
||||
export class ImAgentBridge {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.baseUrl - 平台 IM 端点(env)。
|
||||
* @param {string} opts.token - 实例凭据(env)。
|
||||
* @param {string} opts.agentRef - 本 agent 引用。
|
||||
* @param {(prompt: string, ctx: object) => Promise<string>} opts.askLocalSession
|
||||
* 把上下文交给本地 dsh 会话并取回回答(占位实现见 `defaultAsk`)。
|
||||
* @param {(line: string) => void} [opts.log]
|
||||
* @param {number} [opts.firstDelayMs] / @param {number} [opts.maxDelayMs] - 退避参数(测试用)
|
||||
* @param {() => object} [opts.readSnapshot] - 读房间快照(测试注入;默认走 REST)
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.baseUrl = opts.baseUrl
|
||||
this.token = opts.token
|
||||
this.agentRef = opts.agentRef
|
||||
this.askLocalSession = opts.askLocalSession
|
||||
this.log = opts.log ?? (() => undefined)
|
||||
this.firstDelayMs = opts.firstDelayMs ?? RECONNECT_FIRST_MS
|
||||
this.maxDelayMs = opts.maxDelayMs ?? RECONNECT_MAX_MS
|
||||
this.readSnapshot = opts.readSnapshot ?? null
|
||||
/** 已订阅的房间(重连后要重新 subscribe ⇒ 按游标补拉)。 */
|
||||
this.rooms = new Map()
|
||||
this.socket = null
|
||||
this.decoder = null
|
||||
this.attempt = 0
|
||||
this.timer = null
|
||||
this.stopped = false
|
||||
/** 观测计数(§五-5「指标可查」)。 */
|
||||
this.metrics = { connects: 0, reconnects: 0, triggers: 0, replies: 0, suppressed: 0, errors: 0 }
|
||||
}
|
||||
|
||||
/** 建立(或重建)连接。⛔ 只拨出。 */
|
||||
start() {
|
||||
if (this.stopped) return
|
||||
this.metrics.connects += 1
|
||||
const ep = parseEndpoint(this.baseUrl)
|
||||
const key = randomBytes(16).toString('base64')
|
||||
const socket = connect({ host: ep.host, port: ep.port }, () => {
|
||||
const headers = [
|
||||
`GET ${ep.path} HTTP/1.1`,
|
||||
`Host: ${ep.host}`,
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Key: ${key}`,
|
||||
'Sec-WebSocket-Version: 13',
|
||||
// 🔴 两件关键头:实例凭据 + 区声明(区声明可省;带上便于平台侧判跨区)。
|
||||
`x-dsh-im-instance-token: ${this.token}`,
|
||||
`x-dsh-im-agent-ref: ${this.agentRef}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n')
|
||||
socket.write(headers)
|
||||
})
|
||||
this.socket = socket
|
||||
this.decoder = new ClientDecoder()
|
||||
let handshakeDone = false
|
||||
let raw = Buffer.alloc(0)
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
if (!handshakeDone) {
|
||||
raw = Buffer.concat([raw, chunk])
|
||||
const idx = raw.indexOf('\r\n\r\n')
|
||||
if (idx < 0) return
|
||||
const head = raw.subarray(0, idx).toString('utf8')
|
||||
if (!/^HTTP\/1\.1 101/.test(head)) {
|
||||
// 握手被拒(401 / 404 …):记一笔并按退避重连(⛔ 不静默停)。
|
||||
this.log(`handshake-rejected ${head.split('\r\n')[0]}`)
|
||||
this.metrics.errors += 1
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
const expect = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64')
|
||||
if (!head.includes(`Sec-WebSocket-Accept: ${expect}`)) {
|
||||
this.log('handshake-accept-mismatch')
|
||||
this.metrics.errors += 1
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
handshakeDone = true
|
||||
this.attempt = 0
|
||||
this.log(`connected ${ep.host}`)
|
||||
// 重连后**重新订阅**所有房间(带游标 ⇒ 平台补拉断线期间的消息)。
|
||||
for (const [roomId, since] of this.rooms) this.send({ op: 'resume', roomId, since })
|
||||
const rest = raw.subarray(idx + 4)
|
||||
if (rest.length > 0) this.consume(rest)
|
||||
return
|
||||
}
|
||||
this.consume(chunk)
|
||||
})
|
||||
socket.on('error', (err) => {
|
||||
this.metrics.errors += 1
|
||||
this.log(`socket-error ${String(err && err.message ? err.message : err)}`)
|
||||
})
|
||||
socket.on('close', () => {
|
||||
this.socket = null
|
||||
this.decoder = null
|
||||
if (this.stopped) return
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
consume(chunk) {
|
||||
if (this.decoder === null) return
|
||||
let frames
|
||||
try {
|
||||
frames = this.decoder.push(chunk)
|
||||
} catch (err) {
|
||||
this.log(`decode-error ${String(err)}`)
|
||||
return
|
||||
}
|
||||
for (const frame of frames) {
|
||||
if (frame.opcode === OP_PING) {
|
||||
this.rawWrite(encodeClientFrame(OP_PONG, frame.payload))
|
||||
continue
|
||||
}
|
||||
if (frame.opcode === OP_CLOSE) {
|
||||
// 服务端要关:主动断开 ⇒ 走 close 事件里的退避重连。
|
||||
if (this.socket !== null) this.socket.destroy()
|
||||
continue
|
||||
}
|
||||
if (frame.opcode !== OP_TEXT) continue
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(frame.payload.toString('utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
// ⚠️ 每条消息处理都吞异常:插件故障⛔不许拖垮连接(§五-7)。
|
||||
void this.onServerFrame(msg).catch((err) => {
|
||||
this.metrics.errors += 1
|
||||
this.log(`frame-handler-error ${String(err)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rawWrite(buf) {
|
||||
if (this.socket === null || this.socket.destroyed) return false
|
||||
try {
|
||||
this.socket.write(buf)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
send(obj) {
|
||||
return this.rawWrite(textFrame(JSON.stringify(obj)))
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.stopped) return
|
||||
this.metrics.reconnects += 1
|
||||
const base = Math.min(this.maxDelayMs, this.firstDelayMs * 2 ** this.attempt)
|
||||
// 抖动 ±20%:避免整批实例同时重连打爆平台。
|
||||
const delay = Math.max(1, Math.round(base * (0.8 + Math.random() * 0.4)))
|
||||
this.attempt += 1
|
||||
this.log(`reconnect in ${delay}ms (attempt ${this.attempt})`)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
this.start()
|
||||
}, delay)
|
||||
if (typeof this.timer.unref === 'function') this.timer.unref()
|
||||
}
|
||||
|
||||
/** 订阅一个房(调用方决定订阅哪些)。 */
|
||||
subscribe(roomId, since = 0) {
|
||||
this.rooms.set(roomId, since)
|
||||
this.send({ op: 'subscribe', roomId })
|
||||
}
|
||||
|
||||
/** 记住游标(每条收到的消息都推进它 ⇒ 重连补拉不重复)。 */
|
||||
noteCursor(roomId, seq) {
|
||||
if (typeof seq === 'number' && Number.isFinite(seq)) this.rooms.set(roomId, seq)
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端帧处理。
|
||||
*
|
||||
* 只关心两种:`message`(推送的新消息 ⇒ 可能触发)与 `messages`(补拉结果)。
|
||||
*/
|
||||
async onServerFrame(msg) {
|
||||
if (msg === null || typeof msg !== 'object') return
|
||||
if (msg.op === 'pong') return
|
||||
if (msg.op === 'hello') {
|
||||
this.log('hello')
|
||||
return
|
||||
}
|
||||
if (msg.op === 'error') {
|
||||
this.metrics.errors += 1
|
||||
this.log(`server-error ${String(msg.error)}`)
|
||||
return
|
||||
}
|
||||
const roomId = typeof msg.roomId === 'string' ? msg.roomId : undefined
|
||||
const list = Array.isArray(msg.messages) ? msg.messages : []
|
||||
if (roomId === undefined || list.length === 0) return
|
||||
for (const m of list) {
|
||||
this.noteCursor(roomId, m && typeof m.seq === 'number' ? m.seq : undefined)
|
||||
await this.maybeReply(roomId, m)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判一条消息要不要回、要回就回。
|
||||
*
|
||||
* 🔴 这里**不做**触发过滤的重活 —— 那条判据在平台侧 `src/im/agent-bridge.ts`
|
||||
* (同一套逻辑,测试直接断它)。本文件只做"要不要处理"的**本地前置闸**
|
||||
* (形态 / @ 命中),够用且不引第二份判据源。
|
||||
*/
|
||||
async maybeReply(roomId, message) {
|
||||
if (message === undefined || message === null) return
|
||||
// ① 自激闸门:agent 自己的消息(`via==='agent'` 或 `author_kind!=='human'`)恒不处理。
|
||||
if (message.authorKind !== 'human' || message.via === 'agent') return
|
||||
const mentions = collectMentions(message.payload)
|
||||
if (mentions.length === 0) return
|
||||
// ② 是不是 @ 到我(形态 A:agentRef 直接命中;形态 B:主人命中 —— 由平台侧快照判到底是谁)。
|
||||
const self = String(this.agentRef)
|
||||
const hit =
|
||||
mentions.includes(self) ||
|
||||
mentions.includes(String(this.ownerId ?? '')) ||
|
||||
mentions.some((m) => String(m).startsWith(self))
|
||||
if (!hit) return
|
||||
|
||||
this.metrics.triggers += 1
|
||||
const ctx = {
|
||||
roomId,
|
||||
focusSeq: typeof message.seq === 'number' ? message.seq : undefined,
|
||||
memberId: message.authorId,
|
||||
message,
|
||||
}
|
||||
let answer
|
||||
try {
|
||||
answer = await this.askLocalSession(renderPrompt(message, roomId), ctx)
|
||||
} catch (err) {
|
||||
this.metrics.errors += 1
|
||||
this.log(`ask-failed ${String(err)}`)
|
||||
return
|
||||
}
|
||||
if (typeof answer !== 'string' || answer === '') return
|
||||
// ③ 回写:`via='agent'` + 可见代答标识(§五-6)。
|
||||
const marker = this.form === 'bot' ? '能力机器人' : `由 ${this.ownerName ?? message.authorId} 的助手代答`
|
||||
const ok = this.send({
|
||||
op: 'send',
|
||||
roomId,
|
||||
payload: { text: answer, agentReply: true, agentLabel: marker },
|
||||
via: 'agent',
|
||||
})
|
||||
if (ok) {
|
||||
this.metrics.replies += 1
|
||||
this.log(`reply-sent room=${roomId} focus=#${ctx.focusSeq}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停机(挂 profile 卸载 / 实例退出)。 */
|
||||
close() {
|
||||
this.stopped = true
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
if (this.socket !== null) {
|
||||
try {
|
||||
this.socket.write(encodeClientFrame(OP_CLOSE, Buffer.from('\u0000\u0000shutdown', 'utf8')))
|
||||
} catch {
|
||||
// 写不动就直接销毁
|
||||
}
|
||||
const s = this.socket
|
||||
setTimeout(() => s.destroy(), CLOSE_GRACE_MS).unref?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 payload 抽 @(与平台侧同判据,这里是插件侧的只读副本)。 */
|
||||
export function collectMentions(payload) {
|
||||
if (payload === null || typeof payload !== 'object') return []
|
||||
if (Array.isArray(payload.mentions)) return payload.mentions.filter((m) => typeof m === 'string')
|
||||
const text = payload.text
|
||||
if (typeof text !== 'string') return []
|
||||
const out = []
|
||||
for (const m of text.matchAll(/@([A-Za-z0-9_\-.:]{1,64})/g)) if (m[1] !== undefined) out.push(m[1])
|
||||
return out
|
||||
}
|
||||
|
||||
/** 把一条消息渲染成给本地 dsh 会话的 prompt(占位实现 —— 完整上下文由平台侧组装)。 */
|
||||
export function renderPrompt(message, roomId) {
|
||||
const text = typeof message.payload?.text === 'string' ? message.payload.text : JSON.stringify(message.payload)
|
||||
return `[房间 ${roomId} · 消息 #${message.seq} · 来自 ${message.authorId}]\n${text}\n\n请回答上面这条被 @ 的消息。`
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的本地会话实现:**回显式占位**。
|
||||
*
|
||||
* ⛔ 真正接本地 dsh 会话是 **D 单**的扩展点契约面(§八-5);本单保证的是
|
||||
* 触发 / 上下文 / 预算 / 回写 / 隔离五条判据**可验**。接法 = 通过 `opts.askLocalSession` 注入。
|
||||
*/
|
||||
export function defaultAsk(prompt) {
|
||||
return Promise.resolve(`(agent 桥已收到触发,但本地会话对接属 D 单扩展点)\n${prompt.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
/* ── cordis 落点 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 单例(profile 卸载时 close)。 */
|
||||
let activeBridge = null
|
||||
|
||||
/**
|
||||
* cordis 入口。
|
||||
*
|
||||
* 🔴 **天然开关**:读不到 URL 或 token ⇒ 直接 return(日志里**没有**连接尝试)。
|
||||
* 这与 §三「不注入 env ⇒ 插件不启动」逐字对应,也是平台侧回滚的唯一动作。
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const url = process.env[ENV_URL]
|
||||
const token = process.env[ENV_TOKEN]
|
||||
const home = process.env.DSH_HOME || process.env.HOME || '.'
|
||||
const log = makeLog(home)
|
||||
if (url === undefined || url === '' || token === undefined || token === '') {
|
||||
log(`skip: ${ENV_URL} / ${ENV_TOKEN} 未注入 ⇒ agent 桥不启动`)
|
||||
return
|
||||
}
|
||||
const agentRef = process.env[ENV_AGENT_REF] ?? 'instance-agent'
|
||||
const bridge = new ImAgentBridge({
|
||||
baseUrl: url,
|
||||
token,
|
||||
agentRef,
|
||||
askLocalSession: defaultAsk,
|
||||
log,
|
||||
})
|
||||
log(`apply pid=${process.pid} agent=${agentRef}`)
|
||||
try {
|
||||
bridge.start()
|
||||
} catch (err) {
|
||||
// §五-7:起连接失败⛔不许把 profile 打崩。
|
||||
log(`start-failed ${String(err)}`)
|
||||
}
|
||||
activeBridge = bridge
|
||||
ctx.on?.('dispose', () => {
|
||||
try {
|
||||
bridge.close()
|
||||
} catch {
|
||||
// 收尾失败也不抛
|
||||
}
|
||||
activeBridge = null
|
||||
})
|
||||
}
|
||||
|
||||
/** 供测试/排查取当前单例。 */
|
||||
export function currentBridge() {
|
||||
return activeBridge
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dsh-local/im-agent-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "IM agent 接入桥 —— 实例内自研插件(C 单 `交接单/IM群组-C-Agent接入插件.md`)。职责:**主动拨出**到平台 IM 端点(`/api/im/ws`,带 `x-dsh-im-instance-token`),按两种形态接收触发(能力机器人被 @ / 组员代理:其主人被 @ 且开启「待应答」),组装「最近窗口 + 被 @ 那条」的上下文交给本地 dsh 会话,把回答写回房间;四条硬约束(不互相触发 / 发言预算 / 静默期 / 房间级限速排队)在压测下生效,且代答消息带可见标识。⛔ 不改官方 dsh、⛔ 不改 relay、⛔ 不改 A/B 单的表结构与端点形状。",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# `im-connection-gateway` —— IM 连接层的**参考部署**(外部连接层)
|
||||
|
||||
本目录是 IM 线「连接层候选 B」的部署侧材料:**外部连接层进程的配置样例** + **边缘映射样例**
|
||||
+ **平台应用层帧 ↔ 网关协议的逐项映射表**。
|
||||
|
||||
> 🔴 本目录里的内容**只做部署**:平台的连接层抽象在 `src/im/connection-backend.ts`(中性命名),
|
||||
> 两个后端实现在 `src/im/backends/{native,gateway}.ts`。⛔ 平台内核(`hub.ts` / `ws.ts` /
|
||||
> `store.ts`)**不认识**本目录的任何概念,这是刻意的分层 —— 换掉下面这个组件不需要动内核。
|
||||
|
||||
参考部署用的是 **Centrifugo v6.9.6**(选择依据见 `dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md`
|
||||
§十二 执行回报(IM线-第3棒)的 E-2 对照读数)。
|
||||
|
||||
---
|
||||
🔻 **第 15 棒部署实测修正(2026-09-23,⛔ 下方原文不改,只在此标注)** —— §3 / §4 / §6 有三处被实测改写:
|
||||
|
||||
| 原文 | 实测结论 | 依据 |
|
||||
|---|---|---|
|
||||
| §3 / config:`ping_interval: "0s"`("⛔ 不启用服务端 WS ping") | 🔴 **该值会让客户端保活帧被判 `bad request`** —— 没有待回的 ping 时,协议里的 `{}` 是**空命令**、不是 pong ⇒ 连接被断开(code 3501)。已改为 **`25s`**:网关发协议级 ping,客户端**按连接**回 `{}` | 47 三向实测:off 80/80 断(3012 `no pong`)· conn **掉 0** · global 80/80 断(3501 `bad request`) |
|
||||
| §4 "本棒 ⛔ 未执行" | ✅ **已在 47 就地执行**(二进制 + 配置 + systemd 单元 + 边缘片段落盘 + `nginx -t`);⛔ 边缘**未接进 vhost**(不开流量) | 见 §7 |
|
||||
| §6-1 "在线态取数未接线" | ✅ **已接线**(平台 `presence-ingest.ts` + `POST /api/im/gateway/presence`);`stats().presenceIngest` 从 `not-wired` 变为 `gateway-join-leave` | 见 §7-3 |
|
||||
|
||||
⚠️ 关于 §3 那句「实测 `wsPingRecv = 0`」:**这条实测本身没错** —— 它数的是 **WS 控制帧**,
|
||||
而网关发的是**协议级 JSON ping**。错的是由它推出的结论("所以网关不管保活")。
|
||||
|
||||
---
|
||||
|
||||
## 1. 拓扑
|
||||
|
||||
```
|
||||
浏览器 / 实例内客户端
|
||||
│ ws(s)://<平台域>/api/im/ws ← 客户端侧 URL **一个字都不变**
|
||||
▼
|
||||
边缘(nginx)── location = /api/im/ws ──────▶ 外部连接层(持有 10k+ 连接,做扇出)
|
||||
▲
|
||||
平台进程 ── POST http://<网关>/api/publish ───────────┘ (每条消息**一次**发布调用)
|
||||
│
|
||||
└─ 权威态:房间 / 成员 / 消息 / 游标 / 预算 —— 全在平台 `src/im/**`,一条都不外移
|
||||
```
|
||||
|
||||
平台的 `/api/im/ws`(进程内自研 WS)**不摘**:
|
||||
① 实例内凭据通道(`x-dsh-im-instance-token`)走它;② 它是**一行回滚**的落点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 应用层帧 ↔ 网关协议:逐项映射
|
||||
|
||||
🔴 映射的判据是「**应用层帧不变**」:客户端 `JSON.parse(payload.frame)` 拿到的字节与
|
||||
native 模式**逐字相同**(`test/im-connection-backend.test.mjs` 的 B-18 就是断这条)。
|
||||
|
||||
| 应用层(`/api/im/ws` 的语义) | 网关侧 | 权威在哪 |
|
||||
|---|---|---|
|
||||
| 客户端连 `wss://…/api/im/ws` | 边缘 `location = /api/im/ws` → 网关 WS 端点 | 边缘 |
|
||||
| 鉴权(`sid` cookie / 实例凭据) | 网关侧的连接令牌(`client.token.hmac_secret_key` 签发) | 🔴 **仍是平台**:令牌只回答"你是谁",能不能读房由成员表判 |
|
||||
| `{op:'subscribe', roomId}` | 客户端 `subscribe` 频道 `im:<roomId>` | 平台成员表(`store.isMember`) |
|
||||
| `{op:'subscribe'}` 的应答 `{op:'subscribed', cursor}` | ⛔ 网关不产这个帧 ⇒ 由平台 REST 补(见下「游标」) | 平台 |
|
||||
| `{op:'send', roomId, payload}` | ⛔ 网关不参与:走平台 `POST /api/im/rooms/:id/messages` | 平台(预算 `decideWrite` + 落库) |
|
||||
| `{op:'resume', roomId, since}` | 平台 `GET /api/im/rooms/:id/messages?since=`(网关 `history` 只作**加速**) | 平台(游标权威) |
|
||||
| 服务端 `{op:'message', roomId, messages:[…]}` | 平台 `POST /api/publish` `{channel:'im:<roomId>', data:{frame:'<原字符串>'}}` | 平台产生、网关投递 |
|
||||
| `{op:'presence', roomId, members:[…]}` | 网关 `presence` / `join_leave`(**本轮未接线**) | ⚠️ 见 §4 未完成项 |
|
||||
| 保活 | **客户端**按 §3 的规则自建 | 客户端侧垫片 |
|
||||
|
||||
**负载信封**(`src/im/backends/gateway.ts` 的 `gatewayPayloadOf`):
|
||||
|
||||
```json
|
||||
{ "channel": "im:imr_<uuid>", "data": { "frame": "{\"op\":\"message\",\"roomId\":\"imr_…\",\"messages\":[…]}" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔴 硬实现项 ①:**必须**自建保活(实测必做,⛔ 不是可选优化)
|
||||
|
||||
E-2 判别实验(106 `/root/im-e2/hb.sh`)三条读数,本棒在 `E-7` 里做了三向复核:
|
||||
|
||||
| 口径 | 结果 |
|
||||
|---|---|
|
||||
| `off`:客户端**完全不发**协议 ping | 服务端在 **26 s** 处把连接判死(`wsPingRecv = 0`:网关**不发**服务端 WS ping) |
|
||||
| `global`:**整进程统一计时** | 同上(这是 E-2 记录到的**适配器缺陷**形态) |
|
||||
| `conn`:**每条连接认证完成后**才起自己的 ping 计时 | 保活成立(= 本棒的修正形态) |
|
||||
|
||||
⇒ 规则(`src/im/keepalive.ts` 是它的可执行形态):
|
||||
|
||||
1. 客户端每 **`intervalMs`** 发一次应用层 ping;`intervalMs` 由一个空窗里发 **3** 次反推
|
||||
(实测空窗 26 s ⇒ 8,666 ms;丢一次也不至于被判死)。
|
||||
2. 🔴 **计时起点 = 该连接自己的认证完成时刻**(⛔ 不是进程启动时刻、⛔ 不是 worker 启动时刻)。
|
||||
用统一计时会一次掉成批连接 —— 本棒的回归用例 B-12 / B-13 把这两种模型**结构性区分**开。
|
||||
3. 平台的 `/api/im/stats` 在 `gateway` 模式下会回带 `keepalive` 与
|
||||
`keepaliveAnchor: 'per-connection-auth-complete'`(运维据此核对客户端是否按规则做)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 部署(示例,本棒 ⛔ 未执行)
|
||||
|
||||
```sh
|
||||
# 1) 放二进制与配置(版本与校验见 dsh-server-docs 的部署记录)
|
||||
install -m 0755 centrifugo /opt/dsh-gateway/centrifugo
|
||||
install -m 0644 config.json /opt/dsh-gateway/config.json # 凭据先替换 REPLACE_WITH_ROTATED_SECRET
|
||||
|
||||
# 2) 起进程(端口示例 18081;v6 的端口用命令行给,配置里已不再有 port 键)
|
||||
cd /opt/dsh-gateway && ./centrifugo --config=./config.json --http_server.port=18081
|
||||
|
||||
# 3) 平台侧(drop-in,⛔ 同名键写 /etc/dshs.env 会被静默压掉)
|
||||
# DSH_IM_BACKEND=gateway
|
||||
# DSH_IM_GATEWAY_URL=http://127.0.0.1:18081
|
||||
# DSH_IM_GATEWAY_KEY=<与 http_api.key 同值>
|
||||
# DSH_IM_GATEWAY_CHANNEL_PREFIX=im:
|
||||
|
||||
# 4) 边缘:把 nginx-location.conf 的 location 落进站点配置 → nginx -t → reload
|
||||
|
||||
# 5) 就绪探测与读数
|
||||
curl -s -X POST http://127.0.0.1:18081/api/info -H "Authorization: apikey <key>"
|
||||
# 平台侧:GET /api/im/stats(admin)⇒ backendKind=gateway / backend.degraded 为空
|
||||
```
|
||||
|
||||
**回滚**(顺序与上线相反,两步任一即可):
|
||||
|
||||
1. 去掉边缘的 `location = /api/im/ws` → reload ⇒ 流量立刻回到平台进程内通道;
|
||||
2. 平台 drop-in 里 `DSH_IM_BACKEND` 改回 `native`(或删掉该行)→ 重启 `dshs`。
|
||||
|
||||
⚠️ 两个方向的**都已具备**:平台的 `/api/im/ws` 两种后端都保留(`ImConnectionBackend.dispatch`),
|
||||
所以第 1 步是**零重启**回滚。
|
||||
|
||||
---
|
||||
|
||||
## 5. 容量测算的输入(⛔ 本目录不承诺数值,数值见回报的容量表)
|
||||
|
||||
| 输入 | 值 | 来源 |
|
||||
|---|---|---|
|
||||
| 边际内存 / 连接 | **70.1 KiB**(自研对照 5.9 KiB,**11.9×**) | E-2 实测(本棒 E-6 复核) |
|
||||
| 10k 连接稳态 RSS | 750.2 MiB(自研对照 116.0 MiB) | E-2 实测(本棒 E-6 复核) |
|
||||
| 客户端观测 P99(1k/5k/10k) | 28.9 / 68.1 / 111.9 ms(自研 268.6 / 337.6 / 359.4 ms) | E-2(本棒 E-6 复核) |
|
||||
| 扇出离散中位 | 17.2 / 57.9 / 97.8 ms(自研 171.8 / 227.6 / 225.5 ms) | E-2(本棒 E-6 复核) |
|
||||
| 平台侧 CPU | 每条消息 **1 次** HTTP 发布调用;`lastPublishMs` 见 `/api/im/stats` | 本棒实现口径 |
|
||||
|
||||
⚠️ 内存类读数**跨跑方差 ±12 MiB**(E-5 教训)⇒ 任何内存判据**重复跑 ≥3 次取中位**。
|
||||
|
||||
## 6. 未完成项(⛔ 与 §3 的"已做"分开看)
|
||||
|
||||
> 🔻 **第 15 棒状态标注(2026-09-23,⛔ 原文不改)**:① **已接线**(平台侧 `presence-ingest.ts`
|
||||
> +回调面 `POST /api/im/gateway/presence`,读数 `gateway-join-leave`);② **配置与边缘片段
|
||||
> 已落 47** 并通过 `nginx -t`,**仍未接进生效 vhost**(不开流量);③ 客户端侧垫片**仍未接进
|
||||
> E 单 bundle**(第 13 棒已修掉帧格式 P0,但"换连接层后客户端要按连接回 `{}`"这层尚未做)。
|
||||
|
||||
1. **在线态取数未接线**:平台的 `presence` 需要网关的 `presence` / `join_leave` 面;
|
||||
本棒只把现状暴露在 `stats().presenceIngest = 'not-wired'`,⛔ 不假装完成。
|
||||
2. **本目录的 nginx / config 未应用**(本棒边界:⛔ 不部署到 47 / 106 生产面)。
|
||||
3. **客户端侧垫片未接进 E 单 bundle** —— 见回报里的 P0 发现(客户端 bundle 与平台
|
||||
`/api/im/ws` 的帧格式**当前不一致**,接线前先要修那一处)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 第 15 棒:47 就地部署实况(2026-09-23)
|
||||
|
||||
🔴 本目录只记**部署落点**;权威读数与判据在 `dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md` **§15.8**。
|
||||
|
||||
| 项 | 落点 / 值 |
|
||||
|---|---|
|
||||
| 二进制 | `/opt/dsh-gateway/centrifugo`(v6.9.6,sha256 `f4869bf9c0028ed5253b74479b1dc70e3b916dae2ddad5bb03504b1eb07a848c`,与 106 测试床同源) |
|
||||
| 配置 | `/opt/dsh-gateway/config.json`(**600**,密钥现场 `openssl rand -hex 32` 生成,⛔ 不入库) |
|
||||
| 单元 | `dsh-gateway.service`(`enabled` + `active`),`--http_server.address=127.0.0.1 --http_server.port=18081`,`LimitNOFILE=200000` |
|
||||
| 边缘片段 | `/opt/dsh-gateway/nginx-location.conf`(**已落盘 + 临时配置 `nginx -t` rc=0**;⛔ 未接进 vhost) |
|
||||
| 切流 | `DSH_IM_BACKEND` / `DSH_IM_GATEWAY_URL` / `DSH_IM_GATEWAY_KEY` / `DSH_IM_GATEWAY_CHANNEL_PREFIX` ⇒ drop-in `/etc/systemd/system/dshs.service.d/im-gateway.conf`(模板 = `/opt/dsh-gateway/dshs-im-gateway.conf.example`) |
|
||||
| 当前状态 | **native**(drop-in 已删,`restart dshs` 复验 101 ✅)⇒ 线上行为回到改动前 |
|
||||
|
||||
**只读取证脚本**(本次用的)= `/opt/dsh-gateway/im15-drill.mjs`:起 N 个客户端订阅一个临时频道,
|
||||
三组分口径(off / conn / global)计断开数,并读平台 `/api/im/stats` 与网关 `/api/channels`。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"_comment": "IM 线 · 第 12 棒 —— 外部连接层的参考部署配置(Centrifugo v6)。",
|
||||
"_comment2": "键位以 E-2 实测可用的 /root/im-e2/config.json 为准(v6 已把密钥挪进 client.token / http_api)。",
|
||||
"_comment3": "凭据一律用环境变量或密钥文件注入,⛔ 不要把这个文件里的占位串当真值提交到生产。",
|
||||
"_comment4": "package.json 的运行时依赖**不含**本组件 —— 它是外部进程,不在 Node 依赖树里。",
|
||||
|
||||
"client": {
|
||||
"allowed_origins": ["*"],
|
||||
"_comment_ping": "🔻 第 15 棒部署实测修正:原值 `0s` 会让客户端保活帧被判 bad request —— 没有待回的 ping 时,协议里的 `{}` 是**空命令**而非 pong。三向实测(47 就地部署,240 连接):off(从不回帧)⇒ 80/80 被断开(code 3012 `no pong`);conn(**按每连接**收到 ping 才回 `{}`)⇒ **掉 0**;global(整进程一个节拍器给所有连接发 `{}`)⇒ 80/80 被断开(code 3501 `bad request`)。⇒ 网关**自己发协议级 ping**,客户端只需按连接回 `{}`。",
|
||||
"ping_interval": "25s",
|
||||
"token": {
|
||||
"hmac_secret_key": "REPLACE_WITH_ROTATED_SECRET"
|
||||
}
|
||||
},
|
||||
|
||||
"http_api": {
|
||||
"key": "REPLACE_WITH_ROTATED_SECRET"
|
||||
},
|
||||
|
||||
"channel": {
|
||||
"_comment_proxy": "🔻 第 16 棒新增(选型单 §十六):**订阅回检** —— 客户端订某个频道时,**由平台**回答「这个用户能不能读这个房」。⛔ 不加这一段的话,连接票据只回答「你是谁」,任何登录用户都能订 `im:<任意房间>` 并收到该房实时消息 ⇒ 「成员表判读房」这条会被破掉。`endpoint` 指平台回检面(回环即可:网关与平台同机);头里的密钥必须与 `http_api.key` / `client.token.hmac_secret_key` / 平台 drop-in 的 `DSH_IM_GATEWAY_KEY` **四者同值**。⚠️ 这个共享密钥必须放 `http.static_headers`(**固定值头**),⛔ **不能**放 `http_headers` —— `http_headers` 是 `array[string]` 且元素**只是头名**(转发客户端带来的同名头);拿它塞 `\"Name: value\"` 会变成「带名无值」,平台侧取不到密钥 ⇒ 回检 401(v6.9.6 实测 `error proxying subscribe: unexpected HTTP status code: 401`)。",
|
||||
"proxy": {
|
||||
"subscribe": {
|
||||
"endpoint": "http://127.0.0.1:3080/api/im/gateway/subscribe",
|
||||
"timeout": "2s",
|
||||
"http": {
|
||||
"static_headers": { "X-DSH-IM-Gateway-Key": "REPLACE_WITH_ROTATED_SECRET" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"namespaces": [
|
||||
{
|
||||
"_comment": "命名空间 = 频道名里第一个 `:` 之前的部分 ⇒ 配合 DSH_IM_GATEWAY_CHANNEL_PREFIX=im: 使用。",
|
||||
"name": "im",
|
||||
"_comment2": "🔻 第 16 棒修正:`allow_subscribe_for_client` 只是「已认证连接可以不带 token 订阅」,**不是**权限判定;真正的授权走 `subscribe_proxy_enabled`(平台判成员表)或平台签发的订阅票据(带 token 时 token 优先,代理不参与)。两者都由**平台**裁决 ⇒ 保留 `true`。",
|
||||
"allow_subscribe_for_client": true,
|
||||
"_comment6": "🔻 第 16 棒新增:打开订阅回检。⚠️ 它不是「可选优化」—— 关掉它,任何登录用户都能订任意 `im:*` 频道。",
|
||||
"subscribe_proxy_enabled": true,
|
||||
"_comment3": "presence / join_leave 是**在线态取数**的候选来源(第 15 棒已接线平台侧;⚠️ 网关侧尚无 join/leave 回调调用方,见 §六 已知限)。",
|
||||
"presence": true,
|
||||
"join_leave": true,
|
||||
"_comment4": "history 只作断线**加速**,⛔ 不是游标权威 —— 权威在平台 GET /api/im/rooms/:id/messages。",
|
||||
"history_size": 100,
|
||||
"history_ttl": "300s",
|
||||
"_comment5": "🔻 第 15 棒修正(原文:⛔ 不启用服务端 WS ping):网关**要**发协议级 ping(见上方 `_comment_ping`);「不发 WS ping」这条实测本身没错(`wsPingRecv=0` 数的是 **WS 控制帧**),错的是由它推出的结论 —— 协议级 ping 照样存在,且客户端必须按连接回 `{}`。",
|
||||
"force_push_join_leave": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"prometheus": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"log": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# IM 线 · 第 12 棒 —— 外部连接层的**边缘映射**示例(nginx)。
|
||||
#
|
||||
# 🔻 第 15 棒更新(2026-09-23):本文件已**落到 47** `/opt/dsh-gateway/nginx-location.conf`
|
||||
# 并用临时配置跑过 `nginx -t`(rc=0,见回报 §15.8);⛔ 但**仍未接进生效中的 vhost**
|
||||
# —— 开流量属"改线上连接层",是单独一步。
|
||||
# 落点提示:生效站点 = `ai1net.com.conf` 的 443 server 块(`location / → 127.0.0.1:3080`),
|
||||
# 把本文件的 location 插在它**之前**即可;`$connection_upgrade` 该机已存在
|
||||
# (`/www/server/panel/vhost/nginx/0.websocket.conf`)。
|
||||
#
|
||||
# 作用:客户端侧的 URL `/api/im/ws` **一个字都不变**,由边缘把它转到外部连接层的
|
||||
# WebSocket 端点;平台进程内那条 `/api/im/ws` 仍然保留(实例内凭据通道 + 回滚路径)。
|
||||
#
|
||||
# ⚠️ 前提:`$connection_upgrade` 这个 map 必须在 `http {}` 里存在一次(宝塔/常见发行版
|
||||
# 的默认配置里通常已有;没有就补下面这段):
|
||||
#
|
||||
# map $http_upgrade $connection_upgrade {
|
||||
# default upgrade;
|
||||
# '' close;
|
||||
# }
|
||||
|
||||
location = /api/im/ws {
|
||||
# 外部连接层的客户端通道(示例端口,按实际部署改)
|
||||
proxy_pass http://127.0.0.1:18081/connection/websocket;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# 长连接:⛔ 不要开缓冲,读超时要大于一次保活间隔(垫片默认 8.7 s)
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# 连接层不落平台日志;排障看连接层自己的日志与 /metrics
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 回滚(一行):注释掉上面的 location ⇒ 请求落回平台进程内的 `/api/im/ws`。
|
||||
@@ -0,0 +1,13 @@
|
||||
// @dsh-local/im-conversation-tabs — bundle patch (host row + client half)
|
||||
//
|
||||
// E 单主交付:**会话区 tab 接管**(落点 C′,2026-09-23 用户拍板)。
|
||||
// host 面:只做加载标记(apply 不用任何 ctx 服务)⇒ 无 inject(defensive)。
|
||||
// client 面:`lib/client.js` 由 `dsh.client` 声明注入(见 package.json 的
|
||||
// `dsh.client.platform='web'`)—— 会话区接管全在这半边。
|
||||
//
|
||||
// ⛔ 本插件**不改官方 dsh 主程序 / client bundle / 缓存**(R2):走的全是
|
||||
// 官方公开扩展面(`data-slot` 可寻址 DOM 槽 + client bundle 加载机制)。
|
||||
|
||||
- insert:
|
||||
- id: im-conversation-tabs
|
||||
name: '@dsh-local/im-conversation-tabs'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @dsh-local/im-conversation-tabs — host 半边(E 单)。
|
||||
*
|
||||
* ## 为什么 host 面几乎为空
|
||||
* 本插件的全部交付在**客户端**(会话区 tab 接管)。host 行只需要存在于
|
||||
* profile 的 cordis 树里,客户端半边才会被 dsh 的 client bundle 加载器加载
|
||||
* (`package.json` 的 `dsh.client` 字段声明注入面)。
|
||||
*
|
||||
* ⛔ host 面**不注册任何 ctx 服务**、⛔ 不碰 DB、⛔ 不碰实例文件系统 ——
|
||||
* 房间数据一律由客户端走**同源相对路径** `/api/im/**` 取(见 lib/client.js)。
|
||||
* ⇒ 因此 `cordis.patch.yml` 里本行**不带 `inject`**(apply 不用任何服务)。
|
||||
*
|
||||
* @module @dsh-local/im-conversation-tabs
|
||||
*/
|
||||
|
||||
/** apply 只做一件事:留下一条"已加载"的标记(便于运维排查 profile 是否带上了本插件)。 */
|
||||
export function apply(ctx) {
|
||||
const logger = ctx?.logger ?? ctx?.log
|
||||
if (logger !== undefined && typeof logger.info === 'function') {
|
||||
logger.info({ plugin: '@dsh-local/im-conversation-tabs' }, 'im_conversation_tabs_host_loaded')
|
||||
}
|
||||
}
|
||||
|
||||
/** 无服务依赖(见模块头)。 */
|
||||
export const inject = []
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dsh-local/im-conversation-tabs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "E 单(IM群组-会话界面UI)主交付:接管 dsh 会话区 [data-slot=\"main.conversation\"],顶部渲染 tab 行(dsh 会话 / 群组会话 1..5)。落点 = C′(2026-09-23 用户拍板)。保活 = display:none;群组 tab 上限 5;新建群组入口在业务插件内;IM 数据走同源相对路径 /api/im/**。",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-settings-general"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -83,9 +83,20 @@ window.__ModuleLoader__.load({
|
||||
/** 当前界面语言:优先问官方 locale;拿不到就退回官方 FALLBACK `en`。 */
|
||||
function currentLocale() {
|
||||
try {
|
||||
if (hostCtx && hostCtx.locale && typeof hostCtx.locale.getLocale === "function") {
|
||||
var id = hostCtx.locale.getLocale();
|
||||
if (id) return String(id).split("-")[0];
|
||||
var loc = hostCtx && hostCtx.locale;
|
||||
if (loc) {
|
||||
var v = null;
|
||||
if (typeof loc.getLocale === "function") v = loc.getLocale();
|
||||
// 2026-09-20 修复:dsh 0.1.5-rc.1 的 getLocale() 返回**快照对象**
|
||||
// { active, locales, revision }(0.1.2-rc.1 时代返回 id 字符串)⇒ 直接 String()
|
||||
// 得到 "[object Object]",下拉框永远匹配不上任何 option、浏览器只显示第一项
|
||||
// (English),用户选「中文」看着"没反应"。两种形态都认。
|
||||
if (v && typeof v === "object") v = v.active;
|
||||
if (!v && typeof loc.getSnapshot === "function") {
|
||||
var snap = loc.getSnapshot();
|
||||
if (snap && typeof snap === "object") v = snap.active;
|
||||
}
|
||||
if (v) return String(v).split("-")[0];
|
||||
}
|
||||
} catch (e) { /* 官方 API 变动 ⇒ 只影响本行展示,不让整个分区崩掉 */ }
|
||||
return "en";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@dsh-local/portal-entry",
|
||||
"version": "0.5.5",
|
||||
"description": "portal-entry plugin for dsh web profile — v0.5.5(2026-09-15):**修打包**(0.5.4 把上一版 tgz 打进了产物 ⇒ 补 .npmignore 并升号重发;pnpm 会按版本复用同名 tgz,所以必须升号)。|v0.5.4(2026-09-15):**语言偏好持久化**。官方 `dsh-client-locale` 只对 **loopback** 页面持久化到 `$DSH_HOME/settings.yaml`;平台是「浏览器经域名访问远程实例」⇒ 非 loopback ⇒ 官方只为当前进程保留选择、刷新即回默认英语。v0.5.4 在切换时**额外调平台** `POST /api/me/locale` ⇒ 平台把 `locale.preference` 写进用户 `settings.yaml`(官方读自己的设置文件即生效),既守住官方语义、又记住用户选择;持久化失败不影响本次切换(就地已生效)。|host boot marker + portal_ping tool + settings 「用户设置」section (id user-settings, order 103; role-aware: admins see account/role, everyone sees 界面语言 + 退出登录). v0.5.3 (2026-09-15 用户要求): 「界面语言」切换**搬进本区**(原在 business-plugins 的「偏好设置」分区,该分区已撤 —— 语言是用户级偏好,与账号/退出登录同区即可,另开分区属重复入口)。实现走官方扩展点(零官方改动):ctx.locale.getLocale()/setLocale(id) + ctx.locale.register(NS,{zh,en}),`inject` 加 \"locale\";颜色沿用本文件硬编码(本区在 DARK 主题下渲染,见 v0.4.3)。v0.5.0: section renamed from 平台管理, id platform→user-management, order 100→102 (below 功能插件=101), installed for ALL users (scripts/ensure-portal-entry.cjs), role probed via portal GET /api/auth/me with fail-closed fallback to the non-admin view. v0.5.1: repack ONLY — v0.5.0's tarball was built without lib/index.js (host half), which made every instance fail with ERR_MODULE_NOT_FOUND on @dsh-local/portal-entry/lib/index.js; version bumped because pnpm reuses a same-name/same-version tarball from its cache. v0.4.3 HARDCODES colors (text #f9fafb, btn border #43464d) because both --dsw-alias-label-primary and --dsw-alias-button-elevated-fill are defined as #f9fafb in the dark theme, so var() fallbacks never engaged.",
|
||||
"version": "0.5.6",
|
||||
"description": "portal-entry plugin for dsh web profile — v0.5.6(2026-09-20):**修「用户设置里切语言不起作用」**。dsh 0.1.5-rc.1 的 `ctx.locale.getLocale()` 返回的是**快照对象** `{active,locales,revision}`(0.1.2-rc.1 时代返回 id 字符串)⇒ 旧写法 `String(getLocale()).split(\"-\")[0]` 得到 `\"[object Object]\"`:① 受控 `<select>` 的 value 匹配不上任何 option,浏览器**只显示第一项(English)**;② 选「中文」时界面看着毫无变化(其实是「本来就已是中文」,只是下拉显示错)。v0.5.6 兼容两种形态(对象取 `.active`,并兜底 `getSnapshot().active`)。|v0.5.5(2026-09-15):**修打包**(0.5.4 把上一版 tgz 打进了产物 ⇒ 补 .npmignore 并升号重发;pnpm 会按版本复用同名 tgz,所以必须升号)。|v0.5.4(2026-09-15):**语言偏好持久化**。官方 `dsh-client-locale` 只对 **loopback** 页面持久化到 `$DSH_HOME/settings.yaml`;平台是「浏览器经域名访问远程实例」⇒ 非 loopback ⇒ 官方只为当前进程保留选择、刷新即回默认英语。v0.5.4 在切换时**额外调平台** `POST /api/me/locale` ⇒ 平台把 `locale.preference` 写进用户 `settings.yaml`(官方读自己的设置文件即生效),既守住官方语义、又记住用户选择;持久化失败不影响本次切换(就地已生效)。|host boot marker + portal_ping tool + settings 「用户设置」section (id user-settings, order 103; role-aware: admins see account/role, everyone sees 界面语言 + 退出登录). v0.5.3 (2026-09-15 用户要求): 「界面语言」切换**搬进本区**(原在 business-plugins 的「偏好设置」分区,该分区已撤 —— 语言是用户级偏好,与账号/退出登录同区即可,另开分区属重复入口)。实现走官方扩展点(零官方改动):ctx.locale.getLocale()/setLocale(id) + ctx.locale.register(NS,{zh,en}),`inject` 加 \"locale\";颜色沿用本文件硬编码(本区在 DARK 主题下渲染,见 v0.4.3)。v0.5.0: section renamed from 平台管理, id platform→user-management, order 100→102 (below 功能插件=101), installed for ALL users (scripts/ensure-portal-entry.cjs), role probed via portal GET /api/auth/me with fail-closed fallback to the non-admin view. v0.5.1: repack ONLY — v0.5.0's tarball was built without lib/index.js (host half), which made every instance fail with ERR_MODULE_NOT_FOUND on @dsh-local/portal-entry/lib/index.js; version bumped because pnpm reuses a same-name/same-version tarball from its cache. v0.4.3 HARDCODES colors (text #f9fafb, btn border #43464d) because both --dsw-alias-label-primary and --dsw-alias-button-elevated-fill are defined as #f9fafb in the dark theme, so var() fallbacks never engaged.",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
|
||||
Reference in new issue
Block a user