@dsh-plus/llm-pi 0.1.1 → 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 +59 -54
- package/lib/index.d.ts +1 -20
- package/lib/index.js +9 -80
- package/package.json +4 -2
- package/src/client/api.ts +9 -32
- package/src/client/card.tsx +52 -30
- package/src/client/client.ts +22 -6
- package/src/client/draft.ts +9 -9
- package/src/client/scope.ts +33 -0
- package/src/config-api.ts +10 -75
- package/src/config.ts +2 -54
- package/src/index.ts +3 -3
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
|
|
275
|
+
function draftFromValue(value) {
|
|
288
276
|
return {
|
|
289
|
-
enabled:
|
|
290
|
-
catalogUrl:
|
|
291
|
-
catalogRefreshHours: String(
|
|
292
|
-
catalogProxy:
|
|
293
|
-
providers: Object.fromEntries(Object.entries(
|
|
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 [
|
|
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
|
-
|
|
1352
|
+
fetchCatalog("", "models-dev").then((result) => {
|
|
1359
1353
|
if (!alive) return;
|
|
1360
|
-
|
|
1361
|
-
|
|
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)(() =>
|
|
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 (
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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(
|
|
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
|
-
|
|
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 = !
|
|
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
|
-
|
|
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(
|
|
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,12 @@ 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 = [
|
|
1885
|
+
const inject = [
|
|
1886
|
+
"slots",
|
|
1887
|
+
"locale",
|
|
1888
|
+
"settingsScope",
|
|
1889
|
+
"connection"
|
|
1890
|
+
];
|
|
1891
1891
|
function apply(ctx) {
|
|
1892
1892
|
const c = ctx;
|
|
1893
1893
|
const tag = injectStyle();
|
|
@@ -1898,12 +1898,17 @@ function apply(ctx) {
|
|
|
1898
1898
|
zh,
|
|
1899
1899
|
en
|
|
1900
1900
|
}), "llm-pi: locale");
|
|
1901
|
+
const scope = c.settingsScope.bind({ namespace: SETTINGS_NS });
|
|
1902
|
+
const api = c.get("connection").api.settings;
|
|
1901
1903
|
c.slots.inject("settings.plugin.item", () => c.slots.register({
|
|
1902
1904
|
name: "settings.plugin.item",
|
|
1903
1905
|
key: SETTINGS_NS,
|
|
1904
|
-
order: 110,
|
|
1905
1906
|
locale: NS,
|
|
1906
|
-
inject: () => ({
|
|
1907
|
+
inject: () => ({
|
|
1908
|
+
t: c.locale.bind(NS),
|
|
1909
|
+
scope,
|
|
1910
|
+
api
|
|
1911
|
+
})
|
|
1907
1912
|
}, LlmPiCard));
|
|
1908
1913
|
}
|
|
1909
1914
|
|
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,
|
|
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
|
-
/**
|
|
265
|
-
function
|
|
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(`
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
47
|
-
export interface
|
|
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
|
-
/**
|
|
59
|
-
export
|
|
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}`
|
package/src/client/card.tsx
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「LLM 路由」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
|
|
3
3
|
* 顶部:enabled / catalogUrl / catalogRefreshHours / 只读状态行(kitSource、
|
|
4
|
-
* modelsDevStatus
|
|
5
|
-
*
|
|
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 {
|
|
12
|
-
import {
|
|
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 [
|
|
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
|
-
|
|
48
|
-
.then((
|
|
64
|
+
fetchCatalog('', 'models-dev')
|
|
65
|
+
.then((result) => {
|
|
49
66
|
if (!alive) return
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
64
|
-
draft !==
|
|
65
|
-
|
|
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 (
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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
|
-
|
|
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 = !
|
|
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')}:{
|
|
220
|
+
<p className="lpc-statusRow">{t('kitSource')}:{kitSource ?? ''}</p>
|
|
199
221
|
<div className="lpc-statusRow">
|
|
200
|
-
<span>{t('modelsDevStatus')}:{modelsDevText(
|
|
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"
|
package/src/client/client.ts
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
4
4
|
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
5
5
|
*
|
|
6
|
-
* 类型说明:浏览器半只用到 slots/locale
|
|
7
|
-
*
|
|
8
|
-
* dsh-client-ui-settings-plugins 的
|
|
6
|
+
* 类型说明:浏览器半只用到 slots/locale/settingsScope/connection 的很窄一面,
|
|
7
|
+
* 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
|
|
8
|
+
* client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
|
|
9
|
+
* settings.plugin.item 插槽与 dsh-client-ui-settings 的 settingsScope 服务为准。
|
|
9
10
|
* rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
|
|
10
11
|
* (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
|
|
11
12
|
* 官方配置页按此 key 与 Host 已注册命名空间配对分发。
|
|
13
|
+
* 配置读写经官方 settingsScope 传输(rc7 起第三方命名空间对 settings RPC
|
|
14
|
+
* 全量开放);自定义端点仅剩「模型目录」(api.ts,含 kitSource/models-dev
|
|
15
|
+
* 运行期诊断)。
|
|
12
16
|
* @module @dsh-plus/llm-pi/client
|
|
13
17
|
*/
|
|
14
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -16,12 +20,13 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
16
20
|
import { SETTINGS_NS } from '../ns.ts'
|
|
17
21
|
import { LlmPiCard } from './card.tsx'
|
|
18
22
|
import { en, NS, zh } from './i18n.ts'
|
|
23
|
+
import type { Scope, SettingsApi } from './scope.ts'
|
|
19
24
|
import { injectStyle } from './styles.ts'
|
|
20
25
|
|
|
21
26
|
export const name = 'dsh-plus-llm-pi'
|
|
22
27
|
|
|
23
28
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
24
|
-
export const inject = ['slots', 'locale'] as const
|
|
29
|
+
export const inject = ['slots', 'locale', 'settingsScope', 'connection'] as const
|
|
25
30
|
|
|
26
31
|
interface SlotsLike {
|
|
27
32
|
inject(key: string, callback: () => unknown): unknown
|
|
@@ -33,9 +38,19 @@ interface LocaleLike {
|
|
|
33
38
|
bind(ns: string): (key: string) => string
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
interface SettingsScopeServiceLike {
|
|
42
|
+
bind(spec: { namespace: string }): Scope
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ConnectionLike {
|
|
46
|
+
settings: SettingsApi
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
interface ClientContext {
|
|
37
50
|
slots: SlotsLike
|
|
38
51
|
locale: LocaleLike
|
|
52
|
+
settingsScope: SettingsScopeServiceLike
|
|
53
|
+
get(key: 'connection'): ConnectionLike
|
|
39
54
|
effect(execute: () => () => void, label?: string): unknown
|
|
40
55
|
}
|
|
41
56
|
|
|
@@ -52,14 +67,15 @@ export function apply(ctx: Context): void {
|
|
|
52
67
|
() => c.locale.register(NS, { zh, en }),
|
|
53
68
|
'llm-pi: locale',
|
|
54
69
|
)
|
|
70
|
+
const scope = c.settingsScope.bind({ namespace: SETTINGS_NS })
|
|
71
|
+
const api = c.get('connection').api.settings
|
|
55
72
|
c.slots.inject('settings.plugin.item', () =>
|
|
56
73
|
c.slots.register(
|
|
57
74
|
{
|
|
58
75
|
name: 'settings.plugin.item',
|
|
59
76
|
key: SETTINGS_NS,
|
|
60
|
-
order: 110,
|
|
61
77
|
locale: NS,
|
|
62
|
-
inject: () => ({ t: c.locale.bind(NS) }),
|
|
78
|
+
inject: () => ({ t: c.locale.bind(NS), scope, api }),
|
|
63
79
|
},
|
|
64
80
|
LlmPiCard,
|
|
65
81
|
),
|
package/src/client/draft.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 可编辑草稿模型与
|
|
2
|
+
* 可编辑草稿模型与 ConfigValue ↔ Draft 双向转换。
|
|
3
3
|
* 设计:数值字段用字符串承载('' = 未设置/不写入),多选枚举用布尔 map,
|
|
4
4
|
* 转换时剔除空值,保证 dirty 比较(toPatch 双侧)与保存形状稳定。
|
|
5
5
|
* @module llm-pi/client/draft
|
|
6
6
|
*/
|
|
7
|
-
import type {
|
|
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
|
|
270
|
+
export function draftFromValue(value: ConfigValue): Draft {
|
|
271
271
|
return {
|
|
272
|
-
enabled:
|
|
273
|
-
catalogUrl:
|
|
274
|
-
catalogRefreshHours: String(
|
|
275
|
-
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(
|
|
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):
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
111
|
-
export function
|
|
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(`
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
31
|
+
registerCatalogApi(webCtx, runtime)
|
|
32
32
|
})
|
|
33
33
|
}
|