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,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「想一想」(换一批建议)用的隐藏指令哨兵。
|
|
3
|
+
*
|
|
4
|
+
* 它不是一个 action 标记(命名空间 `agent-web` 不在 KNOWN_TOOLS 里),
|
|
5
|
+
* 作用只有一个:让历史回放能认出「这条用户消息是我们自己发的重生成请求」并把它丢掉,
|
|
6
|
+
* 否则用户会在对话里看到一条莫名其妙的「请再给三条建议」。
|
|
7
|
+
*/
|
|
8
|
+
export const REFRESH_ACTIONS_SENTINEL = "[[agent-web:refresh-actions]]";
|
|
9
|
+
/**
|
|
10
|
+
* 「想一想」要发给模型的指令(纯函数,单测覆盖)。
|
|
11
|
+
*
|
|
12
|
+
* 两个刻意的写法:
|
|
13
|
+
* - 示例给**空标记** `[[action:suggest:]]`(解析器会忽略空标签)—— 模型即使原样抄回来也不会冒假建议;
|
|
14
|
+
* - 明确「4 到 5 条」:ADR-0039 定的条数(3 条里常有一条是废话)。
|
|
15
|
+
*/
|
|
16
|
+
export function buildRefreshActionsPrompt(labels, count) {
|
|
17
|
+
const avoid = labels.length > 0 ? `(不要和这些重复:${labels.join("、")})` : "";
|
|
18
|
+
// 手机上一行放不下 4~5 个按钮:客户端可以指定「就要 3 条」。
|
|
19
|
+
const wanted = typeof count === "number" && Number.isInteger(count) && count >= 1 && count <= 5 ? count : null;
|
|
20
|
+
const amount = wanted === null ? "4 到 5 条" : `${wanted} 条`;
|
|
21
|
+
// **一行**:这条指令在引擎侧会作为一条真实用户消息落盘(DSH web / pi CLI 里也看得见),
|
|
22
|
+
// 所以压到最短,同时语法自包含(dsh / codex 没有系统提示教它这个格式)。
|
|
23
|
+
return `${REFRESH_ACTIONS_SENTINEL} 再给 ${amount}不同的下一步建议${avoid},每行一条 [[action:suggest:]](把按钮文字填在 suggest: 后面),只输出标记,不要正文。`;
|
|
24
|
+
}
|
|
25
|
+
/** 这条用户消息是不是「想一想」的隐藏请求 */
|
|
26
|
+
export function isRefreshActionsPrompt(text) {
|
|
27
|
+
return text.trimStart().startsWith(REFRESH_ACTIONS_SENTINEL);
|
|
28
|
+
}
|
|
29
|
+
export const MAX_ACTIONS = 5; // 每轮建议 4~5 条:超过只留最新的那批
|
|
30
|
+
export const MAX_LABEL_CHARS = 80;
|
|
31
|
+
export const MAX_PROMPT_CHARS = 2000;
|
|
32
|
+
/** 已认识的标记命名空间(只剥离这些,避免误删正文里的普通双括号) */
|
|
33
|
+
const KNOWN_TOOLS = ["action", "suggest"];
|
|
34
|
+
/** 匹配 `[[tool:op:body]]`(body 里不允许再出现 `[[`,避免嵌套误伤) */
|
|
35
|
+
// op 段可选:[[suggest:下一步]] 与 [[action:suggest:下一步]] 都要认
|
|
36
|
+
const TOKEN_RE = /\[\[\s*([a-z]+)\s*:\s*(?:([a-z]+)\s*:\s*)?([^[\]]*?)\s*\]\]/gi;
|
|
37
|
+
/** 流式期间:末尾那个还没闭合的半截标记(例如 `[[action:su`)也不该显示出来 */
|
|
38
|
+
const TRAILING_INCOMPLETE_RE = /[ \t]*\[\[\s*(?:action|suggest)(?:\s*:[^\]]*)?$/i;
|
|
39
|
+
function splitBody(body) {
|
|
40
|
+
const args = [];
|
|
41
|
+
const kwargs = {};
|
|
42
|
+
let rest = body;
|
|
43
|
+
// 先把 kwargs 抽出来(`prompt=xxx` 可能含逗号 → 按 `,key=` 切)
|
|
44
|
+
const segments = rest.split(",");
|
|
45
|
+
for (const segment of segments) {
|
|
46
|
+
const trimmed = segment.trim();
|
|
47
|
+
if (trimmed === "")
|
|
48
|
+
continue;
|
|
49
|
+
const index = trimmed.indexOf("=");
|
|
50
|
+
const key = index > 0 ? trimmed.slice(0, index).trim() : "";
|
|
51
|
+
if (key !== "" && /^[A-Za-z][A-Za-z0-9_-]*$/.test(key))
|
|
52
|
+
kwargs[key.toLowerCase()] = trimmed.slice(index + 1).trim();
|
|
53
|
+
else
|
|
54
|
+
args.push(trimmed);
|
|
55
|
+
}
|
|
56
|
+
rest = "";
|
|
57
|
+
void rest;
|
|
58
|
+
return { args, kwargs };
|
|
59
|
+
}
|
|
60
|
+
/** 纯函数:解析文本里的建议操作标记(畸形标记直接忽略,不当成错误) */
|
|
61
|
+
export function parseActionMarkers(text) {
|
|
62
|
+
if (!text.includes("[["))
|
|
63
|
+
return [];
|
|
64
|
+
const tokens = [];
|
|
65
|
+
TOKEN_RE.lastIndex = 0;
|
|
66
|
+
let match;
|
|
67
|
+
while ((match = TOKEN_RE.exec(text)) !== null) {
|
|
68
|
+
const [, tool, op, body] = match;
|
|
69
|
+
const toolName = tool.toLowerCase();
|
|
70
|
+
// 没写 op 的([[suggest:文字]] / [[action:文字]])按 suggest 处理
|
|
71
|
+
const opName = (op ?? "suggest").toLowerCase();
|
|
72
|
+
if (!KNOWN_TOOLS.includes(toolName))
|
|
73
|
+
continue;
|
|
74
|
+
if (body.includes("[["))
|
|
75
|
+
continue; // 嵌套/畸形:不认
|
|
76
|
+
if (opName === "clear") {
|
|
77
|
+
if (body.trim() === "all" || body.trim() === "")
|
|
78
|
+
tokens.push({ kind: "clear" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!["suggest", "add", "next", "reply", "choice", "option"].includes(opName))
|
|
82
|
+
continue;
|
|
83
|
+
const { args, kwargs } = splitBody(body);
|
|
84
|
+
const argText = args.join(",").trim();
|
|
85
|
+
const label = (kwargs.label ?? argText).trim();
|
|
86
|
+
const prompt = (kwargs.prompt ?? argText).trim();
|
|
87
|
+
if (label === "" && prompt === "")
|
|
88
|
+
continue;
|
|
89
|
+
tokens.push({
|
|
90
|
+
kind: "suggest",
|
|
91
|
+
label: (label || prompt).slice(0, MAX_LABEL_CHARS),
|
|
92
|
+
prompt: (prompt || label).slice(0, MAX_PROMPT_CHARS),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return tokens;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 纯函数:从展示文本里剥离标记。
|
|
99
|
+
* `stripTrailingIncomplete` 用于流式期间(把还没闭合的半截标记也去掉)。
|
|
100
|
+
*/
|
|
101
|
+
export function stripMarkers(text, options = {}) {
|
|
102
|
+
if (!text.includes("[["))
|
|
103
|
+
return text;
|
|
104
|
+
let out = text.replace(TOKEN_RE, (raw, tool, _op, body) => {
|
|
105
|
+
if (!KNOWN_TOOLS.includes(String(tool).toLowerCase()))
|
|
106
|
+
return raw;
|
|
107
|
+
if (body.includes("[["))
|
|
108
|
+
return raw;
|
|
109
|
+
return "";
|
|
110
|
+
});
|
|
111
|
+
if (options.stripTrailingIncomplete)
|
|
112
|
+
out = out.replace(TRAILING_INCOMPLETE_RE, "");
|
|
113
|
+
if (out === text)
|
|
114
|
+
return text;
|
|
115
|
+
// 剥离后可能留下空行(标记通常独占一行)
|
|
116
|
+
return out
|
|
117
|
+
.replace(/^[ \t]+\n/gm, "\n")
|
|
118
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
119
|
+
.trimEnd();
|
|
120
|
+
}
|
|
121
|
+
/** 纯函数:把一段助手文本归约成「干净文本 + 建议列表」 */
|
|
122
|
+
export function reduceActionMarkers(text, current, nextId) {
|
|
123
|
+
const tokens = parseActionMarkers(text);
|
|
124
|
+
if (tokens.length === 0)
|
|
125
|
+
return { text, actions: current };
|
|
126
|
+
let actions = [...current];
|
|
127
|
+
for (const token of tokens) {
|
|
128
|
+
if (token.kind === "clear") {
|
|
129
|
+
actions = [];
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const exists = actions.some((item) => item.label === token.label && item.prompt === token.prompt);
|
|
133
|
+
if (exists)
|
|
134
|
+
continue;
|
|
135
|
+
actions.push({ id: nextId(), label: token.label, prompt: token.prompt });
|
|
136
|
+
if (actions.length > MAX_ACTIONS)
|
|
137
|
+
actions = actions.slice(actions.length - MAX_ACTIONS);
|
|
138
|
+
}
|
|
139
|
+
return { text: stripMarkers(text), actions };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 流式过滤器:把「可能是标记开头」的内容先扣住。
|
|
143
|
+
*
|
|
144
|
+
* 为什么需要它:模型是逐字吐出来的,`[[action:suggest:继续]]` 会被拆成好几个 chunk。
|
|
145
|
+
* 如果只在回合结束时剥离,用户会在流式过程中看到原始语法(截图里就这么被撞见过)。
|
|
146
|
+
* 而「边流边剥」不能靠事后删除(客户端只支持追加重建),只能**先把可能是标记的尾巴扣住**:
|
|
147
|
+
* - 遇到 `[[` 就暂停输出,等它闭合;
|
|
148
|
+
* - 闭合后:是认识的标记 → 直接丢弃;不是认识的(如 `[[todo:...]]`)→ 当普通文本补上;
|
|
149
|
+
* - 长时间不闭合(横文里的双括号)→ 超过上限就当下普通文本吐出,不能把内容卡死。
|
|
150
|
+
*/
|
|
151
|
+
export const MAX_PENDING_CHARS = 500;
|
|
152
|
+
export function createMarkerStreamFilter() {
|
|
153
|
+
let pending = "";
|
|
154
|
+
const drain = (final) => {
|
|
155
|
+
let out = "";
|
|
156
|
+
for (;;) {
|
|
157
|
+
const start = pending.indexOf("[[");
|
|
158
|
+
if (start < 0) {
|
|
159
|
+
// 没有完整的 "[[":但结尾可能孤立着一个 "["(下一个 chunk 才补上第二个)→ 扣住它
|
|
160
|
+
if (!final && pending.endsWith("[")) {
|
|
161
|
+
out += pending.slice(0, -1);
|
|
162
|
+
pending = "[";
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
out += pending;
|
|
166
|
+
pending = "";
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
out += pending.slice(0, start);
|
|
170
|
+
const rest = pending.slice(start);
|
|
171
|
+
const end = rest.indexOf("]]");
|
|
172
|
+
if (end < 0) {
|
|
173
|
+
// 还没闭合:扣住等内容(除非已经不可能是个标记)
|
|
174
|
+
if (final || rest.length > MAX_PENDING_CHARS) {
|
|
175
|
+
out += rest;
|
|
176
|
+
pending = "";
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
pending = rest;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
const expression = rest.slice(0, end + 2);
|
|
183
|
+
pending = rest.slice(end + 2);
|
|
184
|
+
// 认识的名字空间:丢弃;不认识的:当普通文本吐出(与 stripMarkers 一个口径)
|
|
185
|
+
const known = /^\[\[\s*(?:action|suggest)\s*:/i.test(expression);
|
|
186
|
+
if (!known)
|
|
187
|
+
out += expression;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
push(chunk) {
|
|
192
|
+
if (chunk === "")
|
|
193
|
+
return "";
|
|
194
|
+
pending += chunk;
|
|
195
|
+
return drain(false);
|
|
196
|
+
},
|
|
197
|
+
flush() {
|
|
198
|
+
return drain(true);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 把标记从一条消息里落实:剥离正文里的标记,并把建议项追加到当前列表。
|
|
204
|
+
* 只改文本块(工具卡与错误块不动)。
|
|
205
|
+
*/
|
|
206
|
+
export function applyMarkersToMessage(message, current, nextId) {
|
|
207
|
+
const rawText = message.blocks
|
|
208
|
+
.filter((block) => block.kind === "text")
|
|
209
|
+
.map((block) => block.text)
|
|
210
|
+
.join("");
|
|
211
|
+
if (!rawText.includes("[["))
|
|
212
|
+
return { blocks: message.blocks, actions: current };
|
|
213
|
+
const { text: cleaned, actions } = reduceActionMarkers(rawText, current, nextId);
|
|
214
|
+
// 把全部文本块合并成一块干净文本,并接在第一个文本块的位置上(保序)
|
|
215
|
+
const firstTextIndex = message.blocks.findIndex((block) => block.kind === "text");
|
|
216
|
+
const nonTextBefore = message.blocks.slice(0, firstTextIndex).filter((block) => block.kind !== "text");
|
|
217
|
+
const nonTextAfter = message.blocks.slice(firstTextIndex).filter((block) => block.kind !== "text");
|
|
218
|
+
const blocks = [
|
|
219
|
+
...nonTextBefore,
|
|
220
|
+
...(cleaned.trim() === "" ? [] : [{ kind: "text", text: cleaned }]),
|
|
221
|
+
...nonTextAfter,
|
|
222
|
+
];
|
|
223
|
+
return { blocks, actions };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 历史回放清洗(pi / dsh 共用):
|
|
227
|
+
* 1. 丢掉「想一想」的隐藏请求(用户不该看到那条内部消息);
|
|
228
|
+
* 2. 剥掉正文里残留的 action 标记(历史记录里存的是原文);
|
|
229
|
+
* 3. 剥完变空的消息(例如模型只回了三条标记)直接丢掉,不留空气泡。
|
|
230
|
+
*/
|
|
231
|
+
export function sanitizeHistoricalActions(messages) {
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const message of messages) {
|
|
234
|
+
if (message.role === "user") {
|
|
235
|
+
const text = message.blocks
|
|
236
|
+
.filter((block) => block.kind === "text")
|
|
237
|
+
.map((block) => block.text)
|
|
238
|
+
.join("");
|
|
239
|
+
if (isRefreshActionsPrompt(text))
|
|
240
|
+
continue;
|
|
241
|
+
// 防御:哨兵被引擎包了一层(或模型回显到助手消息里)时也要剥掉
|
|
242
|
+
const blocks = message.blocks.map((block) => block.kind === "text" && block.text.includes(REFRESH_ACTIONS_SENTINEL)
|
|
243
|
+
? { ...block, text: block.text.split(REFRESH_ACTIONS_SENTINEL).join("").trim() }
|
|
244
|
+
: block);
|
|
245
|
+
const cleaned = blocks.filter((block) => block.kind !== "text" || block.text.trim() !== "");
|
|
246
|
+
if (cleaned.length === 0)
|
|
247
|
+
continue;
|
|
248
|
+
out.push({ ...message, blocks: cleaned });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!message.blocks.some((block) => block.kind === "text")) {
|
|
252
|
+
out.push(message);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const cleaned = message.blocks.map((block) => block.kind === "text" ? { ...block, text: stripMarkers(block.text) } : block);
|
|
256
|
+
const blocks = cleaned.filter((block) => block.kind !== "text" || block.text.trim() !== "");
|
|
257
|
+
if (blocks.length === 0)
|
|
258
|
+
continue;
|
|
259
|
+
out.push({ ...message, blocks });
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
export const REQUEST_TYPES = [
|
|
3
|
+
"set_engine",
|
|
4
|
+
"refresh_actions",
|
|
5
|
+
"send_message",
|
|
6
|
+
"interrupt",
|
|
7
|
+
"rename_session",
|
|
8
|
+
"approve",
|
|
9
|
+
"get_state",
|
|
10
|
+
"new_chat",
|
|
11
|
+
"switch_conversation",
|
|
12
|
+
"set_settings",
|
|
13
|
+
"set_model",
|
|
14
|
+
"set_approval_mode",
|
|
15
|
+
"list_providers",
|
|
16
|
+
"upsert_provider",
|
|
17
|
+
"delete_provider",
|
|
18
|
+
"test_provider",
|
|
19
|
+
"set_thinking_level",
|
|
20
|
+
"update_model",
|
|
21
|
+
"remove_model",
|
|
22
|
+
"cancel_queued",
|
|
23
|
+
"set_enabled_models",
|
|
24
|
+
];
|
|
25
|
+
const DECISIONS = ["once", "always", "deny"];
|
|
26
|
+
function isAttachmentRef(value) {
|
|
27
|
+
if (!isRecord(value))
|
|
28
|
+
return false;
|
|
29
|
+
const { id, kind, name, mime, bytes } = value;
|
|
30
|
+
return (nonEmptyString(id) &&
|
|
31
|
+
(kind === "image" || kind === "file") &&
|
|
32
|
+
typeof name === "string" &&
|
|
33
|
+
typeof mime === "string" &&
|
|
34
|
+
typeof bytes === "number");
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return typeof value === "object" && value !== null;
|
|
38
|
+
}
|
|
39
|
+
function nonEmptyString(value) {
|
|
40
|
+
return typeof value === "string" && value.length > 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 解析并校验一条上行请求。返回 null 表示「不是合法请求」,
|
|
44
|
+
* 调用方负责回 `protocol.unknown_type` / `protocol.bad_payload`。
|
|
45
|
+
*/
|
|
46
|
+
export function parseClientRequest(raw) {
|
|
47
|
+
if (!isRecord(raw))
|
|
48
|
+
return null;
|
|
49
|
+
const { id, type } = raw;
|
|
50
|
+
if (!nonEmptyString(id) || !nonEmptyString(type))
|
|
51
|
+
return null;
|
|
52
|
+
if (!REQUEST_TYPES.includes(type))
|
|
53
|
+
return null;
|
|
54
|
+
if (type === "get_state")
|
|
55
|
+
return { id, type: "get_state" };
|
|
56
|
+
if (type === "new_chat") {
|
|
57
|
+
const workspace = isRecord(raw.payload) && nonEmptyString(raw.payload.workspace) ? raw.payload.workspace : undefined;
|
|
58
|
+
return workspace ? { id, type: "new_chat", payload: { workspace } } : { id, type: "new_chat" };
|
|
59
|
+
}
|
|
60
|
+
if (type === "refresh_actions") {
|
|
61
|
+
const payload = isRecord(raw.payload) ? raw.payload : undefined;
|
|
62
|
+
const conversationId = payload && nonEmptyString(payload.conversationId) ? payload.conversationId : undefined;
|
|
63
|
+
const rawCount = payload?.count;
|
|
64
|
+
const count = typeof rawCount === "number" && Number.isInteger(rawCount) && rawCount >= 1 && rawCount <= 5
|
|
65
|
+
? rawCount
|
|
66
|
+
: undefined;
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
type: "refresh_actions",
|
|
70
|
+
...(conversationId || count ? { payload: { ...(conversationId ? { conversationId } : {}), ...(count ? { count } : {}) } } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// 这两个请求的 provider 在**顶层**(老的形状),也容忍放进 payload 里
|
|
74
|
+
if (type === "delete_provider" || type === "test_provider") {
|
|
75
|
+
const source = isRecord(raw.payload) ? raw.payload.provider : undefined;
|
|
76
|
+
const rawProvider = typeof raw.provider === "string" ? raw.provider : source;
|
|
77
|
+
const provider = typeof rawProvider === "string" ? rawProvider.trim() : "";
|
|
78
|
+
if (provider === "")
|
|
79
|
+
return null;
|
|
80
|
+
return type === "delete_provider"
|
|
81
|
+
? { id, type: "delete_provider", provider }
|
|
82
|
+
: { id, type: "test_provider", provider };
|
|
83
|
+
}
|
|
84
|
+
const payload = raw.payload;
|
|
85
|
+
if (!isRecord(payload))
|
|
86
|
+
return null;
|
|
87
|
+
if (type === "send_message") {
|
|
88
|
+
// 附件只校验形状(id/kind/name/mime/bytes):真正的文件校验在服务端读回时做
|
|
89
|
+
const attachments = Array.isArray(payload.attachments)
|
|
90
|
+
? payload.attachments.filter(isAttachmentRef).slice(0, 5)
|
|
91
|
+
: undefined;
|
|
92
|
+
if (!nonEmptyString(payload.text) && !attachments?.length)
|
|
93
|
+
return null;
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
type: "send_message",
|
|
97
|
+
payload: {
|
|
98
|
+
text: String(payload.text ?? ""),
|
|
99
|
+
...(payload.mode === "queue" || payload.mode === "steer" ? { mode: payload.mode } : {}),
|
|
100
|
+
...(attachments?.length ? { attachments } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (type === "list_providers")
|
|
105
|
+
return { id, type: "list_providers" };
|
|
106
|
+
if (type === "upsert_provider") {
|
|
107
|
+
// 细节校验在 providers/validate.ts(这里只做形状校验,避免协议层重复实现业务规则)
|
|
108
|
+
if (!isRecord(payload))
|
|
109
|
+
return null;
|
|
110
|
+
const models = Array.isArray(payload.models) ? payload.models : null;
|
|
111
|
+
if (models === null)
|
|
112
|
+
return null;
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
type: "upsert_provider",
|
|
116
|
+
payload: {
|
|
117
|
+
provider: String(payload.provider ?? ""),
|
|
118
|
+
baseUrl: String(payload.baseUrl ?? ""),
|
|
119
|
+
...(typeof payload.api === "string" ? { api: payload.api } : {}),
|
|
120
|
+
...(typeof payload.apiKey === "string" ? { apiKey: payload.apiKey } : {}),
|
|
121
|
+
...(typeof payload.previousProvider === "string" && payload.previousProvider.trim() !== ""
|
|
122
|
+
? { previousProvider: payload.previousProvider.trim() }
|
|
123
|
+
: {}),
|
|
124
|
+
...(isRecord(payload.headers)
|
|
125
|
+
? {
|
|
126
|
+
headers: Object.fromEntries(Object.entries(payload.headers).filter(([, value]) => typeof value === "string")),
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
models: models.map((item) => isRecord(item)
|
|
130
|
+
? { id: String(item.id ?? ""), ...(typeof item.name === "string" ? { name: item.name } : {}) }
|
|
131
|
+
: { id: "" }),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (type === "update_model") {
|
|
136
|
+
if (!isRecord(payload))
|
|
137
|
+
return null;
|
|
138
|
+
const provider = typeof payload.provider === "string" ? payload.provider.trim() : "";
|
|
139
|
+
const model = isRecord(payload.model) ? payload.model : null;
|
|
140
|
+
if (provider === "" || model === null)
|
|
141
|
+
return null;
|
|
142
|
+
return { id, type: "update_model", payload: { provider, model } };
|
|
143
|
+
}
|
|
144
|
+
if (type === "remove_model") {
|
|
145
|
+
const provider = typeof payload?.provider === "string" ? payload.provider.trim() : "";
|
|
146
|
+
const modelId = typeof payload?.modelId === "string" ? payload.modelId.trim() : "";
|
|
147
|
+
return provider === "" || modelId === "" ? null : { id, type: "remove_model", payload: { provider, modelId } };
|
|
148
|
+
}
|
|
149
|
+
if (type === "set_enabled_models") {
|
|
150
|
+
const provider = typeof payload?.provider === "string" ? payload.provider.trim() : "";
|
|
151
|
+
if (provider === "" || typeof payload?.enabled !== "boolean")
|
|
152
|
+
return null;
|
|
153
|
+
const modelId = typeof payload?.modelId === "string" && payload.modelId.trim() !== "" ? payload.modelId.trim() : undefined;
|
|
154
|
+
return {
|
|
155
|
+
id,
|
|
156
|
+
type: "set_enabled_models",
|
|
157
|
+
payload: { provider, enabled: payload.enabled, ...(modelId ? { modelId } : {}) },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (type === "cancel_queued") {
|
|
161
|
+
const queueId = typeof payload?.queueId === "string" ? payload.queueId.trim() : "";
|
|
162
|
+
return queueId === "" ? null : { id, type: "cancel_queued", payload: { queueId } };
|
|
163
|
+
}
|
|
164
|
+
if (type === "set_thinking_level") {
|
|
165
|
+
const level = typeof payload?.level === "string" ? payload.level.trim() : "";
|
|
166
|
+
return level === "" ? null : { id, type: "set_thinking_level", payload: { level } };
|
|
167
|
+
}
|
|
168
|
+
if (type === "set_approval_mode") {
|
|
169
|
+
const mode = payload.mode;
|
|
170
|
+
return mode === "ask" || mode === "allow" ? { id, type: "set_approval_mode", payload: { mode } } : null;
|
|
171
|
+
}
|
|
172
|
+
if (type === "set_engine") {
|
|
173
|
+
// 引擎名只做形状校验;「是不是注册过的引擎」由目录判断(未知名字要回一条可读错误)
|
|
174
|
+
return nonEmptyString(payload.engine) ? { id, type: "set_engine", payload: { engine: payload.engine } } : null;
|
|
175
|
+
}
|
|
176
|
+
if (type === "set_model") {
|
|
177
|
+
return nonEmptyString(payload.modelId) ? { id, type: "set_model", payload: { modelId: payload.modelId } } : null;
|
|
178
|
+
}
|
|
179
|
+
if (type === "set_settings") {
|
|
180
|
+
// 结构有意放宽:真正的归一化在服务端 settings 模块(超长/重复/条数都在那里收敛)
|
|
181
|
+
return { id, type: "set_settings", payload: payload };
|
|
182
|
+
}
|
|
183
|
+
if (type === "switch_conversation") {
|
|
184
|
+
return nonEmptyString(payload.conversationId)
|
|
185
|
+
? { id, type: "switch_conversation", payload: { conversationId: payload.conversationId } }
|
|
186
|
+
: null;
|
|
187
|
+
}
|
|
188
|
+
if (type === "rename_session") {
|
|
189
|
+
const conversationId = typeof payload.conversationId === "string" ? payload.conversationId.trim() : "";
|
|
190
|
+
const title = typeof payload.title === "string" ? payload.title.replace(/\s+/g, " ").trim().slice(0, 60) : "";
|
|
191
|
+
return conversationId === "" ? null : { id, type: "rename_session", payload: { conversationId, title } };
|
|
192
|
+
}
|
|
193
|
+
if (type === "interrupt") {
|
|
194
|
+
return nonEmptyString(payload.conversationId)
|
|
195
|
+
? { id, type: "interrupt", payload: { conversationId: payload.conversationId } }
|
|
196
|
+
: null;
|
|
197
|
+
}
|
|
198
|
+
return nonEmptyString(payload.requestId) && DECISIONS.includes(payload.decision)
|
|
199
|
+
? { id, type: "approve", payload: { requestId: payload.requestId, decision: payload.decision } }
|
|
200
|
+
: null;
|
|
201
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** 三类消息:ClientRequest / ServerReply / ServerEvent。 */
|
|
2
|
+
export const PROTOCOL_VERSION = 1;
|
|
3
|
+
export const SNAPSHOT_MESSAGE_LIMIT = 100;
|
|
4
|
+
export const MAX_FRAME_BYTES = 64 * 1024;
|
|
5
|
+
export const TOOL_EXCERPT_CHARS = 2000; // 工具输出摘录上限
|
|
6
|
+
export { parseClientRequest } from "./protocol-parse.js";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi 的 provider 目录(`~/.pi/agent/models.json`)—— nexita / nexita-anthropic 这类就住在这里。
|
|
3
|
+
*
|
|
4
|
+
* 之前我们只「读」它,界面上把这类 provider 标成「只读,改这里会写坏 pi 的目录」。
|
|
5
|
+
* 但用户说得对:**能改,改的就是 pi 的配置**,只是要按 pi 的格式正确地写。
|
|
6
|
+
* 参照 pi 文档(docs/models.md)的字段:
|
|
7
|
+
*
|
|
8
|
+
* { "providers": { "<provider>": {
|
|
9
|
+
* "api": "anthropic-messages" | "openai-completions" | …,
|
|
10
|
+
* "baseUrl": "https://…",
|
|
11
|
+
* "apiKey": "sk-…" | "ENV_VAR" | "!command",
|
|
12
|
+
* "headers": { … },
|
|
13
|
+
* "models": [{ id, name, reasoning, input: ["text","image"], contextWindow, maxTokens, cost }]
|
|
14
|
+
* } } }
|
|
15
|
+
*
|
|
16
|
+
* 纪律与 models-store.json 相同:原子写 + 0600(里面有明文 key),且**只动目标 provider**。
|
|
17
|
+
*/
|
|
18
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
export const MODELS_FILE = "models.json";
|
|
21
|
+
export async function readCatalog(agentDir) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(await readFile(join(agentDir, MODELS_FILE), "utf8"));
|
|
24
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// 不存在 / 坏文件都按空表处理(与其它配置一致:可重建)
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** 原子写 + 0600:目录文件里有明文 key */
|
|
32
|
+
export async function writeCatalog(agentDir, catalog) {
|
|
33
|
+
const path = join(agentDir, MODELS_FILE);
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
36
|
+
await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`, { mode: 0o600 });
|
|
37
|
+
await rename(temporary, path);
|
|
38
|
+
await chmod(path, 0o600);
|
|
39
|
+
}
|
|
40
|
+
/** 纯函数:改一个模型(不存在就追加);返回新 entry,不改入参 */
|
|
41
|
+
export function patchCatalogModel(entry, modelId, patch) {
|
|
42
|
+
const models = Array.isArray(entry.models) ? entry.models : [];
|
|
43
|
+
const index = models.findIndex((model) => model.id === modelId);
|
|
44
|
+
const current = index >= 0 ? models[index] : { id: modelId };
|
|
45
|
+
const next = patch(current);
|
|
46
|
+
if (next === null) {
|
|
47
|
+
if (index < 0)
|
|
48
|
+
return entry;
|
|
49
|
+
return { ...entry, models: models.filter((model) => model.id !== modelId) };
|
|
50
|
+
}
|
|
51
|
+
const list = [...models];
|
|
52
|
+
if (index >= 0)
|
|
53
|
+
list[index] = next;
|
|
54
|
+
else
|
|
55
|
+
list.push(next);
|
|
56
|
+
return { ...entry, models: list };
|
|
57
|
+
}
|
|
58
|
+
/** 界面表单(ModelSpec 形状)→ pi 目录里的模型字段 */
|
|
59
|
+
export function specToCatalog(spec) {
|
|
60
|
+
return {
|
|
61
|
+
name: spec.name,
|
|
62
|
+
reasoning: spec.reasoning,
|
|
63
|
+
input: spec.imageInput ? ["text", "image"] : ["text"],
|
|
64
|
+
...(spec.contextWindow !== null ? { contextWindow: spec.contextWindow } : {}),
|
|
65
|
+
...(spec.maxTokens !== null ? { maxTokens: spec.maxTokens } : {}),
|
|
66
|
+
...(spec.cost ? { cost: spec.cost } : {}),
|
|
67
|
+
...(spec.costSchedule && spec.costSchedule.length > 0 ? { costSchedule: spec.costSchedule } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** pi 目录条目里的模型 → 界面用的规格视图(没配过就缺省,不编造 0) */
|
|
71
|
+
/** ModelDefinition → 界面用的规格视图(没配过就缺省,不编造 0) */
|
|
72
|
+
export function specView(model) {
|
|
73
|
+
return {
|
|
74
|
+
id: model.id,
|
|
75
|
+
name: model.name ?? model.id,
|
|
76
|
+
reasoning: model.reasoning === true,
|
|
77
|
+
imageInput: Array.isArray(model.input) && model.input.includes("image"),
|
|
78
|
+
...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
|
|
79
|
+
...(typeof model.maxTokens === "number" ? { maxTokens: model.maxTokens } : {}),
|
|
80
|
+
...(model.cost ? { cost: model.cost } : {}),
|
|
81
|
+
...(Array.isArray(model.costSchedule) && model.costSchedule.length > 0
|
|
82
|
+
? { costSchedule: model.costSchedule }
|
|
83
|
+
: {}),
|
|
84
|
+
};
|
|
85
|
+
}
|