mslxdff 0.1.208 → 0.1.209

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.
@@ -104,6 +104,7 @@
104
104
  | 组同步 | `-group sync` | 刷新全组成员 |
105
105
  | 组离开单 | `-group leave <name>` | 离开单组 |
106
106
  | 组列表 | `-group list` | 列组+成员+健康/序号 |
107
+ | 组员链路测试 | `-group test <target\|seq> [--group <名>] [--model <id>] [--say <词>] [--json]` | 验「本机 → 组长 → 某组员(含 relay)→ 上游」整条路,**按阶段指出断在哪一跳**;target 可填 url / `relay://id` / `-group list` 序号 |
107
108
  | 组踢人 | `-group remove <seq>` | 仅 leader 按序号踢人 |
108
109
  | 全部离开 | `-leavegroup` / `--leavegroup` | 离开所有成员组 |
109
110
  | 解散组 | `-delgroup <name>` / `--delgroup` | 仅 leader 解散 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.208",
3
+ "version": "0.1.209",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,6 +110,23 @@ export async function handleGroupCommand(args) {
110
110
  }
111
111
  console.log(`\njoined groups (${joinedList.length}):`);
112
112
  for (const g of joinedList) console.log(` ${g.name} ${g.leaderUrl || "(this node is the leader)"}`);
113
+ } else if (action === "test") {
114
+ // -group test <target> [--group <名>] [--model <id>] [--say <词>] [--json]
115
+ // target 可以是 url / relay://id / 或 -group list 里的序号(数字)。
116
+ const { resolveTargetArg, renderGroupTest } = await import("../group-test-cli.js");
117
+ const target = await resolveTargetArg(a, rest);
118
+ if (!target) {
119
+ console.error("usage: mslxdff -group test <target|seq> [--group <name>] [--model <id>] [--say <text>]");
120
+ process.exit(1);
121
+ }
122
+ const model = argValue(args, "--model", "-m") || undefined;
123
+ const say = argValue(args, "--say", "-s") || undefined;
124
+ const groupArg = argValue(args, "--group", "-g") || undefined;
125
+ const asJson = args.includes("--json");
126
+ const { runGroupLinkTest } = await import("../group-test.js");
127
+ const r = await runGroupLinkTest({ group: groupArg || "", target, ...(model ? { model } : {}), ...(say ? { prompt: say } : {}) });
128
+ renderGroupTest(r, { json: asJson });
129
+ process.exit(r.status === "ok" ? 0 : 1);
113
130
  } else if (action === "remove" && a) {
114
131
  const seq = Number(a);
115
132
  if (!Number.isInteger(seq) || seq < 1) {
@@ -138,7 +155,7 @@ export async function handleGroupCommand(args) {
138
155
  process.exit(1);
139
156
  }
140
157
  } else {
141
- console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group remove <seq> | -creategroup <name>");
158
+ console.error("usage: mslxdff -group sync | -group leave <name> | -group list | -group test <target|seq> | -group remove <seq> | -creategroup <name>");
142
159
  }
143
160
  process.exit(0);
144
161
  }
@@ -0,0 +1,66 @@
1
+ // `-group test` 的 CLI 适配层:把 -group list 的序号解析成 target,并把链路测试结果渲染成四态人话。
2
+ import { loadPeers, loadGroupsJoined, loadToken } from "../state.js";
3
+ import { refreshGroupMembers } from "../groups.js";
4
+ import { compatFetch, timeoutSignal } from "../compat.js";
5
+ import { resolveLeader } from "./group-test.js";
6
+
7
+ /**
8
+ * 把用户给的 target 归一:数字 = -group list 里的序号;否则原样(url / relay://id)。
9
+ * 与 -group list 的编号口径一致(跳过 leader、broadband/static 都算)。
10
+ */
11
+ export async function resolveTargetArg(a, rest = []) {
12
+ const raw = String(a ?? "").trim();
13
+ if (!raw) return "";
14
+ if (!/^\d+$/.test(raw)) return raw; // url 或 relay://id
15
+ const seq = Number(raw);
16
+ const joined = loadGroupsJoined();
17
+ const g = joined.find((x) => x?.leaderUrl) || joined[0];
18
+ if (!g) return "";
19
+ let members;
20
+ if (g.leaderUrl) {
21
+ try {
22
+ const { token } = await loadToken();
23
+ members = await refreshGroupMembers(g.name, {
24
+ leaderUrl: g.leaderUrl, memberName: g.memberName, url: g.myUrl, token,
25
+ fetchImpl: (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(4000) }),
26
+ });
27
+ } catch { return ""; }
28
+ } else {
29
+ return ""; // 本机是组长时,序号口径见 -group list(此处不展开)
30
+ }
31
+ const entries = Object.entries(members || {}).filter(([id]) => id !== "leader");
32
+ const hit = entries[seq - 1];
33
+ return hit ? (hit[1]?.url || hit[0]) : "";
34
+ }
35
+
36
+ /** 结果 → 人话。`json` 时直接吐结构化结果(脚本用)。 */
37
+ export function renderGroupTest(r, { json = false } = {}) {
38
+ if (json) { console.log(JSON.stringify(r, null, 2)); return; }
39
+ console.log("=".repeat(62));
40
+ console.log(` 组员链路测试:本机 → 组长 → ${r.target}`);
41
+ console.log("=".repeat(62));
42
+ console.log(` 组长 ${r.leaderUrl || "?"}|组 ${r.group || "?"}${r.ms !== undefined ? `|耗时 ${r.ms}ms` : ""}`);
43
+ console.log("");
44
+ if (r.status === "ok") {
45
+ console.log(` ✅ 通了!经组长转发到该组员,上游返回:`);
46
+ console.log(` "${String(r.content || "").slice(0, 200) || "(空正文)"}"`);
47
+ process.exitCode = 0;
48
+ return;
49
+ }
50
+ const stageZh = {
51
+ usage: "参数错误",
52
+ "resolve-leader": "定位组长失败",
53
+ "resolve-group": "确定组名失败",
54
+ "forward-fetch": "连不上组长",
55
+ auth: "鉴权被拒",
56
+ "target-missing": "目标不在组内",
57
+ "max-hops": "转发跳数超限",
58
+ "relay-timeout": "组长入了队但成员没回",
59
+ "forward-failed": "组长转发失败",
60
+ upstream: "成员打上游失败",
61
+ }[r.stage] || r.stage;
62
+ console.log(` ❌ 没通 — 断在:**${stageZh}**${r.httpStatus ? `(HTTP ${r.httpStatus})` : ""}`);
63
+ if (r.error) console.log(` 原因:${String(r.error).slice(0, 300)}`);
64
+ if (r.hint) console.log(` 下一步:${r.hint}`);
65
+ process.exitCode = 1;
66
+ }
@@ -0,0 +1,118 @@
1
+ // 组员链路测试:验证「本机 → 组长 → 某组员(含 relay 宽带成员)→ 上游 → 回数据」整条路。
2
+ //
3
+ // 为什么需要它:这条链路跨 3 台机器 + 1 道鉴权,坏了只会看到一句 `502 fetch failed`,
4
+ // 完全无法定位是哪一跳断的。本模块**按阶段**记录,把断点直接指出来:
5
+ // ① 定位本机组/组长 → ② 取组长 token(调组长 forward 要**组长的** token,见下)→
6
+ // ③ POST 组长 /v1/groups/relay/forward → ④ 判读回包(200 拿内容 / 401 鉴权 / 404 成员不存在 / 502·504 转发失败)
7
+ //
8
+ // ⚠ 鉴权事实(2026-10-19 实测):`authorized()` 比的是**接收方自己的 token**,
9
+ // 而 `/v1/groups/relay/forward` 在路由层 requiresAuth=true ⇒ 要**组长 token**。
10
+ // 普通组员只有自己的 token ⇒ 调不动组长 forward(这就是"组员经组长"从没通的原因)。
11
+ // 本命令调用时用组长 token;若拿不到组长 token(如你只是组员且未持有),会明确报出这一步。
12
+ import { loadPeers, loadGroupsJoined, loadToken } from "../state.js";
13
+ import { compatFetch, timeoutSignal } from "../compat.js";
14
+
15
+ /** 默认探测模型与提示词。 */
16
+ export const DEFAULT_TEST_MODEL = "deepseek-v4.1-flash-free";
17
+ export const DEFAULT_TEST_PROMPT = "hi";
18
+
19
+ /**
20
+ * 找一个可用的组长 token:优先 state.peers 里名为 leader 的记录,其次 groupsJoined 里非本机 url 的 peer。
21
+ * @returns {{ leaderUrl: string, leaderToken: string, group: string, source: string } | null}
22
+ */
23
+ export function resolveLeader({ peers = [], joined = [] } = {}) {
24
+ const byName = (peers || []).find((p) => String(p?.name || "") === "leader" && p?.url);
25
+ if (byName) {
26
+ const g = joined.find((x) => x?.leaderUrl === byName.url);
27
+ return { leaderUrl: byName.url, leaderToken: String(byName.token || ""), group: g?.name || "", source: "peers[leader]" };
28
+ }
29
+ // 兜底:用 groupsJoined 的 leaderUrl 去 peers 里找同 url 的记录取 token
30
+ const g = (joined || []).find((x) => x?.leaderUrl);
31
+ if (g) {
32
+ const p = (peers || []).find((x) => x?.url === g.leaderUrl);
33
+ return { leaderUrl: g.leaderUrl, leaderToken: String(p?.token || ""), group: g.name, source: "groupsJoined.leaderUrl" };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * 跑一次链路测试。返回结构化结果(不打印,由 CLI 渲染)。
40
+ *
41
+ * @param {{ group?: string, target: string, model?: string, prompt?: string, timeoutMs?: number,
42
+ * deps?: { peers?, joined?, myToken?, fetchImpl? } }} opts
43
+ * @returns {Promise<{ status: "ok"|"error", stage: string, httpStatus?: number, content?: string,
44
+ * reply?: any, leaderUrl?: string, group?: string, target: string, ms?: number,
45
+ * error?: string, hint?: string }>}
46
+ */
47
+ export async function runGroupLinkTest({
48
+ group = "",
49
+ target = "",
50
+ model = DEFAULT_TEST_MODEL,
51
+ prompt = DEFAULT_TEST_PROMPT,
52
+ timeoutMs = 45_000,
53
+ deps = {},
54
+ } = {}) {
55
+ if (!target) return { status: "error", stage: "usage", target, error: "必须给目标组员(url、relay://id 或 -group list 里的序号对应项)" };
56
+
57
+ const peers = deps.peers ?? loadPeers();
58
+ const joined = deps.joined ?? loadGroupsJoined();
59
+ const myToken = deps.myToken ?? (await loadToken()).token;
60
+ const fetchImpl = deps.fetchImpl ?? compatFetch;
61
+
62
+ const lead = resolveLeader({ peers, joined });
63
+ if (!lead) return { status: "error", stage: "resolve-leader", target, error: "本机没有可用的组长记录(未加入任何组?)", hint: "先 mslxdff -addtogroup <组长host> <组名>" };
64
+ const groupName = group || lead.group;
65
+ if (!groupName) return { status: "error", stage: "resolve-group", target, error: "无法确定组名(state 里 peers/groupsJoined 缺 group)", hint: "用 -group 显式指定组名" };
66
+
67
+ const reqId = `gt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
68
+ const body = { group: groupName, target, reqId, body: { model, messages: [{ role: "user", content: prompt }], stream: false } };
69
+
70
+ const t0 = Date.now();
71
+ let res;
72
+ try {
73
+ res = await fetchImpl(`${lead.leaderUrl}/v1/groups/relay/forward`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${lead.leaderToken}` },
76
+ body: JSON.stringify(body),
77
+ signal: timeoutSignal(timeoutMs),
78
+ });
79
+ } catch (e) {
80
+ const msg = String(e?.message || e);
81
+ return { status: "error", stage: "forward-fetch", target, leaderUrl: lead.leaderUrl, group: groupName, ms: Date.now() - t0,
82
+ error: `连组长都打不通:${msg}`, hint: "组长地址/网络不通;先 mslxdff -group list 看组长健康" };
83
+ }
84
+ const ms = Date.now() - t0;
85
+ const txt = await res.text().catch(() => "");
86
+
87
+ // 按阶段归类失败 —— 这是本命令存在的意义:一眼看出断在哪一跳
88
+ if (res.status === 401) {
89
+ return { status: "error", stage: "auth", httpStatus: 401, target, leaderUrl: lead.leaderUrl, group: groupName, ms,
90
+ error: "组长拒了鉴权(401):调 /v1/groups/relay/forward 需要**组长的** token,本机没有",
91
+ hint: "这是已知缺陷:组员中继链路的鉴权断了。修复 = 组长侧 forward 应接受成员 token(见 -group test 的 PR)" };
92
+ }
93
+ if (res.status === 404) {
94
+ return { status: "error", stage: "target-missing", httpStatus: 404, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "该目标不在组内(名称/url 对不上);用 -group list 核对" };
95
+ }
96
+ if (res.status === 429) {
97
+ return { status: "error", stage: "max-hops", httpStatus: 429, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "转发跳数超限(有环)" };
98
+ }
99
+ if (res.status === 504) {
100
+ return { status: "error", stage: "relay-timeout", httpStatus: 504, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 200), hint: "组长入了队但目标成员没在 30s 内回结果(成员不在线/不通)" };
101
+ }
102
+ if (res.status !== 200) {
103
+ const isFetchFail = /fetch failed/i.test(txt);
104
+ const isRelay = String(target).startsWith("relay://");
105
+ return { status: "error", stage: "forward-failed", httpStatus: res.status, target, leaderUrl: lead.leaderUrl, group: groupName, ms, error: txt.slice(0, 300),
106
+ hint: isFetchFail && isRelay
107
+ ? "对 relay 成员,502 fetch failed 通常是**该成员自己的上游**打不通(组长只负责转发,不碰上游)。判据:同组其他 relay 成员若正常,即证是这一台的网络/上游问题,不是组长。也可能是该成员离线。"
108
+ : isFetchFail ? "组长直连该静态成员失败(它离线或端口不通)" : "" };
109
+ }
110
+
111
+ let reply = null;
112
+ try { reply = JSON.parse(txt); } catch { reply = null; }
113
+ const content = reply?.choices?.[0]?.message?.content ?? reply?.content ?? "";
114
+ if (reply && (reply.error || reply.object === "error")) {
115
+ return { status: "error", stage: "upstream", httpStatus: 200, target, leaderUrl: lead.leaderUrl, group: groupName, ms, reply, error: String(reply.error?.message || reply.error || "").slice(0, 300), hint: "组长转到了成员,但成员打上游失败(该成员自己的上游/额度问题)" };
116
+ }
117
+ return { status: "ok", stage: "done", httpStatus: 200, target, leaderUrl: lead.leaderUrl, group: groupName, ms, content: String(content || ""), reply };
118
+ }