@dsh-plus/llm-pi 0.1.0 → 0.1.2

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
@@ -32,25 +32,25 @@ react = __toESM(react);
32
32
  let react_jsx_runtime = require("react/jsx-runtime");
33
33
  react_jsx_runtime = __toESM(react_jsx_runtime);
34
34
 
35
+ //#region src/ns.ts
36
+ /**
37
+ * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
38
+ * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
39
+ * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
40
+ * 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
41
+ * 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
42
+ * @module llm-pi/ns
43
+ */
44
+ const SETTINGS_NS = "dsh-plus-llm-pi";
45
+
46
+ //#endregion
35
47
  //#region src/client/api.ts
36
- const ROUTE_CONFIG = "/dsh-plus/llm-pi/config";
37
48
  const ROUTE_CATALOG = "/dsh-plus/llm-pi/catalog";
38
49
  async function parse(res) {
39
50
  const body = await res.json();
40
51
  if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
41
52
  return body;
42
53
  }
43
- async function fetchConfig() {
44
- return parse(await fetch(ROUTE_CONFIG, { credentials: "same-origin" }));
45
- }
46
- async function saveConfig(patch) {
47
- return parse(await fetch(ROUTE_CONFIG, {
48
- method: "PUT",
49
- credentials: "same-origin",
50
- headers: { "content-type": "application/json" },
51
- body: JSON.stringify(patch)
52
- }));
53
- }
54
54
  /** 目录查询:provider 为空时只返回该源的 provider 列表。 */
55
55
  async function fetchCatalog(provider, source) {
56
56
  const url = `${ROUTE_CATALOG}?provider=${encodeURIComponent(provider)}&source=${source}`;
@@ -272,13 +272,13 @@ function emptyModelDraft() {
272
272
  compat: {}
273
273
  };
274
274
  }
275
- function draftFromWire(wire) {
275
+ function draftFromValue(value) {
276
276
  return {
277
- enabled: wire.enabled,
278
- catalogUrl: wire.catalogUrl,
279
- catalogRefreshHours: String(wire.catalogRefreshHours),
280
- catalogProxy: wire.catalogProxy,
281
- 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)]))
282
282
  };
283
283
  }
284
284
  /** 提交补丁:完整配置对象,providers 全量替换;空值一律剔除。 */
@@ -1332,35 +1332,38 @@ function modelsDevText(status, t) {
1332
1332
  return `${t("modelsDevStatusLine")}:${status.providers} 个 provider,快照 ${status.fetchedAt}`;
1333
1333
  }
1334
1334
  function LlmPiCard(props) {
1335
- 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;
1336
1338
  const [open, setOpen] = (0, react.useState)(false);
1337
- const [wire, setWire] = (0, react.useState)(null);
1338
1339
  const [draft, setDraft] = (0, react.useState)(null);
1339
1340
  const [epoch, setEpoch] = (0, react.useState)(0);
1340
- const [failed, setFailed] = (0, react.useState)(false);
1341
+ const [kitSource, setKitSource] = (0, react.useState)(null);
1342
+ const [modelsDevStatus, setModelsDevStatus] = (0, react.useState)(null);
1341
1343
  const [saving, setSaving] = (0, react.useState)(false);
1342
1344
  const [refreshing, setRefreshing] = (0, react.useState)(false);
1343
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]);
1344
1350
  (0, react.useEffect)(() => {
1345
1351
  let alive = true;
1346
- fetchConfig().then((loaded) => {
1352
+ fetchCatalog("", "models-dev").then((result) => {
1347
1353
  if (!alive) return;
1348
- setWire(loaded);
1349
- setDraft(draftFromWire(loaded));
1350
- }).catch(() => {
1351
- if (alive) setFailed(true);
1352
- });
1354
+ setKitSource(result.kitSource ?? null);
1355
+ setModelsDevStatus(result.status ?? null);
1356
+ }).catch(() => {});
1353
1357
  return () => {
1354
1358
  alive = false;
1355
1359
  };
1356
1360
  }, []);
1357
- 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]);
1358
1362
  const invalid = (0, react.useMemo)(() => {
1359
1363
  if (draft === null) return false;
1360
1364
  return !numTextOk(draft.catalogRefreshHours) || Object.values(draft.providers).some((provider) => provider.models.some((model) => model.id.trim() === ""));
1361
1365
  }, [draft]);
1362
- if (failed) return null;
1363
- 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", {
1364
1367
  className: "lpc-card",
1365
1368
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1366
1369
  className: "lpc-readOnly",
@@ -1402,10 +1405,17 @@ function LlmPiCard(props) {
1402
1405
  };
1403
1406
  const onSave = () => {
1404
1407
  setSaving(true);
1405
- saveConfig(toPatch(draft)).then((saved) => {
1406
- setWire(saved);
1407
- setDraft(draftFromWire(saved));
1408
- 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);
1409
1419
  setStatus({
1410
1420
  kind: "ok",
1411
1421
  text: t("saveOk")
@@ -1419,17 +1429,14 @@ function LlmPiCard(props) {
1419
1429
  }).finally(() => setSaving(false));
1420
1430
  };
1421
1431
  const onDiscard = () => {
1422
- setDraft(draftFromWire(wire));
1423
- setEpoch((value) => value + 1);
1432
+ setDraft(draftFromValue(value));
1433
+ setEpoch((value$1) => value$1 + 1);
1424
1434
  setStatus(IDLE_STATUS);
1425
1435
  };
1426
1436
  const onRefreshCatalog = () => {
1427
1437
  setRefreshing(true);
1428
1438
  refreshCatalog().then((result) => {
1429
- setWire({
1430
- ...wire,
1431
- modelsDevStatus: result.status
1432
- });
1439
+ setModelsDevStatus(result.status);
1433
1440
  setStatus({
1434
1441
  kind: "ok",
1435
1442
  text: t("refreshOk")
@@ -1442,7 +1449,7 @@ function LlmPiCard(props) {
1442
1449
  });
1443
1450
  }).finally(() => setRefreshing(false));
1444
1451
  };
1445
- const disabled = !wire.writable;
1452
+ const disabled = !snapshot.writable;
1446
1453
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1447
1454
  className: `lpc-card${open ? " lpc-cardOpen" : ""}`,
1448
1455
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -1484,10 +1491,10 @@ function LlmPiCard(props) {
1484
1491
  label: t("enabled"),
1485
1492
  checked: draft.enabled,
1486
1493
  disabled,
1487
- onEdit: (value) => {
1494
+ onEdit: (value$1) => {
1488
1495
  setDraft({
1489
1496
  ...draft,
1490
- enabled: value
1497
+ enabled: value$1
1491
1498
  });
1492
1499
  setStatus(IDLE_STATUS);
1493
1500
  }
@@ -1498,10 +1505,10 @@ function LlmPiCard(props) {
1498
1505
  hint: t("catalogUrlHint"),
1499
1506
  value: draft.catalogUrl,
1500
1507
  disabled,
1501
- onEdit: (value) => {
1508
+ onEdit: (value$1) => {
1502
1509
  setDraft({
1503
1510
  ...draft,
1504
- catalogUrl: value
1511
+ catalogUrl: value$1
1505
1512
  });
1506
1513
  setStatus(IDLE_STATUS);
1507
1514
  }
@@ -1515,10 +1522,10 @@ function LlmPiCard(props) {
1515
1522
  disabled,
1516
1523
  invalid: !numTextOk(draft.catalogRefreshHours),
1517
1524
  invalidLabel: t("invalidNumber"),
1518
- onEdit: (value) => {
1525
+ onEdit: (value$1) => {
1519
1526
  setDraft({
1520
1527
  ...draft,
1521
- catalogRefreshHours: value
1528
+ catalogRefreshHours: value$1
1522
1529
  });
1523
1530
  setStatus(IDLE_STATUS);
1524
1531
  }
@@ -1529,10 +1536,10 @@ function LlmPiCard(props) {
1529
1536
  hint: t("catalogProxyHint"),
1530
1537
  value: draft.catalogProxy,
1531
1538
  disabled,
1532
- onEdit: (value) => {
1539
+ onEdit: (value$1) => {
1533
1540
  setDraft({
1534
1541
  ...draft,
1535
- catalogProxy: value
1542
+ catalogProxy: value$1
1536
1543
  });
1537
1544
  setStatus(IDLE_STATUS);
1538
1545
  }
@@ -1542,7 +1549,7 @@ function LlmPiCard(props) {
1542
1549
  children: [
1543
1550
  t("kitSource"),
1544
1551
  ":",
1545
- wire.kitSource
1552
+ kitSource ?? ""
1546
1553
  ]
1547
1554
  }),
1548
1555
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1550,7 +1557,7 @@ function LlmPiCard(props) {
1550
1557
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1551
1558
  t("modelsDevStatus"),
1552
1559
  ":",
1553
- modelsDevText(wire.modelsDevStatus, t)
1560
+ modelsDevText(modelsDevStatus, t)
1554
1561
  ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1555
1562
  type: "button",
1556
1563
  className: "lpc-btn lpc-btnGhost lpc-btnSmall lpc-refreshBtn",
@@ -1875,7 +1882,12 @@ function injectStyle() {
1875
1882
  //#region src/client/client.ts
1876
1883
  const name = "dsh-plus-llm-pi";
1877
1884
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
1878
- const inject = ["slots", "locale"];
1885
+ const inject = [
1886
+ "slots",
1887
+ "locale",
1888
+ "settingsScope",
1889
+ "connection"
1890
+ ];
1879
1891
  function apply(ctx) {
1880
1892
  const c = ctx;
1881
1893
  const tag = injectStyle();
@@ -1886,12 +1898,17 @@ function apply(ctx) {
1886
1898
  zh,
1887
1899
  en
1888
1900
  }), "llm-pi: locale");
1901
+ const scope = c.settingsScope.bind({ namespace: SETTINGS_NS });
1902
+ const api = c.get("connection").api.settings;
1889
1903
  c.slots.inject("settings.plugin.item", () => c.slots.register({
1890
1904
  name: "settings.plugin.item",
1891
- id: "llm-pi",
1892
- order: 110,
1905
+ key: SETTINGS_NS,
1893
1906
  locale: NS,
1894
- inject: () => ({ t: c.locale.bind(NS) })
1907
+ inject: () => ({
1908
+ t: c.locale.bind(NS),
1909
+ scope,
1910
+ api
1911
+ })
1895
1912
  }, LlmPiCard));
1896
1913
  }
1897
1914
 
package/lib/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import { Context } from "@deepseek-ai/cordis";
4
4
 
5
5
  //#region src/config.d.ts
6
6
 
7
- /** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
7
+ /** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
8
8
  declare const SETTINGS_NS: _deepseek_ai_dsh_settings0.SettingsNamespace;
9
9
  /** 本插件可为手写 route 提供的协议实现(与官方 PROTOCOLS 表一致)。 */
10
10
  declare const PROTOCOL_IDS: readonly ["openai-completions", "openai-responses", "anthropic-messages"];
@@ -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
@@ -17,9 +17,21 @@ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.l
17
17
  import * as vendoredLlm from "@deepseek-ai/dsh-llm";
18
18
  import * as vendoredPiAiAdapter from "@deepseek-ai/dsh-llm-pi-ai";
19
19
 
20
+ //#region src/ns.ts
21
+ /**
22
+ * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
23
+ * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
24
+ * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
25
+ * 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
26
+ * 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
27
+ * @module llm-pi/ns
28
+ */
29
+ const SETTINGS_NS$1 = "dsh-plus-llm-pi";
30
+
31
+ //#endregion
20
32
  //#region src/config.ts
21
- /** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
22
- const SETTINGS_NS = settingsNamespace("dsh-plus-llm-pi");
33
+ /** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
34
+ const SETTINGS_NS = settingsNamespace(SETTINGS_NS$1);
23
35
  /** 本插件可为手写 route 提供的协议实现(与官方 PROTOCOLS 表一致)。 */
24
36
  const PROTOCOL_IDS = [
25
37
  "openai-completions",
@@ -103,25 +115,6 @@ const Config = z.object({
103
115
  catalogProxy: z.string().description("拉取 models.dev 目录时的 HTTP 代理地址(如 http://127.0.0.1:7890);留空直连").default(""),
104
116
  providers: z.dict(providerProfile).description("provider 路由表,键即 route 名").default({})
105
117
  });
106
- function toWire(cfg, writable, kitSource, modelsDevStatus) {
107
- return {
108
- enabled: cfg.enabled,
109
- catalogUrl: cfg.catalogUrl,
110
- catalogRefreshHours: cfg.catalogRefreshHours,
111
- catalogProxy: cfg.catalogProxy ?? "",
112
- providers: cfg.providers ?? {},
113
- writable,
114
- kitSource,
115
- modelsDevStatus
116
- };
117
- }
118
- const WirePatch = z.object({
119
- enabled: z.boolean(),
120
- catalogUrl: z.string(),
121
- catalogRefreshHours: z.number(),
122
- catalogProxy: z.string(),
123
- providers: z.dict(providerProfile)
124
- });
125
118
 
126
119
  //#endregion
127
120
  //#region src/catalog/builtin.ts
@@ -171,63 +164,14 @@ function inheritedCatalogEntries(kit, provider) {
171
164
 
172
165
  //#endregion
173
166
  //#region src/config-api.ts
174
- const ROUTE_CONFIG = "/dsh-plus/llm-pi/config";
175
167
  const ROUTE_CATALOG = "/dsh-plus/llm-pi/catalog";
176
- const MAX_BODY_BYTES = 256 * 1024;
177
168
  function sendJson(res, status, body) {
178
169
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
179
170
  res.end(JSON.stringify(body));
180
171
  }
181
- function readBody(req) {
182
- return new Promise((resolve, reject) => {
183
- const chunks = [];
184
- let size = 0;
185
- req.on("data", (chunk) => {
186
- size += chunk.length;
187
- if (size > MAX_BODY_BYTES) {
188
- reject(/* @__PURE__ */ new Error("request body too large"));
189
- req.destroy();
190
- return;
191
- }
192
- chunks.push(chunk);
193
- });
194
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
195
- req.on("error", reject);
196
- });
197
- }
198
- async function readPatch(req) {
199
- const raw = await readBody(req);
200
- let parsed;
201
- try {
202
- parsed = JSON.parse(raw);
203
- } catch {
204
- throw new Error("request body is not valid JSON");
205
- }
206
- return WirePatch(parsed);
207
- }
208
- function wireOf(runtime, writable) {
209
- return toWire(runtime.currentConfig(), writable, runtime.kitInfo().source, runtime.modelsDev.status());
210
- }
211
- async function handleConfig(ctx, runtime, req, res) {
212
- const settings = ctx.get("settings");
213
- if (req.method === "GET") {
214
- sendJson(res, 200, wireOf(runtime, settings !== void 0));
215
- return;
216
- }
217
- if (req.method !== "PUT") {
218
- sendJson(res, 405, { error: "method not allowed" });
219
- return;
220
- }
221
- if (settings === void 0) {
222
- sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
223
- return;
224
- }
225
- const patch = await readPatch(req);
226
- await settings.replace(SETTINGS_NS, patch);
227
- sendJson(res, 200, wireOf(runtime, true));
228
- }
229
172
  /** 目录查询/手动拉取:GET ?provider=&source= → 该源模型 id 列表;POST /refresh → 立即拉取。 */
230
173
  function handleCatalog(runtime, req, res) {
174
+ const kitSource = runtime.kitInfo().source;
231
175
  if (req.method === "POST" && req.url?.endsWith("/refresh")) {
232
176
  runtime.modelsDev.refresh().then(() => {
233
177
  sendJson(res, 200, { status: runtime.modelsDev.status() });
@@ -240,17 +184,19 @@ function handleCatalog(runtime, req, res) {
240
184
  sendJson(res, 200, {
241
185
  providers: runtime.modelsDev.providerIds(),
242
186
  models: provider.length > 0 ? runtime.modelsDev.modelIds(provider) : [],
243
- status: runtime.modelsDev.status()
187
+ status: runtime.modelsDev.status(),
188
+ kitSource
244
189
  });
245
190
  return;
246
191
  }
247
192
  sendJson(res, 200, {
248
193
  providers: runtime.kit.getBuiltinProviders(),
249
- models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : []
194
+ models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : [],
195
+ kitSource
250
196
  });
251
197
  }
252
- /** 注册配置读写与目录查询路由(webServer 缺失时由调用方保证不调用)。 */
253
- function registerConfigApi(ctx, runtime) {
198
+ /** 注册目录路由(webServer 缺失时由调用方保证不调用)。 */
199
+ function registerCatalogApi(ctx, runtime) {
254
200
  const logger = ctx.logger("llm-pi");
255
201
  const guard = (handler) => {
256
202
  return async (req, res) => {
@@ -258,17 +204,12 @@ function registerConfigApi(ctx, runtime) {
258
204
  await handler(req, res);
259
205
  } catch (error) {
260
206
  const message = error instanceof Error ? error.message : String(error);
261
- logger.warn(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
207
+ logger.warn(`catalog api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
262
208
  if (!res.headersSent) sendJson(res, 400, { error: message });
263
209
  else res.end();
264
210
  }
265
211
  };
266
212
  };
267
- ctx.webServer.register({
268
- kind: "exact",
269
- path: ROUTE_CONFIG,
270
- handler: guard((req, res) => handleConfig(ctx, runtime, req, res))
271
- });
272
213
  ctx.webServer.register({
273
214
  kind: "prefix",
274
215
  path: ROUTE_CATALOG,
@@ -1239,7 +1180,7 @@ const inject = ["llm"];
1239
1180
  async function apply(ctx, config) {
1240
1181
  const runtime = await startRuntime(ctx, config);
1241
1182
  ctx.inject(["webServer"], (webCtx) => {
1242
- registerConfigApi(webCtx, runtime);
1183
+ registerCatalogApi(webCtx, runtime);
1243
1184
  });
1244
1185
  }
1245
1186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/llm-pi",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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-client-ui-settings"
28
30
  ],
29
31
  "platform": "web"
30
32
  }
@@ -32,17 +34,17 @@
32
34
  "dependencies": {
33
35
  "@deepseek-ai/cordis": "4.0.1",
34
36
  "@deepseek-ai/schemastery": "3.18.1",
35
- "@deepseek-ai/dsh-settings": "0.1.0-rc.6",
36
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
37
- "@deepseek-ai/dsh-credentials": "0.1.0-rc.6",
38
- "@deepseek-ai/dsh-home-paths": "0.1.0-rc.6",
39
- "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.6",
40
- "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-settings": "0.1.0-rc.7",
38
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.7",
39
+ "@deepseek-ai/dsh-credentials": "0.1.0-rc.7",
40
+ "@deepseek-ai/dsh-home-paths": "0.1.0-rc.7",
41
+ "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.7",
42
+ "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.7",
41
43
  "@earendil-works/pi-ai": "0.82.1",
42
44
  "https-proxy-agent": "^7.0.6"
43
45
  },
44
46
  "devDependencies": {
45
- "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
47
+ "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7",
46
48
  "@types/react": "~18.3.1",
47
49
  "react": "^18.2.0"
48
50
  },
@@ -52,7 +54,7 @@
52
54
  "license": "MIT",
53
55
  "repository": {
54
56
  "type": "git",
55
- "url": "git+https://github.com/A-G-guy/dsh-plugins.git",
57
+ "url": "git+https://github.com/A-G-guy/dsh-plus.git",
56
58
  "directory": "packages/llm-pi"
57
59
  },
58
60
  "scripts": {
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,21 +3,30 @@
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/settingsScope/connection 的很窄一面,
7
+ * 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
8
+ * client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
9
+ * settings.plugin.item 插槽与 dsh-client-ui-settings 的 settingsScope 服务为准。
10
+ * rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
11
+ * (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
12
+ * 官方配置页按此 key 与 Host 已注册命名空间配对分发。
13
+ * 配置读写经官方 settingsScope 传输(rc7 起第三方命名空间对 settings RPC
14
+ * 全量开放);自定义端点仅剩「模型目录」(api.ts,含 kitSource/models-dev
15
+ * 运行期诊断)。
9
16
  * @module @dsh-plus/llm-pi/client
10
17
  */
11
18
  import type { Context } from '@deepseek-ai/cordis'
12
19
 
20
+ import { SETTINGS_NS } from '../ns.ts'
13
21
  import { LlmPiCard } from './card.tsx'
14
22
  import { en, NS, zh } from './i18n.ts'
23
+ import type { Scope, SettingsApi } from './scope.ts'
15
24
  import { injectStyle } from './styles.ts'
16
25
 
17
26
  export const name = 'dsh-plus-llm-pi'
18
27
 
19
28
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
20
- export const inject = ['slots', 'locale'] as const
29
+ export const inject = ['slots', 'locale', 'settingsScope', 'connection'] as const
21
30
 
22
31
  interface SlotsLike {
23
32
  inject(key: string, callback: () => unknown): unknown
@@ -29,9 +38,19 @@ interface LocaleLike {
29
38
  bind(ns: string): (key: string) => string
30
39
  }
31
40
 
41
+ interface SettingsScopeServiceLike {
42
+ bind(spec: { namespace: string }): Scope
43
+ }
44
+
45
+ interface ConnectionLike {
46
+ settings: SettingsApi
47
+ }
48
+
32
49
  interface ClientContext {
33
50
  slots: SlotsLike
34
51
  locale: LocaleLike
52
+ settingsScope: SettingsScopeServiceLike
53
+ get(key: 'connection'): ConnectionLike
35
54
  effect(execute: () => () => void, label?: string): unknown
36
55
  }
37
56
 
@@ -48,14 +67,15 @@ export function apply(ctx: Context): void {
48
67
  () => c.locale.register(NS, { zh, en }),
49
68
  'llm-pi: locale',
50
69
  )
70
+ const scope = c.settingsScope.bind({ namespace: SETTINGS_NS })
71
+ const api = c.get('connection').api.settings
51
72
  c.slots.inject('settings.plugin.item', () =>
52
73
  c.slots.register(
53
74
  {
54
75
  name: 'settings.plugin.item',
55
- id: 'llm-pi',
56
- order: 110,
76
+ key: SETTINGS_NS,
57
77
  locale: NS,
58
- inject: () => ({ t: c.locale.bind(NS) }),
78
+ inject: () => ({ t: c.locale.bind(NS), scope, api }),
59
79
  },
60
80
  LlmPiCard,
61
81
  ),
@@ -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,33 @@
1
+ /**
2
+ * 浏览器半 settingsScope/connection 的最小本地接口声明。
3
+ * 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
4
+ * 运行时契约以官方 dsh-client-ui-settings 的 settingsScope 服务与
5
+ * dsh-client-connection 的 settings RPC 面为准(rc7:白名单已移除,
6
+ * 任何已注册命名空间均可 describe/update/replace)。
7
+ * @module llm-pi/client/scope
8
+ */
9
+
10
+ /** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
11
+ export interface ScopeSnapshot {
12
+ status: 'loading' | 'ready' | 'unavailable'
13
+ /** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
14
+ value: unknown
15
+ /** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
16
+ revision: number | undefined
17
+ /** Host 文档是否可写。 */
18
+ writable: boolean
19
+ }
20
+
21
+ /** settingsScope.bind({namespace}) 返回的控制器面。 */
22
+ export interface Scope {
23
+ getSnapshot(): ScopeSnapshot
24
+ subscribe(listener: () => void): () => void
25
+ load(): Promise<void>
26
+ }
27
+
28
+ /** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
29
+ export interface SettingsApi {
30
+ describe(): Promise<{ result: { ok: boolean; value?: { namespaces: Array<{ ns: string; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
31
+ update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
32
+ replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
33
+ }
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
@@ -11,8 +11,10 @@
11
11
  import z from '@deepseek-ai/schemastery'
12
12
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
13
13
 
14
- /** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
15
- export const SETTINGS_NS = settingsNamespace('dsh-plus-llm-pi')
14
+ import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
15
+
16
+ /** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
17
+ export const SETTINGS_NS = settingsNamespace(NS_LITERAL)
16
18
 
17
19
  /** 本插件可为手写 route 提供的协议实现(与官方 PROTOCOLS 表一致)。 */
18
20
  export const PROTOCOL_IDS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const
@@ -154,57 +156,5 @@ export const Config: z<LlmPiConfig> = z.object({
154
156
  providers: z.dict(providerProfile).description('provider 路由表,键即 route 名').default({}),
155
157
  })
156
158
 
157
- /** 配置卡片读取用的传输对象:配置无密钥字段,原样传输;附运行期元信息。 */
158
- export interface WireConfig {
159
- enabled: boolean
160
- catalogUrl: string
161
- catalogRefreshHours: number
162
- catalogProxy: string
163
- providers: Record<string, ProviderProfileConfig>
164
- /** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
165
- writable: boolean
166
- /** 模块解析来源与自检结果(dsh 树 / vendored 兜底)。 */
167
- kitSource: string
168
- /** models.dev 快照状态(fetchedAt/模型数/错误),供卡片展示。 */
169
- modelsDevStatus: { fetchedAt: string | null; providers: number; models: number; error: string | null } | null
170
- }
171
-
172
- export function toWire(
173
- cfg: LlmPiConfig,
174
- writable: boolean,
175
- kitSource: string,
176
- modelsDevStatus: WireConfig['modelsDevStatus'],
177
- ): WireConfig {
178
- return {
179
- enabled: cfg.enabled,
180
- catalogUrl: cfg.catalogUrl,
181
- catalogRefreshHours: cfg.catalogRefreshHours,
182
- catalogProxy: cfg.catalogProxy ?? '',
183
- providers: cfg.providers ?? {},
184
- writable,
185
- kitSource,
186
- modelsDevStatus,
187
- }
188
- }
189
-
190
- /**
191
- * 配置卡片写回:卡片总是提交完整配置对象(含 providers 全量),
192
- * 后端经 settings.replace 整段覆盖用户层——providers dict 的删除语义
193
- * 无法经深合并表达,整段替换是唯一正确语义。
194
- */
195
- /** 卡片提交的形状:字段全可选(无默认值),只携带用户编辑过的字段。 */
196
- export interface WirePatchInput {
197
- enabled?: boolean
198
- catalogUrl?: string
199
- catalogRefreshHours?: number
200
- catalogProxy?: string
201
- providers?: Record<string, ProviderProfileConfig>
202
- }
203
-
204
- export const WirePatch: z<WirePatchInput> = z.object({
205
- enabled: z.boolean(),
206
- catalogUrl: z.string(),
207
- catalogRefreshHours: z.number(),
208
- catalogProxy: z.string(),
209
- providers: z.dict(providerProfile),
210
- })
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
  }
package/src/ns.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
+ * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
4
+ * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
5
+ * 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
6
+ * 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
7
+ * @module llm-pi/ns
8
+ */
9
+ export const SETTINGS_NS = 'dsh-plus-llm-pi'