@dsh-plus/llm-pi 0.1.1 → 0.1.3

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/lib/client.js CHANGED
@@ -45,24 +45,12 @@ const SETTINGS_NS = "dsh-plus-llm-pi";
45
45
 
46
46
  //#endregion
47
47
  //#region src/client/api.ts
48
- const ROUTE_CONFIG = "/dsh-plus/llm-pi/config";
49
48
  const ROUTE_CATALOG = "/dsh-plus/llm-pi/catalog";
50
49
  async function parse(res) {
51
50
  const body = await res.json();
52
51
  if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
53
52
  return body;
54
53
  }
55
- async function fetchConfig() {
56
- return parse(await fetch(ROUTE_CONFIG, { credentials: "same-origin" }));
57
- }
58
- async function saveConfig(patch) {
59
- return parse(await fetch(ROUTE_CONFIG, {
60
- method: "PUT",
61
- credentials: "same-origin",
62
- headers: { "content-type": "application/json" },
63
- body: JSON.stringify(patch)
64
- }));
65
- }
66
54
  /** 目录查询:provider 为空时只返回该源的 provider 列表。 */
67
55
  async function fetchCatalog(provider, source) {
68
56
  const url = `${ROUTE_CATALOG}?provider=${encodeURIComponent(provider)}&source=${source}`;
@@ -284,13 +272,13 @@ function emptyModelDraft() {
284
272
  compat: {}
285
273
  };
286
274
  }
287
- function draftFromWire(wire) {
275
+ function draftFromValue(value) {
288
276
  return {
289
- enabled: wire.enabled,
290
- catalogUrl: wire.catalogUrl,
291
- catalogRefreshHours: String(wire.catalogRefreshHours),
292
- catalogProxy: wire.catalogProxy,
293
- providers: Object.fromEntries(Object.entries(wire.providers).map(([route, provider]) => [route, providerDraftFromWire(provider)]))
277
+ enabled: value.enabled,
278
+ catalogUrl: value.catalogUrl,
279
+ catalogRefreshHours: String(value.catalogRefreshHours),
280
+ catalogProxy: value.catalogProxy,
281
+ providers: Object.fromEntries(Object.entries(value.providers).map(([route, provider]) => [route, providerDraftFromWire(provider)]))
294
282
  };
295
283
  }
296
284
  /** 提交补丁:完整配置对象,providers 全量替换;空值一律剔除。 */
@@ -1344,35 +1332,38 @@ function modelsDevText(status, t) {
1344
1332
  return `${t("modelsDevStatusLine")}:${status.providers} 个 provider,快照 ${status.fetchedAt}`;
1345
1333
  }
1346
1334
  function LlmPiCard(props) {
1347
- const { t } = props;
1335
+ const { t, scope, api } = props;
1336
+ const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
1337
+ const value = snapshot.value;
1348
1338
  const [open, setOpen] = (0, react.useState)(false);
1349
- const [wire, setWire] = (0, react.useState)(null);
1350
1339
  const [draft, setDraft] = (0, react.useState)(null);
1351
1340
  const [epoch, setEpoch] = (0, react.useState)(0);
1352
- const [failed, setFailed] = (0, react.useState)(false);
1341
+ const [kitSource, setKitSource] = (0, react.useState)(null);
1342
+ const [modelsDevStatus, setModelsDevStatus] = (0, react.useState)(null);
1353
1343
  const [saving, setSaving] = (0, react.useState)(false);
1354
1344
  const [refreshing, setRefreshing] = (0, react.useState)(false);
1355
1345
  const [status, setStatus] = (0, react.useState)(IDLE_STATUS);
1346
+ (0, react.useEffect)(() => {
1347
+ if (value === void 0 || draft !== null) return;
1348
+ setDraft(draftFromValue(value));
1349
+ }, [value, draft]);
1356
1350
  (0, react.useEffect)(() => {
1357
1351
  let alive = true;
1358
- fetchConfig().then((loaded) => {
1352
+ fetchCatalog("", "models-dev").then((result) => {
1359
1353
  if (!alive) return;
1360
- setWire(loaded);
1361
- setDraft(draftFromWire(loaded));
1362
- }).catch(() => {
1363
- if (alive) setFailed(true);
1364
- });
1354
+ setKitSource(result.kitSource ?? null);
1355
+ setModelsDevStatus(result.status ?? null);
1356
+ }).catch(() => {});
1365
1357
  return () => {
1366
1358
  alive = false;
1367
1359
  };
1368
1360
  }, []);
1369
- const dirty = (0, react.useMemo)(() => wire !== null && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))), [wire, draft]);
1361
+ const dirty = (0, react.useMemo)(() => value !== void 0 && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))), [value, draft]);
1370
1362
  const invalid = (0, react.useMemo)(() => {
1371
1363
  if (draft === null) return false;
1372
1364
  return !numTextOk(draft.catalogRefreshHours) || Object.values(draft.providers).some((provider) => provider.models.some((model) => model.id.trim() === ""));
1373
1365
  }, [draft]);
1374
- if (failed) return null;
1375
- if (wire === null || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
1366
+ if (value === void 0 || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
1376
1367
  className: "lpc-card",
1377
1368
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1378
1369
  className: "lpc-readOnly",
@@ -1414,10 +1405,17 @@ function LlmPiCard(props) {
1414
1405
  };
1415
1406
  const onSave = () => {
1416
1407
  setSaving(true);
1417
- saveConfig(toPatch(draft)).then((saved) => {
1418
- setWire(saved);
1419
- setDraft(draftFromWire(saved));
1420
- setEpoch((value) => value + 1);
1408
+ const revision = scope.getSnapshot().revision;
1409
+ api.replace({
1410
+ ns: SETTINGS_NS,
1411
+ section: toPatch(draft),
1412
+ ...revision !== void 0 ? { expectedRevision: revision } : {}
1413
+ }).then(async (response) => {
1414
+ if (!response.result.ok) throw new Error(response.result.error?.message ?? t("saveFailed"));
1415
+ await scope.load();
1416
+ const next = scope.getSnapshot().value;
1417
+ if (next !== void 0) setDraft(draftFromValue(next));
1418
+ setEpoch((value$1) => value$1 + 1);
1421
1419
  setStatus({
1422
1420
  kind: "ok",
1423
1421
  text: t("saveOk")
@@ -1431,17 +1429,14 @@ function LlmPiCard(props) {
1431
1429
  }).finally(() => setSaving(false));
1432
1430
  };
1433
1431
  const onDiscard = () => {
1434
- setDraft(draftFromWire(wire));
1435
- setEpoch((value) => value + 1);
1432
+ setDraft(draftFromValue(value));
1433
+ setEpoch((value$1) => value$1 + 1);
1436
1434
  setStatus(IDLE_STATUS);
1437
1435
  };
1438
1436
  const onRefreshCatalog = () => {
1439
1437
  setRefreshing(true);
1440
1438
  refreshCatalog().then((result) => {
1441
- setWire({
1442
- ...wire,
1443
- modelsDevStatus: result.status
1444
- });
1439
+ setModelsDevStatus(result.status);
1445
1440
  setStatus({
1446
1441
  kind: "ok",
1447
1442
  text: t("refreshOk")
@@ -1454,7 +1449,7 @@ function LlmPiCard(props) {
1454
1449
  });
1455
1450
  }).finally(() => setRefreshing(false));
1456
1451
  };
1457
- const disabled = !wire.writable;
1452
+ const disabled = !snapshot.writable;
1458
1453
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1459
1454
  className: `lpc-card${open ? " lpc-cardOpen" : ""}`,
1460
1455
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -1496,10 +1491,10 @@ function LlmPiCard(props) {
1496
1491
  label: t("enabled"),
1497
1492
  checked: draft.enabled,
1498
1493
  disabled,
1499
- onEdit: (value) => {
1494
+ onEdit: (value$1) => {
1500
1495
  setDraft({
1501
1496
  ...draft,
1502
- enabled: value
1497
+ enabled: value$1
1503
1498
  });
1504
1499
  setStatus(IDLE_STATUS);
1505
1500
  }
@@ -1510,10 +1505,10 @@ function LlmPiCard(props) {
1510
1505
  hint: t("catalogUrlHint"),
1511
1506
  value: draft.catalogUrl,
1512
1507
  disabled,
1513
- onEdit: (value) => {
1508
+ onEdit: (value$1) => {
1514
1509
  setDraft({
1515
1510
  ...draft,
1516
- catalogUrl: value
1511
+ catalogUrl: value$1
1517
1512
  });
1518
1513
  setStatus(IDLE_STATUS);
1519
1514
  }
@@ -1527,10 +1522,10 @@ function LlmPiCard(props) {
1527
1522
  disabled,
1528
1523
  invalid: !numTextOk(draft.catalogRefreshHours),
1529
1524
  invalidLabel: t("invalidNumber"),
1530
- onEdit: (value) => {
1525
+ onEdit: (value$1) => {
1531
1526
  setDraft({
1532
1527
  ...draft,
1533
- catalogRefreshHours: value
1528
+ catalogRefreshHours: value$1
1534
1529
  });
1535
1530
  setStatus(IDLE_STATUS);
1536
1531
  }
@@ -1541,10 +1536,10 @@ function LlmPiCard(props) {
1541
1536
  hint: t("catalogProxyHint"),
1542
1537
  value: draft.catalogProxy,
1543
1538
  disabled,
1544
- onEdit: (value) => {
1539
+ onEdit: (value$1) => {
1545
1540
  setDraft({
1546
1541
  ...draft,
1547
- catalogProxy: value
1542
+ catalogProxy: value$1
1548
1543
  });
1549
1544
  setStatus(IDLE_STATUS);
1550
1545
  }
@@ -1554,7 +1549,7 @@ function LlmPiCard(props) {
1554
1549
  children: [
1555
1550
  t("kitSource"),
1556
1551
  ":",
1557
- wire.kitSource
1552
+ kitSource ?? ""
1558
1553
  ]
1559
1554
  }),
1560
1555
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1562,7 +1557,7 @@ function LlmPiCard(props) {
1562
1557
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1563
1558
  t("modelsDevStatus"),
1564
1559
  ":",
1565
- modelsDevText(wire.modelsDevStatus, t)
1560
+ modelsDevText(modelsDevStatus, t)
1566
1561
  ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1567
1562
  type: "button",
1568
1563
  className: "lpc-btn lpc-btnGhost lpc-btnSmall lpc-refreshBtn",
@@ -1887,7 +1882,81 @@ function injectStyle() {
1887
1882
  //#region src/client/client.ts
1888
1883
  const name = "dsh-plus-llm-pi";
1889
1884
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
1890
- const inject = ["slots", "locale"];
1885
+ const inject = [
1886
+ "slots",
1887
+ "locale",
1888
+ "connection",
1889
+ "remote"
1890
+ ];
1891
+ /**
1892
+ * api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
1893
+ * 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
1894
+ * 刷新:remote `settings/document-updated`(按命名空间过滤)与
1895
+ * `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
1896
+ * (loopback 或 tailnet 信任域名)下行为一致。
1897
+ */
1898
+ function createApiScope(api, ns, c) {
1899
+ let state = {
1900
+ status: "loading",
1901
+ value: void 0,
1902
+ revision: void 0,
1903
+ writable: false
1904
+ };
1905
+ const listeners = /* @__PURE__ */ new Set();
1906
+ let generation = 0;
1907
+ const publish = (next) => {
1908
+ state = next;
1909
+ for (const listener of [...listeners]) listener();
1910
+ };
1911
+ const load = async () => {
1912
+ const gen = ++generation;
1913
+ let response;
1914
+ try {
1915
+ response = await api.describe({});
1916
+ } catch {
1917
+ return;
1918
+ }
1919
+ if (gen !== generation || !response.result.ok) return;
1920
+ const writable = response.result.value?.writable ?? false;
1921
+ const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns);
1922
+ if (view === void 0) {
1923
+ publish({
1924
+ status: "unavailable",
1925
+ value: void 0,
1926
+ revision: void 0,
1927
+ writable
1928
+ });
1929
+ return;
1930
+ }
1931
+ publish({
1932
+ status: "ready",
1933
+ value: view.value,
1934
+ revision: view.revision,
1935
+ writable
1936
+ });
1937
+ };
1938
+ const refresh = (namespace) => {
1939
+ if (namespace !== void 0 && namespace !== ns) return;
1940
+ load();
1941
+ };
1942
+ const disposers = [c.get("remote").$on("settings/document-updated", refresh), c.on("connection/reset", () => {
1943
+ load();
1944
+ })];
1945
+ c.effect(() => () => {
1946
+ for (const dispose of disposers) dispose();
1947
+ }, "llm-pi: settings scope");
1948
+ load();
1949
+ return {
1950
+ getSnapshot: () => state,
1951
+ subscribe: (listener) => {
1952
+ listeners.add(listener);
1953
+ return () => {
1954
+ listeners.delete(listener);
1955
+ };
1956
+ },
1957
+ load
1958
+ };
1959
+ }
1891
1960
  function apply(ctx) {
1892
1961
  const c = ctx;
1893
1962
  const tag = injectStyle();
@@ -1898,12 +1967,17 @@ function apply(ctx) {
1898
1967
  zh,
1899
1968
  en
1900
1969
  }), "llm-pi: locale");
1970
+ const api = c.get("connection").api.settings;
1971
+ const scope = createApiScope(api, SETTINGS_NS, c);
1901
1972
  c.slots.inject("settings.plugin.item", () => c.slots.register({
1902
1973
  name: "settings.plugin.item",
1903
1974
  key: SETTINGS_NS,
1904
- order: 110,
1905
1975
  locale: NS,
1906
- inject: () => ({ t: c.locale.bind(NS) })
1976
+ inject: () => ({
1977
+ t: c.locale.bind(NS),
1978
+ scope,
1979
+ api
1980
+ })
1907
1981
  }, LlmPiCard));
1908
1982
  }
1909
1983
 
package/lib/index.d.ts CHANGED
@@ -62,29 +62,10 @@ interface LlmPiConfig {
62
62
  providers: Record<string, ProviderProfileConfig>;
63
63
  }
64
64
  declare const Config: z<LlmPiConfig>;
65
- /** 配置卡片读取用的传输对象:配置无密钥字段,原样传输;附运行期元信息。 */
66
- interface WireConfig {
67
- enabled: boolean;
68
- catalogUrl: string;
69
- catalogRefreshHours: number;
70
- catalogProxy: string;
71
- providers: Record<string, ProviderProfileConfig>;
72
- /** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
73
- writable: boolean;
74
- /** 模块解析来源与自检结果(dsh 树 / vendored 兜底)。 */
75
- kitSource: string;
76
- /** models.dev 快照状态(fetchedAt/模型数/错误),供卡片展示。 */
77
- modelsDevStatus: {
78
- fetchedAt: string | null;
79
- providers: number;
80
- models: number;
81
- error: string | null;
82
- } | null;
83
- }
84
65
  //#endregion
85
66
  //#region src/index.d.ts
86
67
  declare const name = "dsh-plus-llm-pi";
87
68
  declare const inject: readonly ["llm"];
88
69
  declare function apply(ctx: Context, config: LlmPiConfig): Promise<void>;
89
70
  //#endregion
90
- export { Config, type LlmPiConfig, type ModelEntryConfig, type ProviderProfileConfig, SETTINGS_NS, type WireConfig, apply, inject, name };
71
+ export { Config, type LlmPiConfig, type ModelEntryConfig, type ProviderProfileConfig, SETTINGS_NS, apply, inject, name };
package/lib/index.js CHANGED
@@ -115,25 +115,6 @@ const Config = z.object({
115
115
  catalogProxy: z.string().description("拉取 models.dev 目录时的 HTTP 代理地址(如 http://127.0.0.1:7890);留空直连").default(""),
116
116
  providers: z.dict(providerProfile).description("provider 路由表,键即 route 名").default({})
117
117
  });
118
- function toWire(cfg, writable, kitSource, modelsDevStatus) {
119
- return {
120
- enabled: cfg.enabled,
121
- catalogUrl: cfg.catalogUrl,
122
- catalogRefreshHours: cfg.catalogRefreshHours,
123
- catalogProxy: cfg.catalogProxy ?? "",
124
- providers: cfg.providers ?? {},
125
- writable,
126
- kitSource,
127
- modelsDevStatus
128
- };
129
- }
130
- const WirePatch = z.object({
131
- enabled: z.boolean(),
132
- catalogUrl: z.string(),
133
- catalogRefreshHours: z.number(),
134
- catalogProxy: z.string(),
135
- providers: z.dict(providerProfile)
136
- });
137
118
 
138
119
  //#endregion
139
120
  //#region src/catalog/builtin.ts
@@ -183,63 +164,14 @@ function inheritedCatalogEntries(kit, provider) {
183
164
 
184
165
  //#endregion
185
166
  //#region src/config-api.ts
186
- const ROUTE_CONFIG = "/dsh-plus/llm-pi/config";
187
167
  const ROUTE_CATALOG = "/dsh-plus/llm-pi/catalog";
188
- const MAX_BODY_BYTES = 256 * 1024;
189
168
  function sendJson(res, status, body) {
190
169
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
191
170
  res.end(JSON.stringify(body));
192
171
  }
193
- function readBody(req) {
194
- return new Promise((resolve, reject) => {
195
- const chunks = [];
196
- let size = 0;
197
- req.on("data", (chunk) => {
198
- size += chunk.length;
199
- if (size > MAX_BODY_BYTES) {
200
- reject(/* @__PURE__ */ new Error("request body too large"));
201
- req.destroy();
202
- return;
203
- }
204
- chunks.push(chunk);
205
- });
206
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
207
- req.on("error", reject);
208
- });
209
- }
210
- async function readPatch(req) {
211
- const raw = await readBody(req);
212
- let parsed;
213
- try {
214
- parsed = JSON.parse(raw);
215
- } catch {
216
- throw new Error("request body is not valid JSON");
217
- }
218
- return WirePatch(parsed);
219
- }
220
- function wireOf(runtime, writable) {
221
- return toWire(runtime.currentConfig(), writable, runtime.kitInfo().source, runtime.modelsDev.status());
222
- }
223
- async function handleConfig(ctx, runtime, req, res) {
224
- const settings = ctx.get("settings");
225
- if (req.method === "GET") {
226
- sendJson(res, 200, wireOf(runtime, settings !== void 0));
227
- return;
228
- }
229
- if (req.method !== "PUT") {
230
- sendJson(res, 405, { error: "method not allowed" });
231
- return;
232
- }
233
- if (settings === void 0) {
234
- sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
235
- return;
236
- }
237
- const patch = await readPatch(req);
238
- await settings.replace(SETTINGS_NS, patch);
239
- sendJson(res, 200, wireOf(runtime, true));
240
- }
241
172
  /** 目录查询/手动拉取:GET ?provider=&source= → 该源模型 id 列表;POST /refresh → 立即拉取。 */
242
173
  function handleCatalog(runtime, req, res) {
174
+ const kitSource = runtime.kitInfo().source;
243
175
  if (req.method === "POST" && req.url?.endsWith("/refresh")) {
244
176
  runtime.modelsDev.refresh().then(() => {
245
177
  sendJson(res, 200, { status: runtime.modelsDev.status() });
@@ -252,17 +184,19 @@ function handleCatalog(runtime, req, res) {
252
184
  sendJson(res, 200, {
253
185
  providers: runtime.modelsDev.providerIds(),
254
186
  models: provider.length > 0 ? runtime.modelsDev.modelIds(provider) : [],
255
- status: runtime.modelsDev.status()
187
+ status: runtime.modelsDev.status(),
188
+ kitSource
256
189
  });
257
190
  return;
258
191
  }
259
192
  sendJson(res, 200, {
260
193
  providers: runtime.kit.getBuiltinProviders(),
261
- models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : []
194
+ models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : [],
195
+ kitSource
262
196
  });
263
197
  }
264
- /** 注册配置读写与目录查询路由(webServer 缺失时由调用方保证不调用)。 */
265
- function registerConfigApi(ctx, runtime) {
198
+ /** 注册目录路由(webServer 缺失时由调用方保证不调用)。 */
199
+ function registerCatalogApi(ctx, runtime) {
266
200
  const logger = ctx.logger("llm-pi");
267
201
  const guard = (handler) => {
268
202
  return async (req, res) => {
@@ -270,17 +204,12 @@ function registerConfigApi(ctx, runtime) {
270
204
  await handler(req, res);
271
205
  } catch (error) {
272
206
  const message = error instanceof Error ? error.message : String(error);
273
- logger.warn(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
207
+ logger.warn(`catalog api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
274
208
  if (!res.headersSent) sendJson(res, 400, { error: message });
275
209
  else res.end();
276
210
  }
277
211
  };
278
212
  };
279
- ctx.webServer.register({
280
- kind: "exact",
281
- path: ROUTE_CONFIG,
282
- handler: guard((req, res) => handleConfig(ctx, runtime, req, res))
283
- });
284
213
  ctx.webServer.register({
285
214
  kind: "prefix",
286
215
  path: ROUTE_CATALOG,
@@ -1251,7 +1180,7 @@ const inject = ["llm"];
1251
1180
  async function apply(ctx, config) {
1252
1181
  const runtime = await startRuntime(ctx, config);
1253
1182
  ctx.inject(["webServer"], (webCtx) => {
1254
- registerConfigApi(webCtx, runtime);
1183
+ registerCatalogApi(webCtx, runtime);
1255
1184
  });
1256
1185
  }
1257
1186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/llm-pi",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "dsh-plus service+ui plugin: 基于 PiAiAdapter 的自定义 LLM 路由(全量 compat、模型继承、models.dev 目录兜底)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -24,7 +24,9 @@
24
24
  "client": {
25
25
  "inject": [
26
26
  "@deepseek-ai/dsh-client-runtime",
27
- "@deepseek-ai/dsh-client-locale"
27
+ "@deepseek-ai/dsh-client-locale",
28
+ "@deepseek-ai/dsh-client-connection",
29
+ "@deepseek-ai/dsh-api-remotes"
28
30
  ],
29
31
  "platform": "web"
30
32
  }
package/src/client/api.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * 配置卡片数据通道:同源 fetch 调自建 webServer 路由(notify-email 同款模式;
3
- * 官方 settings.* RPC 白名单硬编码不含第三方 namespace)。
2
+ * 自定义端点通道:仅剩「模型目录」(配置读写已迁移到官方 settingsScope
3
+ * 传输,见 scope.ts card.tsx)。目录响应附带 kitSource 与 models-dev
4
+ * 状态,供卡片的状态行展示(运行期诊断,非配置数据)。
4
5
  * @module llm-pi/client/api
5
6
  */
6
7
 
@@ -43,35 +44,26 @@ export interface WireModel {
43
44
  compat?: Record<string, unknown>
44
45
  }
45
46
 
46
- /** GET /config 返回(config.ts WireConfig)。 */
47
- export interface WireConfig {
47
+ /** settings 命名空间的解析值(llm-pi secret 字段,value 即完整配置)。 */
48
+ export interface ConfigValue {
48
49
  enabled: boolean
49
50
  catalogUrl: string
50
51
  catalogRefreshHours: number
51
52
  catalogProxy: string
52
53
  providers: Record<string, WireProvider>
53
- writable: boolean
54
- kitSource: string
55
- modelsDevStatus: WireModelsDevStatus | null
56
54
  }
57
55
 
58
- /** PUT /config 提交形状(config.ts WirePatchInput;providers 全量替换)。 */
59
- export interface WirePatchInput {
60
- enabled?: boolean
61
- catalogUrl?: string
62
- catalogRefreshHours?: number
63
- catalogProxy?: string
64
- providers?: Record<string, WireProvider>
65
- }
56
+ /** 保存提交形状:完整配置对象,providers 全量替换(settings.replace 语义)。 */
57
+ export type ConfigPatch = ConfigValue
66
58
 
67
- /** GET /catalog?provider=&source= 返回。 */
59
+ /** GET /catalog?provider=&source= 返回(kitSource 为运行期套件来源诊断)。 */
68
60
  export interface CatalogResult {
69
61
  providers: string[]
70
62
  models: string[]
71
63
  status?: WireModelsDevStatus
64
+ kitSource?: string
72
65
  }
73
66
 
74
- const ROUTE_CONFIG = '/dsh-plus/llm-pi/config'
75
67
  const ROUTE_CATALOG = '/dsh-plus/llm-pi/catalog'
76
68
 
77
69
  async function parse<T>(res: Response): Promise<T> {
@@ -80,21 +72,6 @@ async function parse<T>(res: Response): Promise<T> {
80
72
  return body
81
73
  }
82
74
 
83
- export async function fetchConfig(): Promise<WireConfig> {
84
- return parse<WireConfig>(await fetch(ROUTE_CONFIG, { credentials: 'same-origin' }))
85
- }
86
-
87
- export async function saveConfig(patch: WirePatchInput): Promise<WireConfig> {
88
- return parse<WireConfig>(
89
- await fetch(ROUTE_CONFIG, {
90
- method: 'PUT',
91
- credentials: 'same-origin',
92
- headers: { 'content-type': 'application/json' },
93
- body: JSON.stringify(patch),
94
- }),
95
- )
96
- }
97
-
98
75
  /** 目录查询:provider 为空时只返回该源的 provider 列表。 */
99
76
  export async function fetchCatalog(provider: string, source: 'builtin' | 'models-dev'): Promise<CatalogResult> {
100
77
  const url = `${ROUTE_CATALOG}?provider=${encodeURIComponent(provider)}&source=${source}`
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * 「LLM 路由」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
3
3
  * 顶部:enabled / catalogUrl / catalogRefreshHours / 只读状态行(kitSource、
4
- * modelsDevStatus)+ 保存(PUT 全量)与错误/成功提示;下方为 providers 路由
5
- * 列表(新增/删除/字段编辑/compat/模型目录,见 views/)。
4
+ * modelsDevStatus,来自模型目录端点)+ 保存(settings.replace 全量)与
5
+ * 错误/成功提示;下方为 providers 路由列表(新增/删除/字段编辑/compat/模型
6
+ * 目录,见 views/)。配置读写走官方 settingsScope 传输(scope.ts)。
6
7
  * 交互对齐官方卡片与 notify-email:折叠/展开、staged draft、未保存标记。
7
8
  * @module llm-pi/client/card
8
9
  */
9
- import { useEffect, useMemo, useState, type ReactElement } from 'react'
10
+ import { useEffect, useMemo, useSyncExternalStore, useState, type ReactElement } from 'react'
10
11
 
11
- import { fetchConfig, refreshCatalog, saveConfig, type WireConfig, type WireModelsDevStatus } from './api.ts'
12
- import { draftFromWire, emptyProviderDraft, numTextOk, toPatch, type Draft, type ProviderDraft } from './draft.ts'
12
+ import { SETTINGS_NS } from '../ns.ts'
13
+ import { fetchCatalog, refreshCatalog, type ConfigValue, type WireModelsDevStatus } from './api.ts'
14
+ import { draftFromValue, emptyProviderDraft, numTextOk, toPatch, type Draft, type ProviderDraft } from './draft.ts'
13
15
  import { CheckRow, TextField } from './fields.tsx'
16
+ import type { Scope, SettingsApi } from './scope.ts'
14
17
  import { ProvidersSection } from './views/providers.tsx'
15
18
 
16
19
  export interface CardProps {
17
20
  t(key: string): string
21
+ scope: Scope
22
+ api: SettingsApi
18
23
  }
19
24
 
20
25
  interface Status {
@@ -32,27 +37,37 @@ function modelsDevText(status: WireModelsDevStatus | null, t: (key: string) => s
32
37
  }
33
38
 
34
39
  export function LlmPiCard(props: CardProps): ReactElement | null {
35
- const { t } = props
40
+ const { t, scope, api } = props
41
+ const snapshot = useSyncExternalStore(
42
+ (listener: () => void) => scope.subscribe(listener),
43
+ () => scope.getSnapshot(),
44
+ )
45
+ const value = snapshot.value as ConfigValue | undefined
36
46
  const [open, setOpen] = useState(false)
37
- const [wire, setWire] = useState<WireConfig | null>(null)
38
47
  const [draft, setDraft] = useState<Draft | null>(null)
39
48
  const [epoch, setEpoch] = useState(0)
40
- const [failed, setFailed] = useState(false)
49
+ const [kitSource, setKitSource] = useState<string | null>(null)
50
+ const [modelsDevStatus, setModelsDevStatus] = useState<WireModelsDevStatus | null>(null)
41
51
  const [saving, setSaving] = useState(false)
42
52
  const [refreshing, setRefreshing] = useState(false)
43
53
  const [status, setStatus] = useState<Status>(IDLE_STATUS)
44
54
 
55
+ // 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
56
+ useEffect(() => {
57
+ if (value === undefined || draft !== null) return
58
+ setDraft(draftFromValue(value))
59
+ }, [value, draft])
60
+
61
+ // 运行期诊断行(kitSource / models-dev 状态)来自模型目录端点,非配置数据。
45
62
  useEffect(() => {
46
63
  let alive = true
47
- fetchConfig()
48
- .then((loaded) => {
64
+ fetchCatalog('', 'models-dev')
65
+ .then((result) => {
49
66
  if (!alive) return
50
- setWire(loaded)
51
- setDraft(draftFromWire(loaded))
52
- })
53
- .catch(() => {
54
- if (alive) setFailed(true)
67
+ setKitSource(result.kitSource ?? null)
68
+ setModelsDevStatus(result.status ?? null)
55
69
  })
70
+ .catch(() => {})
56
71
  return () => {
57
72
  alive = false
58
73
  }
@@ -60,10 +75,9 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
60
75
 
61
76
  const dirty = useMemo(
62
77
  () =>
63
- wire !== null &&
64
- draft !== null &&
65
- JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))),
66
- [wire, draft],
78
+ value !== undefined && draft !== null &&
79
+ JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
80
+ [value, draft],
67
81
  )
68
82
  const invalid = useMemo(() => {
69
83
  if (draft === null) return false
@@ -75,8 +89,7 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
75
89
  )
76
90
  }, [draft])
77
91
 
78
- if (failed) return null
79
- if (wire === null || draft === null) {
92
+ if (value === undefined || draft === null) {
80
93
  return <li className="lpc-card"><p className="lpc-readOnly">{t('loading')}</p></li>
81
94
  }
82
95
 
@@ -97,10 +110,19 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
97
110
  }
98
111
  const onSave = (): void => {
99
112
  setSaving(true)
100
- saveConfig(toPatch(draft))
101
- .then((saved) => {
102
- setWire(saved)
103
- setDraft(draftFromWire(saved))
113
+ const revision = scope.getSnapshot().revision
114
+ api.replace({
115
+ ns: SETTINGS_NS,
116
+ section: toPatch(draft) as unknown as Record<string, unknown>,
117
+ ...(revision !== undefined ? { expectedRevision: revision } : {}),
118
+ })
119
+ .then(async (response) => {
120
+ if (!response.result.ok) {
121
+ throw new Error(response.result.error?.message ?? t('saveFailed'))
122
+ }
123
+ await scope.load()
124
+ const next = scope.getSnapshot().value as ConfigValue | undefined
125
+ if (next !== undefined) setDraft(draftFromValue(next))
104
126
  setEpoch((value) => value + 1)
105
127
  setStatus({ kind: 'ok', text: t('saveOk') })
106
128
  })
@@ -111,7 +133,7 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
111
133
  .finally(() => setSaving(false))
112
134
  }
113
135
  const onDiscard = (): void => {
114
- setDraft(draftFromWire(wire))
136
+ setDraft(draftFromValue(value))
115
137
  setEpoch((value) => value + 1)
116
138
  setStatus(IDLE_STATUS)
117
139
  }
@@ -119,7 +141,7 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
119
141
  setRefreshing(true)
120
142
  refreshCatalog()
121
143
  .then((result) => {
122
- setWire({ ...wire, modelsDevStatus: result.status })
144
+ setModelsDevStatus(result.status)
123
145
  setStatus({ kind: 'ok', text: t('refreshOk') })
124
146
  })
125
147
  .catch((error: unknown) => {
@@ -129,7 +151,7 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
129
151
  .finally(() => setRefreshing(false))
130
152
  }
131
153
 
132
- const disabled = !wire.writable
154
+ const disabled = !snapshot.writable
133
155
  return (
134
156
  <li className={`lpc-card${open ? ' lpc-cardOpen' : ''}`}>
135
157
  <button
@@ -195,9 +217,9 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
195
217
  setStatus(IDLE_STATUS)
196
218
  }}
197
219
  />
198
- <p className="lpc-statusRow">{t('kitSource')}:{wire.kitSource}</p>
220
+ <p className="lpc-statusRow">{t('kitSource')}:{kitSource ?? ''}</p>
199
221
  <div className="lpc-statusRow">
200
- <span>{t('modelsDevStatus')}:{modelsDevText(wire.modelsDevStatus, t)}</span>
222
+ <span>{t('modelsDevStatus')}:{modelsDevText(modelsDevStatus, t)}</span>
201
223
  <button
202
224
  type="button"
203
225
  className="lpc-btn lpc-btnGhost lpc-btnSmall lpc-refreshBtn"
@@ -3,12 +3,17 @@
3
3
  * 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
4
4
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
5
5
  *
6
- * 类型说明:浏览器半只用到 slots/locale 的很窄一面,此处以最小本地接口声明,
7
- * 避免为构建期类型引入整条官方 client 依赖树;运行时契约以官方
8
- * dsh-client-ui-settings-plugins 的 settings.plugin.item 插槽为准。
6
+ * 类型说明:浏览器半只用到 slots/locale/connection/remote 的很窄一面,
7
+ * 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
8
+ * client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
9
+ * settings.plugin.item 插槽与 dsh-client-connection 的 settings RPC 面为准。
9
10
  * rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
10
11
  * (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
11
12
  * 官方配置页按此 key 与 Host 已注册命名空间配对分发。
13
+ * 配置读写经官方 settings RPC 直连(rc7 起第三方命名空间全量开放;
14
+ * 不复用 settingsScope 服务——非 loopback 页面下它无数据,见 scope.ts);
15
+ * 自定义端点仅剩「模型目录」(api.ts,含 kitSource/models-dev
16
+ * 运行期诊断)。
12
17
  * @module @dsh-plus/llm-pi/client
13
18
  */
14
19
  import type { Context } from '@deepseek-ai/cordis'
@@ -16,12 +21,13 @@ import type { Context } from '@deepseek-ai/cordis'
16
21
  import { SETTINGS_NS } from '../ns.ts'
17
22
  import { LlmPiCard } from './card.tsx'
18
23
  import { en, NS, zh } from './i18n.ts'
24
+ import type { Scope, SettingsApi } from './scope.ts'
19
25
  import { injectStyle } from './styles.ts'
20
26
 
21
27
  export const name = 'dsh-plus-llm-pi'
22
28
 
23
29
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
24
- export const inject = ['slots', 'locale'] as const
30
+ export const inject = ['slots', 'locale', 'connection', 'remote'] as const
25
31
 
26
32
  interface SlotsLike {
27
33
  inject(key: string, callback: () => unknown): unknown
@@ -33,13 +39,86 @@ interface LocaleLike {
33
39
  bind(ns: string): (key: string) => string
34
40
  }
35
41
 
42
+ interface RemoteLike {
43
+ $on(event: string, listener: (payload?: unknown) => void): () => void
44
+ }
45
+
46
+ interface ConnectionLike {
47
+ api: { settings: SettingsApi }
48
+ }
49
+
36
50
  interface ClientContext {
37
51
  slots: SlotsLike
38
52
  locale: LocaleLike
53
+ get(key: 'connection'): ConnectionLike
54
+ get(key: 'remote'): RemoteLike
55
+ on(event: string, listener: () => void): () => void
39
56
  effect(execute: () => () => void, label?: string): unknown
40
57
  }
41
58
 
59
+ /**
60
+ * api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
61
+ * 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
62
+ * 刷新:remote `settings/document-updated`(按命名空间过滤)与
63
+ * `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
64
+ * (loopback 或 tailnet 信任域名)下行为一致。
65
+ */
66
+ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
67
+ let state: ScopeSnapshot = { status: 'loading', value: undefined, revision: undefined, writable: false }
68
+ const listeners = new Set<() => void>()
69
+ let generation = 0
70
+ const publish = (next: ScopeSnapshot): void => {
71
+ state = next
72
+ for (const listener of [...listeners]) listener()
73
+ }
74
+ const load = async (): Promise<void> => {
75
+ const gen = ++generation
76
+ let response
77
+ try {
78
+ response = await api.describe({})
79
+ } catch {
80
+ return
81
+ }
82
+ if (gen !== generation || !response.result.ok) return
83
+ const writable = response.result.value?.writable ?? false
84
+ const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
85
+ if (view === undefined) {
86
+ publish({ status: 'unavailable', value: undefined, revision: undefined, writable })
87
+ return
88
+ }
89
+ publish({ status: 'ready', value: view.value, revision: view.revision, writable })
90
+ }
91
+ const refresh = (namespace?: unknown): void => {
92
+ if (namespace !== undefined && namespace !== ns) return
93
+ void load()
94
+ }
95
+ const disposers = [
96
+ c.get('remote').$on('settings/document-updated', refresh),
97
+ c.on('connection/reset', () => {
98
+ void load()
99
+ }),
100
+ ]
101
+ c.effect(
102
+ () => () => {
103
+ for (const dispose of disposers) dispose()
104
+ },
105
+ 'llm-pi: settings scope',
106
+ )
107
+ void load()
108
+ return {
109
+ getSnapshot: () => state,
110
+ subscribe: (listener: () => void) => {
111
+ listeners.add(listener)
112
+ return () => {
113
+ listeners.delete(listener)
114
+ }
115
+ },
116
+ load,
117
+ }
118
+ }
119
+
42
120
  export function apply(ctx: Context): void {
121
+
43
122
  const c = ctx as unknown as ClientContext
44
123
  const tag = injectStyle()
45
124
  c.effect(
@@ -52,14 +131,15 @@ export function apply(ctx: Context): void {
52
131
  () => c.locale.register(NS, { zh, en }),
53
132
  'llm-pi: locale',
54
133
  )
134
+ const api = c.get('connection').api.settings
135
+ const scope = createApiScope(api, SETTINGS_NS, c)
55
136
  c.slots.inject('settings.plugin.item', () =>
56
137
  c.slots.register(
57
138
  {
58
139
  name: 'settings.plugin.item',
59
140
  key: SETTINGS_NS,
60
- order: 110,
61
141
  locale: NS,
62
- inject: () => ({ t: c.locale.bind(NS) }),
142
+ inject: () => ({ t: c.locale.bind(NS), scope, api }),
63
143
  },
64
144
  LlmPiCard,
65
145
  ),
@@ -1,10 +1,10 @@
1
1
  /**
2
- * 可编辑草稿模型与 WireConfig ↔ Draft 双向转换。
2
+ * 可编辑草稿模型与 ConfigValue ↔ Draft 双向转换。
3
3
  * 设计:数值字段用字符串承载('' = 未设置/不写入),多选枚举用布尔 map,
4
4
  * 转换时剔除空值,保证 dirty 比较(toPatch 双侧)与保存形状稳定。
5
5
  * @module llm-pi/client/draft
6
6
  */
7
- import type { WireConfig, WireModel, WirePatchInput, WireProvider } from './api.ts'
7
+ import type { ConfigPatch, ConfigValue, WireModel, WireProvider } from './api.ts'
8
8
 
9
9
  export interface HeaderPair {
10
10
  key: string
@@ -267,20 +267,20 @@ export function emptyModelDraft(): ModelDraft {
267
267
  }
268
268
  }
269
269
 
270
- export function draftFromWire(wire: WireConfig): Draft {
270
+ export function draftFromValue(value: ConfigValue): Draft {
271
271
  return {
272
- enabled: wire.enabled,
273
- catalogUrl: wire.catalogUrl,
274
- catalogRefreshHours: String(wire.catalogRefreshHours),
275
- catalogProxy: wire.catalogProxy,
272
+ enabled: value.enabled,
273
+ catalogUrl: value.catalogUrl,
274
+ catalogRefreshHours: String(value.catalogRefreshHours),
275
+ catalogProxy: value.catalogProxy,
276
276
  providers: Object.fromEntries(
277
- Object.entries(wire.providers).map(([route, provider]) => [route, providerDraftFromWire(provider)]),
277
+ Object.entries(value.providers).map(([route, provider]) => [route, providerDraftFromWire(provider)]),
278
278
  ),
279
279
  }
280
280
  }
281
281
 
282
282
  /** 提交补丁:完整配置对象,providers 全量替换;空值一律剔除。 */
283
- export function toPatch(draft: Draft): WirePatchInput {
283
+ export function toPatch(draft: Draft): ConfigPatch {
284
284
  return {
285
285
  enabled: draft.enabled,
286
286
  catalogUrl: draft.catalogUrl.trim(),
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 浏览器半 settingsScope/connection 的最小本地接口声明。
3
+ * 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
4
+ * 运行时契约以官方 dsh-client-connection 的 settings RPC 面为准
5
+ * (rc7:白名单已移除,任何已注册命名空间均可 describe/update/replace)。
6
+ *
7
+ * 实现说明:本插件不复用 settingsScope 服务的 bind()——它在非 loopback
8
+ * 页面(如经 tailnet 域名访问)下走 memory 模式、永远无数据;这里按官方
9
+ * 配置页 tab 的同款模式用 `api.settings` 直连实现 Scope(describe 读 +
10
+ * remote `settings/document-updated` / `connection/reset` 刷新,见 client.ts)。
11
+ * @module {pkg}/client/scope
12
+ */
13
+
14
+ /** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
15
+ export interface ScopeSnapshot {
16
+ status: 'loading' | 'ready' | 'unavailable'
17
+ /** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
18
+ value: unknown
19
+ /** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
20
+ revision: number | undefined
21
+ /** Host 文档是否可写。 */
22
+ writable: boolean
23
+ }
24
+
25
+ /** settingsScope.bind({namespace}) 返回的控制器面。 */
26
+ export interface Scope {
27
+ getSnapshot(): ScopeSnapshot
28
+ subscribe(listener: () => void): () => void
29
+ load(): Promise<void>
30
+ }
31
+
32
+ /** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
33
+ export interface SettingsApi {
34
+ describe(payload?: Record<string, never>): Promise<{ result: { ok: boolean; value?: { namespaces: Array<{ ns: string; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
35
+ update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
36
+ replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
37
+ }
package/src/config-api.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
- * 配置 HTTP 通道:webui 配置卡片的后端(notify-email 同款模式)。
3
- * 背景:官方 apiproxy 的 settings.* RPC 对 namespace 有硬编码白名单,第三方
4
- * namespace 一律 settings-not-exposed;卡片数据走自建 webServer 同源路由,
5
- * 写回经 ctx.settings.replace 整段覆盖用户层(providers dict 的删除语义
6
- * 无法经深合并表达)。写入先过 settings 校验钩子(完整解析试跑),
7
- * 非法配置在写入处拒绝并返回错误明细。
2
+ * 自定义端点 HTTP 通道:配置读写已迁移到官方 settings RPC(rc7 起第三方
3
+ * 命名空间全量开放),这里只保留官方传输没有的「模型目录」端点(含
4
+ * kitSource / models-dev 运行期诊断,供卡片状态行展示)。
5
+ * 仅监听 dsh web 同源(webserver 默认 127.0.0.1),与 GUI 其余面同等暴露面。
8
6
  * @module llm-pi/config-api
9
7
  */
10
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -12,78 +10,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
12
10
  import type { Context } from '@deepseek-ai/cordis'
13
11
 
14
12
  import { builtinModelIds } from './catalog/builtin.ts'
15
- import { SETTINGS_NS, toWire, WirePatch, type WirePatchInput } from './config.ts'
16
13
  import type { LlmPiRuntime } from './service.ts'
17
14
 
18
- const ROUTE_CONFIG = '/dsh-plus/llm-pi/config'
19
15
  const ROUTE_CATALOG = '/dsh-plus/llm-pi/catalog'
20
- const MAX_BODY_BYTES = 256 * 1024
21
16
 
22
17
  function sendJson(res: ServerResponse, status: number, body: unknown): void {
23
18
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
24
19
  res.end(JSON.stringify(body))
25
20
  }
26
21
 
27
- function readBody(req: IncomingMessage): Promise<string> {
28
- return new Promise((resolve, reject) => {
29
- const chunks: Buffer[] = []
30
- let size = 0
31
- req.on('data', (chunk: Buffer) => {
32
- size += chunk.length
33
- if (size > MAX_BODY_BYTES) {
34
- reject(new Error('request body too large'))
35
- req.destroy()
36
- return
37
- }
38
- chunks.push(chunk)
39
- })
40
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
41
- req.on('error', reject)
42
- })
43
- }
44
-
45
- async function readPatch(req: IncomingMessage): Promise<WirePatchInput> {
46
- const raw = await readBody(req)
47
- let parsed: unknown
48
- try {
49
- parsed = JSON.parse(raw)
50
- } catch {
51
- throw new Error('request body is not valid JSON')
52
- }
53
- return WirePatch(parsed) as WirePatchInput
54
- }
55
-
56
- function wireOf(runtime: LlmPiRuntime, writable: boolean) {
57
- // 状态始终上报(含未拉取/失败),由卡片区分文案;不再用 enabled 做 null 开关。
58
- return toWire(runtime.currentConfig(), writable, runtime.kitInfo().source, runtime.modelsDev.status())
59
- }
60
-
61
- async function handleConfig(
62
- ctx: Context,
63
- runtime: LlmPiRuntime,
64
- req: IncomingMessage,
65
- res: ServerResponse,
66
- ): Promise<void> {
67
- const settings = ctx.get('settings')
68
- if (req.method === 'GET') {
69
- sendJson(res, 200, wireOf(runtime, settings !== undefined))
70
- return
71
- }
72
- if (req.method !== 'PUT') {
73
- sendJson(res, 405, { error: 'method not allowed' })
74
- return
75
- }
76
- if (settings === undefined) {
77
- sendJson(res, 503, { error: 'settings provider 不可用,无法在线保存;请编辑 settings.yaml' })
78
- return
79
- }
80
- const patch = await readPatch(req)
81
- await settings.replace(SETTINGS_NS, patch)
82
- sendJson(res, 200, wireOf(runtime, true))
83
- }
84
-
85
22
  /** 目录查询/手动拉取:GET ?provider=&source= → 该源模型 id 列表;POST /refresh → 立即拉取。 */
86
23
  function handleCatalog(runtime: LlmPiRuntime, req: IncomingMessage, res: ServerResponse): void {
24
+ const kitSource = runtime.kitInfo().source
87
25
  if (req.method === 'POST' && req.url?.endsWith('/refresh')) {
88
26
  void runtime.modelsDev.refresh().then(() => {
89
27
  sendJson(res, 200, { status: runtime.modelsDev.status() })
@@ -98,17 +36,19 @@ function handleCatalog(runtime: LlmPiRuntime, req: IncomingMessage, res: ServerR
98
36
  providers: runtime.modelsDev.providerIds(),
99
37
  models: provider.length > 0 ? runtime.modelsDev.modelIds(provider) : [],
100
38
  status: runtime.modelsDev.status(),
39
+ kitSource,
101
40
  })
102
41
  return
103
42
  }
104
43
  sendJson(res, 200, {
105
44
  providers: runtime.kit.getBuiltinProviders(),
106
45
  models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : [],
46
+ kitSource,
107
47
  })
108
48
  }
109
49
 
110
- /** 注册配置读写与目录查询路由(webServer 缺失时由调用方保证不调用)。 */
111
- export function registerConfigApi(ctx: Context, runtime: LlmPiRuntime): void {
50
+ /** 注册目录路由(webServer 缺失时由调用方保证不调用)。 */
51
+ export function registerCatalogApi(ctx: Context, runtime: LlmPiRuntime): void {
112
52
  const logger = ctx.logger('llm-pi')
113
53
  const guard = (handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> | void) => {
114
54
  return async (req: IncomingMessage, res: ServerResponse) => {
@@ -116,17 +56,12 @@ export function registerConfigApi(ctx: Context, runtime: LlmPiRuntime): void {
116
56
  await handler(req, res)
117
57
  } catch (error) {
118
58
  const message = error instanceof Error ? error.message : String(error)
119
- logger.warn(`config api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
59
+ logger.warn(`catalog api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
120
60
  if (!res.headersSent) sendJson(res, 400, { error: message })
121
61
  else res.end()
122
62
  }
123
63
  }
124
64
  }
125
- ctx.webServer.register({
126
- kind: 'exact',
127
- path: ROUTE_CONFIG,
128
- handler: guard((req, res) => handleConfig(ctx, runtime, req, res)),
129
- })
130
65
  ctx.webServer.register({
131
66
  kind: 'prefix',
132
67
  path: ROUTE_CATALOG,
package/src/config.ts CHANGED
@@ -156,57 +156,5 @@ export const Config: z<LlmPiConfig> = z.object({
156
156
  providers: z.dict(providerProfile).description('provider 路由表,键即 route 名').default({}),
157
157
  })
158
158
 
159
- /** 配置卡片读取用的传输对象:配置无密钥字段,原样传输;附运行期元信息。 */
160
- export interface WireConfig {
161
- enabled: boolean
162
- catalogUrl: string
163
- catalogRefreshHours: number
164
- catalogProxy: string
165
- providers: Record<string, ProviderProfileConfig>
166
- /** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
167
- writable: boolean
168
- /** 模块解析来源与自检结果(dsh 树 / vendored 兜底)。 */
169
- kitSource: string
170
- /** models.dev 快照状态(fetchedAt/模型数/错误),供卡片展示。 */
171
- modelsDevStatus: { fetchedAt: string | null; providers: number; models: number; error: string | null } | null
172
- }
173
-
174
- export function toWire(
175
- cfg: LlmPiConfig,
176
- writable: boolean,
177
- kitSource: string,
178
- modelsDevStatus: WireConfig['modelsDevStatus'],
179
- ): WireConfig {
180
- return {
181
- enabled: cfg.enabled,
182
- catalogUrl: cfg.catalogUrl,
183
- catalogRefreshHours: cfg.catalogRefreshHours,
184
- catalogProxy: cfg.catalogProxy ?? '',
185
- providers: cfg.providers ?? {},
186
- writable,
187
- kitSource,
188
- modelsDevStatus,
189
- }
190
- }
191
-
192
- /**
193
- * 配置卡片写回:卡片总是提交完整配置对象(含 providers 全量),
194
- * 后端经 settings.replace 整段覆盖用户层——providers dict 的删除语义
195
- * 无法经深合并表达,整段替换是唯一正确语义。
196
- */
197
- /** 卡片提交的形状:字段全可选(无默认值),只携带用户编辑过的字段。 */
198
- export interface WirePatchInput {
199
- enabled?: boolean
200
- catalogUrl?: string
201
- catalogRefreshHours?: number
202
- catalogProxy?: string
203
- providers?: Record<string, ProviderProfileConfig>
204
- }
205
-
206
- export const WirePatch: z<WirePatchInput> = z.object({
207
- enabled: z.boolean(),
208
- catalogUrl: z.string(),
209
- catalogRefreshHours: z.number(),
210
- catalogProxy: z.string(),
211
- providers: z.dict(providerProfile),
212
- })
159
+ /** 卡片提交的形状:完整配置对象(含 providers 全量),settings.replace 整段覆盖。 */
160
+ export type LlmPiPatch = LlmPiConfig
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import type { Context } from '@deepseek-ai/cordis'
14
14
 
15
15
  import { Config, type LlmPiConfig } from './config.ts'
16
- import { registerConfigApi } from './config-api.ts'
16
+ import { registerCatalogApi } from './config-api.ts'
17
17
  import { startRuntime } from './service.ts'
18
18
 
19
19
  export const name = 'dsh-plus-llm-pi'
@@ -22,12 +22,12 @@ export const inject = ['llm'] as const
22
22
 
23
23
  export { Config }
24
24
 
25
- export type { LlmPiConfig, ProviderProfileConfig, ModelEntryConfig, WireConfig } from './config.ts'
25
+ export type { LlmPiConfig, ProviderProfileConfig, ModelEntryConfig } from './config.ts'
26
26
  export { SETTINGS_NS } from './config.ts'
27
27
 
28
28
  export async function apply(ctx: Context, config: LlmPiConfig): Promise<void> {
29
29
  const runtime = await startRuntime(ctx, config)
30
30
  ctx.inject(['webServer'], (webCtx) => {
31
- registerConfigApi(webCtx, runtime)
31
+ registerCatalogApi(webCtx, runtime)
32
32
  })
33
33
  }