oh-my-agent-web 0.2.0 → 0.4.0
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/README.md +86 -447
- package/dist/server/approval/read-only.js +107 -0
- package/dist/server/approval/rules.js +85 -0
- package/dist/server/approval/scope.js +150 -0
- package/dist/server/brand.js +2 -0
- package/dist/server/cli-config.js +52 -0
- package/dist/server/cli-info.js +101 -0
- package/dist/server/cli.js +304 -0
- package/dist/server/engine/builtin.js +61 -0
- package/dist/server/engine/catalog.js +8 -0
- package/dist/server/engine/codex-approvals.js +45 -0
- package/dist/server/engine/codex-map.js +298 -0
- package/dist/server/engine/codex-sessions.js +159 -0
- package/dist/server/engine/codex-transport.js +49 -0
- package/dist/server/engine/codex.js +314 -0
- package/dist/server/engine/dsh-history.js +195 -0
- package/dist/server/engine/dsh-log.js +245 -0
- package/dist/server/engine/dsh-map.js +221 -0
- package/dist/server/engine/dsh-sessions.js +207 -0
- package/dist/server/engine/dsh-transport.js +37 -0
- package/dist/server/engine/dsh.js +284 -0
- package/dist/server/engine/fake.js +231 -0
- package/dist/server/engine/jsonrpc-stdio.js +241 -0
- package/dist/server/engine/model-registry-reload.js +115 -0
- package/dist/server/engine/pi-events.js +148 -0
- package/dist/server/engine/pi-rename.js +14 -0
- package/dist/server/engine/pi-sdk.js +343 -0
- package/dist/server/engine/pi-session-model.js +23 -0
- package/dist/server/engine/pi-thinking.js +34 -0
- package/dist/server/engine/pi-transcript.js +225 -0
- package/dist/server/engine/pi-version.js +11 -0
- package/dist/server/engine/port.js +1 -0
- package/dist/server/engine/registry.js +47 -0
- package/dist/server/engine/scratch.js +13 -0
- package/dist/server/engine/suggestion-guidance.js +9 -0
- package/dist/server/engine/switchable.js +22 -0
- package/dist/server/engine/wiring.js +107 -0
- package/dist/server/files/dir-routes.js +40 -0
- package/dist/server/files/file-search.js +63 -0
- package/dist/server/files/file-service.js +232 -0
- package/dist/server/files/files-routes.js +182 -0
- package/dist/server/files/git-info.js +186 -0
- package/dist/server/http/auth-routes.js +99 -0
- package/dist/server/http/auth.js +191 -0
- package/dist/server/http/aux-routes.js +24 -0
- package/dist/server/http/compress.js +75 -0
- package/dist/server/http/health-and-static.js +33 -0
- package/dist/server/http/live-routes.js +113 -0
- package/dist/server/http/login-page.js +200 -0
- package/dist/server/index.js +342 -520
- package/dist/server/markers/actions.js +262 -0
- package/dist/server/protocol-parse.js +201 -0
- package/dist/server/protocol-settings.js +1 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/providers/catalog-store.js +85 -0
- package/dist/server/providers/catalog-writes.js +197 -0
- package/dist/server/providers/catalog.js +77 -0
- package/dist/server/providers/enabled-models.js +118 -0
- package/dist/server/providers/import-models.js +69 -0
- package/dist/server/providers/import-routes.js +136 -0
- package/dist/server/providers/model-test.js +52 -0
- package/dist/server/providers/probe.js +64 -0
- package/dist/server/providers/provider-handlers.js +203 -0
- package/dist/server/providers/provider-store.js +327 -0
- package/dist/server/providers/request-headers.js +7 -0
- package/dist/server/providers/validate.js +241 -0
- package/dist/server/push/push-routes.js +114 -0
- package/dist/server/push/push.js +120 -0
- package/dist/server/session/approval-bridge.js +103 -0
- package/dist/server/session/attachment-resolve.js +21 -0
- package/dist/server/session/client-view.js +82 -0
- package/dist/server/session/conversation-create.js +65 -0
- package/dist/server/session/delete-session.js +84 -0
- package/dist/server/session/engine-switch.js +108 -0
- package/dist/server/session/lock.js +82 -0
- package/dist/server/session/model-controller.js +111 -0
- package/dist/server/session/notices.js +31 -0
- package/dist/server/session/pi-opener.js +123 -0
- package/dist/server/session/pi-refresh.js +51 -0
- package/dist/server/session/pi-sessions-provider.js +46 -0
- package/dist/server/session/pi-sessions.js +272 -0
- package/dist/server/session/refresh-actions.js +42 -0
- package/dist/server/session/rename-session.js +23 -0
- package/dist/server/session/runtime-services.js +28 -0
- package/dist/server/session/runtime-types.js +1 -0
- package/dist/server/session/runtime.js +358 -0
- package/dist/server/session/session-stats.js +88 -0
- package/dist/server/session/settings.js +367 -0
- package/dist/server/session/snapshot-for.js +7 -0
- package/dist/server/session/stats-refresher.js +50 -0
- package/dist/server/session/switch-conversation.js +86 -0
- package/dist/server/session/throttle.js +14 -0
- package/dist/server/session/turn-events.js +126 -0
- package/dist/server/session/turn-finish.js +79 -0
- package/dist/server/session/turn-markers.js +26 -0
- package/dist/server/session/turn-queue.js +111 -0
- package/dist/server/session/turn-send.js +88 -0
- package/dist/server/session/turn-watchdog.js +77 -0
- package/dist/server/sessions/session-routes.js +17 -0
- package/dist/server/skills/skill-routes.js +28 -0
- package/dist/server/skills/skill-store.js +134 -0
- package/dist/server/snapshot/fan-out.js +10 -0
- package/dist/server/snapshot/messages.js +75 -0
- package/dist/server/snapshot/sender.js +62 -0
- package/dist/server/snapshot/state.js +193 -0
- package/dist/server/terminal/terminal-routes.js +176 -0
- package/dist/server/terminal/terminal-session.js +140 -0
- package/dist/server/uploads/upload-routes.js +64 -0
- package/dist/server/uploads/upload-store.js +115 -0
- package/dist/server/util/data-dir.js +36 -0
- package/dist/server/util/env.js +140 -0
- package/dist/server/util/ids.js +28 -0
- package/dist/server/util/log-file.js +67 -0
- package/dist/server/util/log.js +52 -0
- package/dist/server/util/origin.js +75 -0
- package/dist/server/util/paths.js +60 -0
- package/dist/server/util/pricing-index.js +121 -0
- package/dist/server/util/pricing.js +214 -0
- package/dist/server/util/startup.js +34 -0
- package/dist/server/util/upstream-version.js +140 -0
- package/dist/server/util/usage.js +19 -0
- package/dist/server/util/version.js +50 -0
- package/dist/server/ws/handle-message.js +95 -0
- package/package.json +49 -63
- package/web/dist/apple-touch-icon-precomposed.png +0 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/abnfDiagram-WEYOIA3C-NLp09iEC.js +1 -0
- package/web/dist/assets/arc-DviL9o3c.js +1 -0
- package/web/dist/assets/architecture-WOLXFQ4H-fjsM_Sy3.js +1 -0
- package/web/dist/assets/architectureDiagram-ZHVYKT53-Cn3B4TE8.js +36 -0
- package/web/dist/assets/array-BifhSqXX.js +1 -0
- package/web/dist/assets/blockDiagram-RJAE7QA3-CIuvxWvi.js +140 -0
- package/web/dist/assets/c4Diagram-XDPPPE54-DvdKmEpF.js +38 -0
- package/web/dist/assets/channel-C3AQ6nAs.js +1 -0
- package/web/dist/assets/chunk-2E7WHVMD-HNNASGCS.js +88 -0
- package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web/dist/assets/chunk-4EMNKR2T-Cg-eRwBE.js +1 -0
- package/web/dist/assets/chunk-55XCU76K-BweiisZL.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web/dist/assets/chunk-7S6XSTFO-BCzIiP-6.js +2 -0
- package/web/dist/assets/chunk-AQDTSKZB-Dl0NRFuU.js +72 -0
- package/web/dist/assets/chunk-DSNLSHJZ-zhHCjjN9.js +129 -0
- package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/web/dist/assets/chunk-HAGFOTED-BDQqU2EH.js +62 -0
- package/web/dist/assets/chunk-HNE4Q36P-B-qRzMjX.js +1 -0
- package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/web/dist/assets/chunk-NQOKRVIF-CNcvhMn_.js +213 -0
- package/web/dist/assets/chunk-UHW7PFBP-Dwr8LUzr.js +1 -0
- package/web/dist/assets/chunk-VQ5W62QI-O8sN6jJu.js +1 -0
- package/web/dist/assets/chunk-WQQL4CDK-B6-59Zg2.js +10 -0
- package/web/dist/assets/chunk-XXDRQBXY-DvSbeUlZ.js +1 -0
- package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web/dist/assets/chunk-YZO67HFG-BXpKYKsw.js +1 -0
- package/web/dist/assets/chunk-ZAUX2LTX-B1Z5VSkz.js +1 -0
- package/web/dist/assets/chunk-ZCHMQSOR-h_5mBLIA.js +2 -0
- package/web/dist/assets/classDiagram-v2-4MYWR3UA-Dr6JnaaV.js +217 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-36ShkR7R.js +1 -0
- package/web/dist/assets/cynefin-EF2NZ3EQ-Dg6bI7pC.js +1 -0
- package/web/dist/assets/cynefinDiagram-WF24UDRP-B0YHpBlR.js +62 -0
- package/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/web/dist/assets/dagre-NK4GIZVX-Cx3mn7YS.js +4 -0
- package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/web/dist/assets/diagram-CBK3MWTC-l8aZG_Bm.js +24 -0
- package/web/dist/assets/diagram-I5XIIV2D-Bd5f14_h.js +24 -0
- package/web/dist/assets/diagram-ODIL2UCW-hBsxCYUj.js +30 -0
- package/web/dist/assets/diagram-PFBEJUXE-BgpIBUYl.js +3 -0
- package/web/dist/assets/diagram-RJA3I4VV-CDU1gUVD.js +41 -0
- package/web/dist/assets/diagram-TYUNFSNR-Iuxh7Tqf.js +200 -0
- package/web/dist/assets/dist-C86cKTuE.js +1 -0
- package/web/dist/assets/ebnfDiagram-V4P3TTPT-Cms0HFKh.js +1 -0
- package/web/dist/assets/elk-QONTBCTQ-B7yEy0PQ.js +27 -0
- package/web/dist/assets/erDiagram-KYQIGQAA-CL48j4aU.js +99 -0
- package/web/dist/assets/eventmodeling-K75KTNOO-CFvkaejh.js +1 -0
- package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +1 -0
- package/web/dist/assets/ganttDiagram-R7GPNTJY-vdMr-0ZV.js +292 -0
- package/web/dist/assets/gitGraph-VSP46ZUC-2z85Wuwo.js +1 -0
- package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BY4GPiqC.js +106 -0
- package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/web/dist/assets/index-Bjn56a2i.js +76 -0
- package/web/dist/assets/index-Drp6e5iU.css +1 -0
- package/web/dist/assets/info-OHQRW6UA-ECzkYTx-.js +1 -0
- package/web/dist/assets/infoDiagram-MVDRKAX5-DDk5EAeb.js +2 -0
- package/web/dist/assets/init-C-OQMol4.js +1 -0
- package/web/dist/assets/ishikawaDiagram-HPEHV2GE-9yPH4J94.js +70 -0
- package/web/dist/assets/journeyDiagram-BOSHW7O7-BPTIap3l.js +139 -0
- package/web/dist/assets/kanban-definition-FNSA4HC5-BbADHrA8.js +89 -0
- package/web/dist/assets/katex-B_JcIwqj.js +257 -0
- package/web/dist/assets/line-CHV9_4lL.js +1 -0
- package/web/dist/assets/linear-C-eZsBTv.js +1 -0
- package/web/dist/assets/mermaid-parser.core-BiAhX1l1.js +7 -0
- package/web/dist/assets/mermaid.core-BxP_A_zj.js +44 -0
- package/web/dist/assets/mindmap-definition-6OY57UG3-BMWlit95.js +98 -0
- package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/web/dist/assets/packet-JDAUHWVQ-FVApuKOp.js +1 -0
- package/web/dist/assets/path-fybaL0A-.js +1 -0
- package/web/dist/assets/pegDiagram-ECIUJNBX-WjWJvDHM.js +1 -0
- package/web/dist/assets/pie-XZMESJXO-BGCZnvhp.js +1 -0
- package/web/dist/assets/pieDiagram-GNMPY74T-DJpAC_JC.js +39 -0
- package/web/dist/assets/quadrantDiagram-D57K4MC3-hxx5c7oq.js +7 -0
- package/web/dist/assets/radar-ABXABTNO-BU9Shc-O.js +1 -0
- package/web/dist/assets/railroad-I3PHUGI6-pWj8d-G4.js +1 -0
- package/web/dist/assets/railroad-abnf-LNEFI6M7-D7HZBTAj.js +1 -0
- package/web/dist/assets/railroad-ebnf-DWYD2UWJ-CKiXJSuM.js +1 -0
- package/web/dist/assets/railroad-peg-JR7BVNR7-DB3VJHgR.js +1 -0
- package/web/dist/assets/railroadDiagram-Y72NRBL7-Cev7Y65k.js +1 -0
- package/web/dist/assets/requirementDiagram-EQYSW5RA-CqbwMLD8.js +84 -0
- package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/web/dist/assets/sankeyDiagram-SY24JHO5-V2cyVi8q.js +40 -0
- package/web/dist/assets/sequenceDiagram-BLEHNHQU-By6NJOPM.js +169 -0
- package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/web/dist/assets/src-Da91fG3g.js +1 -0
- package/web/dist/assets/stateDiagram-v2-A6IESKWZ-BnPGX5UI.js +291 -0
- package/web/dist/assets/swimlanes-B3ENRKFQ-C58BpIpm.js +1 -0
- package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +8 -0
- package/web/dist/assets/timeline-definition-JXYKGLGB-Hgo1DhnS.js +120 -0
- package/web/dist/assets/treeView-D4JQ5SDB-DF0dfjqB.js +1 -0
- package/web/dist/assets/treemap-SAJKECNS-CSSM5FqB.js +1 -0
- package/web/dist/assets/usecaseDiagram-WPOLBNNX-BkBBD1NY.js +328 -0
- package/web/dist/assets/vennDiagram-Q3Y3IDCW-Bm28keX_.js +34 -0
- package/web/dist/assets/wardley-7MLQ67FV-By7KgEQX.js +1 -0
- package/web/dist/assets/wardleyDiagram-IVAIDKWA-oVtIrnGa.js +78 -0
- package/web/dist/assets/xychartDiagram-4URNHDU4-aI8FH4g2.js +7 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicon.svg +20 -8
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-maskable-512.png +0 -0
- package/web/dist/index.html +21 -27
- package/web/dist/manifest.webmanifest +17 -0
- package/web/dist/maskable.svg +25 -0
- package/web/dist/sw.js +40 -0
- package/bin/oh-my-agent-web-ps.mjs +0 -203
- package/bin/oh-my-agent-web-ws.mjs +0 -593
- package/bin/oh-my-agent-web.mjs +0 -321
- package/bin/update-check.mjs +0 -142
- package/dist/server/codex-client.js +0 -310
- package/dist/server/codex-supervisor.js +0 -446
- package/dist/server/fs-service.js +0 -144
- package/dist/server/request-inspector.js +0 -458
- package/dist/shared/protocol.js +0 -112
- package/scripts/manage-server.mjs +0 -155
- package/scripts/omaw.mjs +0 -74
- package/shared/protocol.ts +0 -228
- package/web/dist/assets/index-B8-VzDf2.css +0 -1
- package/web/dist/assets/index-DeC2eiiq.js +0 -42
- package/web/dist/assets/markdown-CYXe6JZx.js +0 -55
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider / 模型配置的校验(ADR-0025 §3)——纯函数,全部可单测。
|
|
3
|
+
*
|
|
4
|
+
* 这一层的存在理由:这些值会被写进 `~/.pi/agent/models-store.json` 与 `auth.json`,
|
|
5
|
+
* 一旦写坏,下次 pi 启动可能整个模型表都读不出来。所以「用户能填什么」必须先收窄。
|
|
6
|
+
*/
|
|
7
|
+
import { validateSchedule } from "../util/pricing.js";
|
|
8
|
+
/** 支持的上游 API 形态(与 pi 的 ModelDefinition.api 对齐) */
|
|
9
|
+
export const API_KINDS = [
|
|
10
|
+
"openai-completions",
|
|
11
|
+
"openai-responses",
|
|
12
|
+
"openai-codex-responses",
|
|
13
|
+
"anthropic-messages",
|
|
14
|
+
"typesafe-system-one",
|
|
15
|
+
];
|
|
16
|
+
export const DEFAULT_API_KIND = "openai-completions";
|
|
17
|
+
export const MAX_PROVIDER_ID_CHARS = 32;
|
|
18
|
+
export const MAX_MODELS_PER_PROVIDER = 50;
|
|
19
|
+
export const MAX_API_KEY_CHARS = 512;
|
|
20
|
+
export const MIN_API_KEY_CHARS = 8;
|
|
21
|
+
export const MAX_HEADERS = 32;
|
|
22
|
+
export const MAX_HEADER_CHARS = 256;
|
|
23
|
+
const PROVIDER_RE = /^[a-z0-9][a-z0-9-]{1,30}$/;
|
|
24
|
+
const MODEL_ID_RE = /^[A-Za-z0-9._:/-]{1,120}$/;
|
|
25
|
+
/** 纯函数:baseUrl 只允许 http(s),且禁止 user:pass@ 这种把凭证塞进地址的形态 */
|
|
26
|
+
export function validateBaseUrl(raw) {
|
|
27
|
+
if (typeof raw !== "string")
|
|
28
|
+
return { ok: false, error: "baseUrl 必须是字符串" };
|
|
29
|
+
const value = raw.trim();
|
|
30
|
+
if (value === "")
|
|
31
|
+
return { ok: false, error: "baseUrl 不能为空" };
|
|
32
|
+
if (/^(file|javascript|data|vbscript):/i.test(value))
|
|
33
|
+
return { ok: false, error: "baseUrl 只允许 http(s)" };
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(value);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { ok: false, error: "baseUrl 不是合法 URL" };
|
|
40
|
+
}
|
|
41
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
42
|
+
return { ok: false, error: "baseUrl 只允许 http(s)" };
|
|
43
|
+
if (url.username !== "" || url.password !== "")
|
|
44
|
+
return { ok: false, error: "baseUrl 里不能带用户名密码" };
|
|
45
|
+
return { ok: true, value: value.replace(/\/+$/, "") };
|
|
46
|
+
}
|
|
47
|
+
/** 纯函数:api key 只做形态校验(长度 / 不许换行),不验真伪 */
|
|
48
|
+
export function validateHeaders(raw) {
|
|
49
|
+
if (raw === undefined || raw === null || raw === "")
|
|
50
|
+
return { ok: true, value: undefined };
|
|
51
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
52
|
+
return { ok: false, error: "headers 必须是 JSON 对象" };
|
|
53
|
+
const headers = {};
|
|
54
|
+
for (const [rawName, rawValue] of Object.entries(raw)) {
|
|
55
|
+
const name = rawName.trim();
|
|
56
|
+
const value = typeof rawValue === "string" ? rawValue.trim() : "";
|
|
57
|
+
if (name === "" || value === "")
|
|
58
|
+
continue;
|
|
59
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
|
|
60
|
+
return { ok: false, error: `header 名称不合法:${name}` };
|
|
61
|
+
if (name.length > MAX_HEADER_CHARS || value.length > MAX_HEADER_CHARS)
|
|
62
|
+
return { ok: false, error: "header 名称和值不能超过 256 个字符" };
|
|
63
|
+
if (/[\r\n]/.test(value))
|
|
64
|
+
return { ok: false, error: `header 值不能包含换行:${name}` };
|
|
65
|
+
headers[name] = value;
|
|
66
|
+
if (Object.keys(headers).length > MAX_HEADERS)
|
|
67
|
+
return { ok: false, error: `最多配置 ${MAX_HEADERS} 个 header` };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, value: Object.keys(headers).length > 0 ? headers : undefined };
|
|
70
|
+
}
|
|
71
|
+
export function validateApiKey(raw) {
|
|
72
|
+
if (raw === undefined || raw === null || raw === "")
|
|
73
|
+
return { ok: true, value: undefined };
|
|
74
|
+
if (typeof raw !== "string")
|
|
75
|
+
return { ok: false, error: "apiKey 必须是字符串" };
|
|
76
|
+
const value = raw.trim();
|
|
77
|
+
if (value === "")
|
|
78
|
+
return { ok: true, value: undefined };
|
|
79
|
+
if (value.length < MIN_API_KEY_CHARS)
|
|
80
|
+
return { ok: false, error: `apiKey 太短(至少 ${MIN_API_KEY_CHARS} 位)` };
|
|
81
|
+
if (value.length > MAX_API_KEY_CHARS)
|
|
82
|
+
return { ok: false, error: `apiKey 太长(最多 ${MAX_API_KEY_CHARS} 位)` };
|
|
83
|
+
if (/[\r\n]/.test(value))
|
|
84
|
+
return { ok: false, error: "apiKey 不能包含换行" };
|
|
85
|
+
return { ok: true, value };
|
|
86
|
+
}
|
|
87
|
+
/** 纯函数:把 key 变成「尾 4 位」提示(前端只能看到这个) */
|
|
88
|
+
export function maskKey(key) {
|
|
89
|
+
if (!key)
|
|
90
|
+
return null;
|
|
91
|
+
const tail = key.slice(-4);
|
|
92
|
+
return `…${tail}`;
|
|
93
|
+
}
|
|
94
|
+
/** 纯函数:整张表单的校验(一次收集所有错误,别让用户挤牙膏式试错) */
|
|
95
|
+
export function validateProviderInput(input) {
|
|
96
|
+
if (typeof input !== "object" || input === null)
|
|
97
|
+
return { ok: false, errors: ["入参不是对象"] };
|
|
98
|
+
const record = input;
|
|
99
|
+
const errors = [];
|
|
100
|
+
const provider = typeof record.provider === "string" ? record.provider.trim().toLowerCase() : "";
|
|
101
|
+
if (!PROVIDER_RE.test(provider))
|
|
102
|
+
errors.push("provider 只能是小写字母/数字/短横线(2–32 位)");
|
|
103
|
+
const baseUrlResult = validateBaseUrl(record.baseUrl);
|
|
104
|
+
if (!baseUrlResult.ok)
|
|
105
|
+
errors.push(baseUrlResult.error);
|
|
106
|
+
const api = typeof record.api === "string" && record.api.trim() !== "" ? record.api.trim() : DEFAULT_API_KIND;
|
|
107
|
+
if (!API_KINDS.includes(api))
|
|
108
|
+
errors.push(`api 必须是:${API_KINDS.join(" / ")}`);
|
|
109
|
+
const keyResult = validateApiKey(record.apiKey);
|
|
110
|
+
if (!keyResult.ok)
|
|
111
|
+
errors.push(keyResult.error);
|
|
112
|
+
const headersResult = validateHeaders(record.headers);
|
|
113
|
+
if (!headersResult.ok)
|
|
114
|
+
errors.push(headersResult.error);
|
|
115
|
+
const models = [];
|
|
116
|
+
const rawModels = Array.isArray(record.models) ? record.models : [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const item of rawModels) {
|
|
119
|
+
const id = typeof item === "string"
|
|
120
|
+
? item.trim()
|
|
121
|
+
: typeof item === "object" && item !== null
|
|
122
|
+
? String(item.id ?? "").trim()
|
|
123
|
+
: "";
|
|
124
|
+
if (id === "")
|
|
125
|
+
continue;
|
|
126
|
+
if (!MODEL_ID_RE.test(id)) {
|
|
127
|
+
errors.push(`模型 id 含非法字符:${id}`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (seen.has(id))
|
|
131
|
+
continue;
|
|
132
|
+
seen.add(id);
|
|
133
|
+
const name = typeof item === "object" && item !== null ? item.name : undefined;
|
|
134
|
+
const contextWindow = typeof item === "object" && item !== null ? item.contextWindow : undefined;
|
|
135
|
+
models.push({
|
|
136
|
+
id,
|
|
137
|
+
...(typeof name === "string" && name.trim() !== "" ? { name: name.trim().slice(0, 80) } : {}),
|
|
138
|
+
...(typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
|
|
139
|
+
? { contextWindow: Math.floor(contextWindow) }
|
|
140
|
+
: {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (models.length === 0)
|
|
144
|
+
errors.push("至少填一个模型 id");
|
|
145
|
+
if (models.length > MAX_MODELS_PER_PROVIDER)
|
|
146
|
+
errors.push(`最多 ${MAX_MODELS_PER_PROVIDER} 个模型`);
|
|
147
|
+
if (errors.length > 0)
|
|
148
|
+
return { ok: false, errors };
|
|
149
|
+
const previousRaw = typeof record.previousProvider === "string" ? record.previousProvider.trim().toLowerCase() : "";
|
|
150
|
+
const previousProvider = previousRaw !== "" && previousRaw !== provider ? previousRaw : undefined;
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
value: {
|
|
154
|
+
provider,
|
|
155
|
+
...(previousProvider ? { previousProvider } : {}),
|
|
156
|
+
baseUrl: baseUrlResult.ok ? baseUrlResult.value : "",
|
|
157
|
+
api,
|
|
158
|
+
...(keyResult.ok && keyResult.value ? { apiKey: keyResult.value } : {}),
|
|
159
|
+
...(headersResult.ok && headersResult.value ? { headers: headersResult.value } : {}),
|
|
160
|
+
models,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export const MAX_CONTEXT_WINDOW = 100_000_000;
|
|
165
|
+
export const MAX_MAX_TOKENS = 10_000_000;
|
|
166
|
+
/** 纯函数:正整数(可空);空串/null/undefined 都算"未提供" */
|
|
167
|
+
function positiveInteger(raw, limit, label) {
|
|
168
|
+
if (raw === undefined || raw === null || raw === "")
|
|
169
|
+
return { ok: true, value: null };
|
|
170
|
+
const value = typeof raw === "number" ? raw : Number(String(raw).trim());
|
|
171
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
172
|
+
return { ok: false, error: `${label} 必须是正整数` };
|
|
173
|
+
const rounded = Math.floor(value);
|
|
174
|
+
if (rounded > limit)
|
|
175
|
+
return { ok: false, error: `${label} 不能超过 ${limit}` };
|
|
176
|
+
return { ok: true, value: rounded };
|
|
177
|
+
}
|
|
178
|
+
/** 纯函数:非负价格(可空) */
|
|
179
|
+
function price(raw, label) {
|
|
180
|
+
if (raw === undefined || raw === null || raw === "")
|
|
181
|
+
return { ok: true, value: 0 };
|
|
182
|
+
const value = typeof raw === "number" ? raw : Number(String(raw).trim());
|
|
183
|
+
if (!Number.isFinite(value) || value < 0)
|
|
184
|
+
return { ok: false, error: `${label} 必须是非负数` };
|
|
185
|
+
return { ok: true, value };
|
|
186
|
+
}
|
|
187
|
+
/** 纯函数:整张模型表单的校验(一次收集全部错误) */
|
|
188
|
+
export function validateModelSpec(input) {
|
|
189
|
+
if (typeof input !== "object" || input === null)
|
|
190
|
+
return { ok: false, errors: ["入参不是对象"] };
|
|
191
|
+
const record = input;
|
|
192
|
+
const errors = [];
|
|
193
|
+
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
194
|
+
if (!MODEL_ID_RE.test(id))
|
|
195
|
+
errors.push("模型 id 只能包含字母数字与 . _ : / -(1–120 位)");
|
|
196
|
+
const contextWindow = positiveInteger(record.contextWindow, MAX_CONTEXT_WINDOW, "上下文窗口");
|
|
197
|
+
if (!contextWindow.ok)
|
|
198
|
+
errors.push(contextWindow.error);
|
|
199
|
+
const maxTokens = positiveInteger(record.maxTokens, MAX_MAX_TOKENS, "最大输出 tokens");
|
|
200
|
+
if (!maxTokens.ok)
|
|
201
|
+
errors.push(maxTokens.error);
|
|
202
|
+
const costInput = record.cost;
|
|
203
|
+
const cost = {
|
|
204
|
+
input: price(costInput?.input, "输入价格"),
|
|
205
|
+
output: price(costInput?.output, "输出价格"),
|
|
206
|
+
cacheRead: price(costInput?.cacheRead, "缓存读价格"),
|
|
207
|
+
cacheWrite: price(costInput?.cacheWrite, "缓存写价格"),
|
|
208
|
+
};
|
|
209
|
+
for (const item of Object.values(cost))
|
|
210
|
+
if (!item.ok)
|
|
211
|
+
errors.push(item.error);
|
|
212
|
+
const costProvided = costInput !== undefined &&
|
|
213
|
+
Object.values(costInput).some((value) => value !== "" && value !== null && value !== undefined);
|
|
214
|
+
// 分时价格:起始时间 / 价格格式一次列全(写法坏了的时段表会让算账静默变错,必须挡住)
|
|
215
|
+
const schedule = validateSchedule(record.costSchedule);
|
|
216
|
+
if (!schedule.ok)
|
|
217
|
+
for (const error of schedule.errors)
|
|
218
|
+
errors.push(error);
|
|
219
|
+
if (errors.length > 0)
|
|
220
|
+
return { ok: false, errors };
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
value: {
|
|
224
|
+
id,
|
|
225
|
+
name: typeof record.name === "string" && record.name.trim() !== "" ? record.name.trim().slice(0, 80) : id,
|
|
226
|
+
reasoning: record.reasoning === true,
|
|
227
|
+
imageInput: record.imageInput === true,
|
|
228
|
+
contextWindow: contextWindow.ok ? contextWindow.value : null,
|
|
229
|
+
maxTokens: maxTokens.ok ? maxTokens.value : null,
|
|
230
|
+
cost: costProvided
|
|
231
|
+
? {
|
|
232
|
+
input: cost.input.ok ? cost.input.value : 0,
|
|
233
|
+
output: cost.output.ok ? cost.output.value : 0,
|
|
234
|
+
cacheRead: cost.cacheRead.ok ? cost.cacheRead.value : 0,
|
|
235
|
+
cacheWrite: cost.cacheWrite.ok ? cost.cacheWrite.value : 0,
|
|
236
|
+
}
|
|
237
|
+
: null,
|
|
238
|
+
costSchedule: schedule.ok ? schedule.value : null,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { VAPID_PUBLIC_KEY } from "./push.js";
|
|
2
|
+
import { BRAND_NAME } from "../brand.js";
|
|
3
|
+
/** 没有别名的老订阅:按推送网关猜一个(Apple / Chrome-FCM),总比一串域名好认 */
|
|
4
|
+
function defaultName(host) {
|
|
5
|
+
if (host.includes("push.apple.com"))
|
|
6
|
+
return "Apple 设备(Safari)";
|
|
7
|
+
if (host.includes("googleapis.com") || host.includes("fcm"))
|
|
8
|
+
return "Chrome 设备";
|
|
9
|
+
return host;
|
|
10
|
+
}
|
|
11
|
+
export function registerPushRoutes(app, options) {
|
|
12
|
+
app.get("/api/push/key", (_request, response) => {
|
|
13
|
+
response.json({ publicKey: VAPID_PUBLIC_KEY });
|
|
14
|
+
});
|
|
15
|
+
app.post("/api/push/subscribe", async (request, response) => {
|
|
16
|
+
const body = request.body;
|
|
17
|
+
const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
|
|
18
|
+
if (endpoint === "") {
|
|
19
|
+
response.status(400).json({ error: { code: "push.no_endpoint", message: "缺少 endpoint" } });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const keys = body?.keys;
|
|
23
|
+
const result = await options.store.add({
|
|
24
|
+
endpoint,
|
|
25
|
+
...(typeof keys?.p256dh === "string" && typeof keys.auth === "string"
|
|
26
|
+
? { keys: { p256dh: keys.p256dh, auth: keys.auth } }
|
|
27
|
+
: {}),
|
|
28
|
+
...(typeof body?.clientId === "string" ? { clientId: body.clientId } : {}),
|
|
29
|
+
...(typeof body?.name === "string" &&
|
|
30
|
+
String(body.name).trim() !== ""
|
|
31
|
+
? {
|
|
32
|
+
name: String(body.name)
|
|
33
|
+
.trim()
|
|
34
|
+
.slice(0, 40),
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
});
|
|
39
|
+
response.json(result);
|
|
40
|
+
});
|
|
41
|
+
app.post("/api/push/unsubscribe", async (request, response) => {
|
|
42
|
+
const body = request.body;
|
|
43
|
+
const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
|
|
44
|
+
if (endpoint === "") {
|
|
45
|
+
response.status(400).json({ error: { code: "push.no_endpoint", message: "缺少 endpoint" } });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
response.json(await options.store.remove(endpoint));
|
|
49
|
+
});
|
|
50
|
+
/** 设备清单(不含 keys,端点只给 host + 尾段,界面上展示用) */
|
|
51
|
+
app.get("/api/push/subscriptions", async (_request, response) => {
|
|
52
|
+
const list = await options.store.list();
|
|
53
|
+
response.json({
|
|
54
|
+
total: list.length,
|
|
55
|
+
devices: list.map((item) => {
|
|
56
|
+
let host = "";
|
|
57
|
+
try {
|
|
58
|
+
host = new URL(item.endpoint).host;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
host = "unknown";
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
endpoint: item.endpoint,
|
|
65
|
+
host,
|
|
66
|
+
// 老订阅没存名字:按推送服务商推一个像人话的默认名(用户可再改名)
|
|
67
|
+
name: item.name ?? defaultName(host),
|
|
68
|
+
createdAt: item.createdAt,
|
|
69
|
+
lastSentAt: item.lastSentAt ?? null,
|
|
70
|
+
lastStatus: item.lastStatus ?? null,
|
|
71
|
+
clientId: item.clientId ?? null,
|
|
72
|
+
};
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
app.post("/api/push/test", async (request, response) => {
|
|
77
|
+
const body = request.body;
|
|
78
|
+
const endpoints = Array.isArray(body?.endpoints)
|
|
79
|
+
? body.endpoints.filter((item) => typeof item === "string")
|
|
80
|
+
: undefined;
|
|
81
|
+
const result = await options.store.send({
|
|
82
|
+
title: `${BRAND_NAME} 测试推送`,
|
|
83
|
+
body: "如果你看到这条,说明订阅与推送链路都通了。",
|
|
84
|
+
url: "/",
|
|
85
|
+
tag: "agent-web-test",
|
|
86
|
+
}, endpoints && endpoints.length > 0 ? endpoints : undefined);
|
|
87
|
+
response.json(result);
|
|
88
|
+
});
|
|
89
|
+
app.post("/api/push/rename", async (request, response) => {
|
|
90
|
+
const body = request.body;
|
|
91
|
+
const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
|
|
92
|
+
const name = typeof body?.name === "string" ? body.name.trim().slice(0, 40) : "";
|
|
93
|
+
if (endpoint === "" || name === "") {
|
|
94
|
+
response.status(400).json({ error: { code: "push.bad_request", message: "缺少 endpoint 或 name" } });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
response.json(await options.store.rename(endpoint, name));
|
|
98
|
+
});
|
|
99
|
+
app.post("/api/push/delete", async (request, response) => {
|
|
100
|
+
const body = request.body;
|
|
101
|
+
const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
|
|
102
|
+
if (endpoint === "") {
|
|
103
|
+
response.status(400).json({ error: { code: "push.bad_request", message: "缺少 endpoint" } });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
response.json(await options.store.removeOne(endpoint));
|
|
107
|
+
});
|
|
108
|
+
app.post("/api/push/clear", async (_request, response) => {
|
|
109
|
+
response.json(await options.store.clear());
|
|
110
|
+
});
|
|
111
|
+
app.get("/api/push/count", async (_request, response) => {
|
|
112
|
+
response.json({ total: await options.store.count() });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Push(VAPID):注册订阅 + 推送。
|
|
3
|
+
*
|
|
4
|
+
* 订阅存 `<dataDir>/push-subscriptions.json`(0600,只含 endpoint / keys,没有凭证)。
|
|
5
|
+
* VAPID 密钥默认内置(可用环境变量覆盖),私钥只在服务端。
|
|
6
|
+
*/
|
|
7
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import webpush from "web-push";
|
|
10
|
+
import { BRAND_NAME } from "../brand.js";
|
|
11
|
+
export const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY ??
|
|
12
|
+
"BPnvp8p4QdnOh4vO6-oAhxFGouHJTqY68C_4-goxBh1sphBEb1blz2FgqyEVg5JoFMBdSJKu2tDR-VLkdUNIHrQ";
|
|
13
|
+
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY ?? "UArGsIjinVV7Lk0g3zOdkMctvWIoTzUT8jV2vayiOyQ";
|
|
14
|
+
const VAPID_SUBJECT = process.env.VAPID_SUBJECT ?? "mailto:duzhenxun@126.com";
|
|
15
|
+
export function createPushStore(options) {
|
|
16
|
+
const path = join(options.dataDir, "push-subscriptions.json");
|
|
17
|
+
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
|
18
|
+
const read = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const write = async (list) => {
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
30
|
+
await writeFile(temporary, `${JSON.stringify(list, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
await rename(temporary, path);
|
|
32
|
+
await chmod(path, 0o600);
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
list: read,
|
|
36
|
+
async add(subscription) {
|
|
37
|
+
const list = await read();
|
|
38
|
+
// endpoint 相同视为同一订阅:更新,但**保留用户改过的名字**
|
|
39
|
+
const previous = list.find((item) => item.endpoint === subscription.endpoint);
|
|
40
|
+
const merged = { ...subscription, ...(previous?.name ? { name: previous.name } : {}) };
|
|
41
|
+
const next = [...list.filter((item) => item.endpoint !== subscription.endpoint), merged];
|
|
42
|
+
await write(next);
|
|
43
|
+
return { ok: true, total: next.length };
|
|
44
|
+
},
|
|
45
|
+
async remove(endpoint) {
|
|
46
|
+
const next = (await read()).filter((item) => item.endpoint !== endpoint);
|
|
47
|
+
await write(next);
|
|
48
|
+
return { ok: true, total: next.length };
|
|
49
|
+
},
|
|
50
|
+
async count() {
|
|
51
|
+
return (await read()).length;
|
|
52
|
+
},
|
|
53
|
+
async rename(endpoint, name) {
|
|
54
|
+
const list = await read();
|
|
55
|
+
await write(list.map((item) => (item.endpoint === endpoint ? { ...item, name } : item)));
|
|
56
|
+
return { ok: true, total: (await read()).length };
|
|
57
|
+
},
|
|
58
|
+
async removeOne(endpoint) {
|
|
59
|
+
const next = (await read()).filter((item) => item.endpoint !== endpoint);
|
|
60
|
+
await write(next);
|
|
61
|
+
return { ok: true, total: next.length };
|
|
62
|
+
},
|
|
63
|
+
async clear() {
|
|
64
|
+
await write([]);
|
|
65
|
+
return { ok: true, total: 0 };
|
|
66
|
+
},
|
|
67
|
+
async send(payload, endpoints) {
|
|
68
|
+
const all = await read();
|
|
69
|
+
const list = endpoints ? all.filter((item) => endpoints.includes(item.endpoint)) : all;
|
|
70
|
+
let sent = 0;
|
|
71
|
+
let failed = 0;
|
|
72
|
+
const dead = [];
|
|
73
|
+
const status = new Map();
|
|
74
|
+
for (const subscription of list) {
|
|
75
|
+
try {
|
|
76
|
+
await webpush.sendNotification({ endpoint: subscription.endpoint, keys: subscription.keys }, JSON.stringify(payload));
|
|
77
|
+
sent += 1;
|
|
78
|
+
status.set(subscription.endpoint, "成功");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
failed += 1;
|
|
82
|
+
const code = error.statusCode;
|
|
83
|
+
// 404/410:订阅已失效,顺手清掉,避免每次白打
|
|
84
|
+
if (code === 404 || code === 410)
|
|
85
|
+
dead.push(subscription.endpoint);
|
|
86
|
+
status.set(subscription.endpoint, `失败 ${code ?? ""}`.trim());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 记下「上次推送」:界面要显示时间与结果
|
|
90
|
+
const stamped = all
|
|
91
|
+
.filter((item) => !dead.includes(item.endpoint))
|
|
92
|
+
.map((item) => status.has(item.endpoint)
|
|
93
|
+
? { ...item, lastSentAt: options.now(), lastStatus: status.get(item.endpoint) }
|
|
94
|
+
: item);
|
|
95
|
+
if (stamped.length !== all.length || status.size > 0)
|
|
96
|
+
await write(stamped);
|
|
97
|
+
return { sent, failed };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** 项目名 = 工作区路径的最后一段(拿不到就回落应用名,别把通知的抬头留空) */
|
|
102
|
+
export function projectNameOf(workspace) {
|
|
103
|
+
const name = (workspace ?? "").split("/").filter(Boolean).pop();
|
|
104
|
+
return name && name !== "" ? name : BRAND_NAME;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 回合结束时的那条通知(index 里只留一行调用)。
|
|
108
|
+
*
|
|
109
|
+
* 排版按手机锁屏来定:**第一行(title)= 项目名,正文(body)= 会话名**。
|
|
110
|
+
* 锁屏只露两行,「哪个项目的哪个会话」一眼能认;「回合完成」这种套话不占位置
|
|
111
|
+
* (通知本身出现就代表完成了)。点开仍直达那条会话(url 带 ?session=)。
|
|
112
|
+
*/
|
|
113
|
+
export function notifyTurnFinished(store, info) {
|
|
114
|
+
void store.send({
|
|
115
|
+
title: projectNameOf(info.workspace),
|
|
116
|
+
body: info.title.trim() || "会话",
|
|
117
|
+
url: `/?session=${encodeURIComponent(info.conversationId)}`,
|
|
118
|
+
tag: `turn-${info.conversationId}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 审批桥(ADR-0006):把「引擎里的危险操作」与「界面上的一次点击」连起来。
|
|
3
|
+
*
|
|
4
|
+
* 职责就三件,刻意从 runtime 里拆出来(否则 runtime 会越过 400 行护栏,ADR-0010):
|
|
5
|
+
* 1. `handler`:引擎在危险操作前调它,返回 Promise 等用户决定;
|
|
6
|
+
* 2. `approve`:界面点完按钮调它,解析那个 Promise;
|
|
7
|
+
* 3. 规则:命中「总是允许这类」就直接放行(含跨重启的持久规则),并把新规则交给存储。
|
|
8
|
+
*
|
|
9
|
+
* 默认模式是 `allow`(ADR-0034:自己一个人用的工作台,默认不想被弹窗打断),
|
|
10
|
+
* 但下面这条底线不变:
|
|
11
|
+
* - 没有 handler / 被取消 → **拒绝**(绝不因为"没接上"就放行);
|
|
12
|
+
* - 回合结束时若还悬着一个审批 → 按「拒绝」收尾,不留下一个永远等不到答复的 Promise。
|
|
13
|
+
*/
|
|
14
|
+
import { hasRule, withRule } from "../approval/rules.js";
|
|
15
|
+
export function createApprovalBridge(options) {
|
|
16
|
+
let rules = [];
|
|
17
|
+
/** 本次运行内记住的(即使写盘失败也生效) */
|
|
18
|
+
const sessionRules = new Set();
|
|
19
|
+
/**
|
|
20
|
+
* 审批模式:只在内存里(不落盘),**默认 allow**(ADR-0034)。
|
|
21
|
+
* 切到 allow 时,**下一次**需要审批的操作起就直接放行;已经悬着的那个弹窗不会凭空消失
|
|
22
|
+
* (用户仍能选),避免"点了放行却看到一个闪现消失的弹窗"。
|
|
23
|
+
*/
|
|
24
|
+
let approvalMode = "allow";
|
|
25
|
+
const pending = [];
|
|
26
|
+
const viewOf = (entry) => ({
|
|
27
|
+
requestId: entry.requestId,
|
|
28
|
+
toolName: entry.request.toolName,
|
|
29
|
+
argsSummary: entry.request.argsSummary,
|
|
30
|
+
riskNote: entry.request.riskNote,
|
|
31
|
+
scopeHint: entry.request.scopeHint,
|
|
32
|
+
});
|
|
33
|
+
const showHead = () => {
|
|
34
|
+
const head = pending[0];
|
|
35
|
+
options.onPending(head ? viewOf(head) : null);
|
|
36
|
+
};
|
|
37
|
+
const bridge = {
|
|
38
|
+
async handler(request) {
|
|
39
|
+
// 本会话全部放行:不问,直接 once(不记忆、不写盘,重启即恢复询问)
|
|
40
|
+
if (approvalMode === "allow")
|
|
41
|
+
return "once";
|
|
42
|
+
if (sessionRules.has(request.scopeKey) || hasRule(rules, request.scopeKey))
|
|
43
|
+
return "always";
|
|
44
|
+
const requestId = options.nextId("appr");
|
|
45
|
+
let settle = () => { };
|
|
46
|
+
const promise = new Promise((resolve) => {
|
|
47
|
+
settle = resolve;
|
|
48
|
+
});
|
|
49
|
+
pending.push({ requestId, request, scopeKey: request.scopeKey, scopeHint: request.scopeHint, settle });
|
|
50
|
+
// 已经有一条在显示时不抢镜:等队首处理完再顶上(否则界面上那条会突然被换掉)
|
|
51
|
+
if (pending.length === 1)
|
|
52
|
+
options.onPending(viewOf(pending[0]));
|
|
53
|
+
return promise;
|
|
54
|
+
},
|
|
55
|
+
approve(requestId, decision) {
|
|
56
|
+
const index = pending.findIndex((entry) => entry.requestId === requestId);
|
|
57
|
+
const current = index >= 0 ? pending.splice(index, 1)[0] : undefined;
|
|
58
|
+
if (!current) {
|
|
59
|
+
return { ok: false, error: { code: "approval.unknown_request", message: "没有待处理的审批请求" } };
|
|
60
|
+
}
|
|
61
|
+
if (decision === "always") {
|
|
62
|
+
sessionRules.add(current.scopeKey);
|
|
63
|
+
const nextRules = withRule(rules, {
|
|
64
|
+
scopeKey: current.scopeKey,
|
|
65
|
+
scopeHint: current.scopeHint,
|
|
66
|
+
createdAt: options.now(),
|
|
67
|
+
});
|
|
68
|
+
rules = nextRules;
|
|
69
|
+
// 内存规则立即生效;写盘失败只影响下次启动 → 只警告,不阻塞回合
|
|
70
|
+
void options.rules.save(nextRules).catch((error) => {
|
|
71
|
+
options.onNotice(`审批规则写盘失败:${error instanceof Error ? error.message : String(error)}`, "warn");
|
|
72
|
+
});
|
|
73
|
+
options.onNotice(`已记住:以后这类操作不再询问(${current.scopeHint})`, "info");
|
|
74
|
+
}
|
|
75
|
+
current.settle(decision);
|
|
76
|
+
showHead(); // 队里还有下一条就顶上来,否则清空弹窗
|
|
77
|
+
return { ok: true };
|
|
78
|
+
},
|
|
79
|
+
async loadRules() {
|
|
80
|
+
rules = await options.rules.load();
|
|
81
|
+
return rules.length;
|
|
82
|
+
},
|
|
83
|
+
rulesCount() {
|
|
84
|
+
return rules.length;
|
|
85
|
+
},
|
|
86
|
+
setMode(next) {
|
|
87
|
+
approvalMode = next;
|
|
88
|
+
},
|
|
89
|
+
mode() {
|
|
90
|
+
return approvalMode;
|
|
91
|
+
},
|
|
92
|
+
cancelPending() {
|
|
93
|
+
if (pending.length === 0)
|
|
94
|
+
return;
|
|
95
|
+
const waiting = pending.splice(0);
|
|
96
|
+
options.onPending(null);
|
|
97
|
+
// 安全默认值:所有悬着的审批都按拒绝收尾(绝不默认放行)
|
|
98
|
+
for (const entry of waiting)
|
|
99
|
+
entry.settle("deny");
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return bridge;
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function resolveAttachments(text, attachments, uploadStore) {
|
|
2
|
+
let promptText = text;
|
|
3
|
+
const images = [];
|
|
4
|
+
const missing = [];
|
|
5
|
+
for (const attachment of attachments ?? []) {
|
|
6
|
+
const found = uploadStore ? await uploadStore.read(attachment.id) : null;
|
|
7
|
+
if (!found) {
|
|
8
|
+
missing.push(attachment.name);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
if (attachment.kind === "image" && found.mime.startsWith("image/")) {
|
|
12
|
+
images.push({ data: found.bytes.toString("base64"), mimeType: found.mime });
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const path = uploadStore?.pathOf?.(attachment.id) ?? attachment.name;
|
|
16
|
+
promptText += `\n\n(附上文件,未注入内容:${path})`;
|
|
17
|
+
}
|
|
18
|
+
if (promptText.trim() === "")
|
|
19
|
+
promptText = "(见附件)";
|
|
20
|
+
return { promptText, images, missing };
|
|
21
|
+
}
|