agent-loft 0.2.28 → 0.2.30

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/channels/web/server.ts +1 -1
  3. package/web/app.js +8 -5555
  4. package/web/css/base.css +291 -0
  5. package/web/css/composer.css +506 -0
  6. package/web/css/loader.css +196 -0
  7. package/web/css/messages.css +734 -0
  8. package/web/css/mobile.css +100 -0
  9. package/web/css/overlay.css +437 -0
  10. package/web/css/schedule.css +93 -0
  11. package/web/css/settings.css +335 -0
  12. package/web/css/sidebar.css +593 -0
  13. package/web/css/topbar.css +205 -0
  14. package/web/index.html +11 -1
  15. package/web/lib/api.js +26 -0
  16. package/web/lib/dom.js +101 -0
  17. package/web/lib/markdown.js +52 -0
  18. package/web/lib/overlay.js +144 -0
  19. package/web/lib/push.js +70 -0
  20. package/web/lib/time.js +60 -0
  21. package/web/lib/ws.js +232 -0
  22. package/web/settings/agents.js +210 -0
  23. package/web/settings/commands.js +411 -0
  24. package/web/settings/common.js +42 -0
  25. package/web/settings/feishu.js +181 -0
  26. package/web/settings/general.js +184 -0
  27. package/web/settings/index.js +63 -0
  28. package/web/settings/memory.js +366 -0
  29. package/web/settings/notify.js +80 -0
  30. package/web/settings/security.js +188 -0
  31. package/web/settings/users.js +189 -0
  32. package/web/state.js +44 -0
  33. package/web/views/auth.js +61 -0
  34. package/web/views/composer.js +324 -0
  35. package/web/views/conv-actions.js +404 -0
  36. package/web/views/interaction.js +140 -0
  37. package/web/views/layout.js +186 -0
  38. package/web/views/messages.js +510 -0
  39. package/web/views/quota.js +145 -0
  40. package/web/views/schedule.js +120 -0
  41. package/web/views/sidebar.js +455 -0
  42. package/web/views/slash.js +185 -0
  43. package/web/views/topbar.js +394 -0
  44. package/web/views/voice.js +189 -0
  45. package/web/style.css +0 -3500
@@ -0,0 +1,60 @@
1
+ /** 用时按大小换单位:12.3秒 / 3分12秒 / 1小时5分(和服务端 formatDuration 一致) */
2
+ function formatDuration(ms) {
3
+ const sec = ms / 1000;
4
+ if (sec < 60) return `${sec.toFixed(1)}秒`;
5
+ const total = Math.round(sec);
6
+ const hr = Math.floor(total / 3600);
7
+ const m = Math.floor((total % 3600) / 60);
8
+ const s = total % 60;
9
+ if (hr > 0) return `${hr}小时${m ? `${m}分` : ""}`;
10
+ return `${m}分${s ? `${s}秒` : ""}`;
11
+ }
12
+
13
+ /** 回复进行中的实时用时:整秒跳动,12秒 / 3分05秒 / 1小时05分 */
14
+ export function liveDuration(ms) {
15
+ const total = Math.max(0, Math.floor(ms / 1000));
16
+ const hr = Math.floor(total / 3600);
17
+ const m = Math.floor((total % 3600) / 60);
18
+ const s = total % 60;
19
+ if (hr > 0) return `${hr}小时${String(m).padStart(2, "0")}分`;
20
+ if (m > 0) return `${m}分${String(s).padStart(2, "0")}秒`;
21
+ return `${s}秒`;
22
+ }
23
+
24
+ /** 服务端时间 - 本机时间。事件里的 ts 是服务端时钟,算用时要换算过来,免得两边时钟不准 */
25
+ let clockSkew = 0;
26
+ export const serverNow = () => Date.now() + clockSkew;
27
+ export const setServerTime = (serverTime) => (clockSkew = serverTime - Date.now());
28
+
29
+ /** 所有进行中的用时一秒刷一次,只改文字,不重建消息 */
30
+ setInterval(() => {
31
+ for (const el of document.querySelectorAll(".ai-elapsed[data-start]")) {
32
+ el.textContent = liveDuration(serverNow() - Number(el.dataset.start));
33
+ }
34
+ }, 1000);
35
+
36
+ /** 老版本记下的脚注是「用时 1234.5s」,显示时也换成时 / 分 / 秒 */
37
+ export function tidyFooter(footer) {
38
+ return footer?.replace(/用时 ([\d.]+)s/, (_, s) => `用时 ${formatDuration(Number(s) * 1000)}`) ?? "";
39
+ }
40
+
41
+ /** 回复下面的时间:今天只显示时分,今年显示月日,再早带上年份 */
42
+ export function clockTime(ts) {
43
+ const d = new Date(ts);
44
+ const now = new Date();
45
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
46
+ if (d.toDateString() === now.toDateString()) return hm;
47
+ const md = `${d.getMonth() + 1}月${d.getDate()}日`;
48
+ return d.getFullYear() === now.getFullYear() ? `${md} ${hm}` : `${d.getFullYear()}年${md} ${hm}`;
49
+ }
50
+
51
+ export function relTime(ts) {
52
+ const m = Math.floor((Date.now() - ts) / 60000);
53
+ if (m < 1) return "刚刚";
54
+ if (m < 60) return `${m} 分钟前`;
55
+ const hr = Math.floor(m / 60);
56
+ if (hr < 24) return `${hr} 小时前`;
57
+ const d = Math.floor(hr / 24);
58
+ if (d < 30) return `${d} 天前`;
59
+ return new Date(ts).toLocaleDateString("zh-CN");
60
+ }
package/web/lib/ws.js ADDED
@@ -0,0 +1,232 @@
1
+ import { h } from "./dom.js";
2
+ import { api } from "./api.js";
3
+ import { desktop, S, ui } from "../state.js";
4
+ import { showAuth } from "../views/auth.js";
5
+ import { renderSidebar, scheduleSidebar } from "../views/sidebar.js";
6
+ import { scheduleQuota } from "../views/quota.js";
7
+ import { renderTopbar } from "../views/topbar.js";
8
+ import { applyEvent, openConversation } from "../views/messages.js";
9
+ import { upsertInteraction } from "../views/interaction.js";
10
+ import { setBusy } from "../views/composer.js";
11
+ import { renderScheduled } from "../views/schedule.js";
12
+
13
+ // ---------- WebSocket ----------
14
+
15
+ export const WS = {
16
+ sock: null,
17
+ rid: 0,
18
+ waiters: new Map(),
19
+ backoff: 500,
20
+ connected: false,
21
+ pingTimer: null,
22
+
23
+ stopped: false,
24
+ epoch: null,
25
+ hellos: 0,
26
+
27
+ connect() {
28
+ if (this.stopped) return;
29
+ if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return;
30
+ const proto = location.protocol === "https:" ? "wss" : "ws";
31
+ const sock = new WebSocket(`${proto}://${location.host}/ws`);
32
+ this.sock = sock;
33
+ sock.onopen = () => {
34
+ this.connected = true;
35
+ this.backoff = 500;
36
+ setConnBanner(null);
37
+ clearInterval(this.pingTimer);
38
+ this.pingTimer = setInterval(() => this.raw({ t: "ping" }), 25000);
39
+ };
40
+ sock.onmessage = (e) => onWsMessage(JSON.parse(e.data));
41
+ sock.onclose = async (e) => {
42
+ this.connected = false;
43
+ clearInterval(this.pingTimer);
44
+ for (const w of this.waiters.values()) w.reject(new Error("连接已断开"));
45
+ this.waiters.clear();
46
+ if (this.sock !== sock) return;
47
+ // 登录已失效(改了密码、被踢下线):回登录页,不再重连
48
+ if (e.code === 4401) {
49
+ this.stopped = true;
50
+ showAuth();
51
+ return;
52
+ }
53
+ if (this.stopped) return;
54
+ // 握手被拒(401)在浏览器里只表现为 1006:问一下是不是登录态没了
55
+ try {
56
+ const me = await (await fetch("/api/me", { credentials: "same-origin" })).json();
57
+ if (!me.authed) {
58
+ this.stopped = true;
59
+ showAuth();
60
+ return;
61
+ }
62
+ } catch {
63
+ // 网络不通,照常重连
64
+ }
65
+ if (document.visibilityState === "visible") setConnBanner("连接已断开,正在重连…");
66
+ setTimeout(() => this.connect(), this.backoff);
67
+ this.backoff = Math.min(this.backoff * 2, 10000);
68
+ };
69
+ },
70
+
71
+ raw(msg) {
72
+ if (this.sock?.readyState === 1) this.sock.send(JSON.stringify(msg));
73
+ },
74
+
75
+ request(msg, timeoutMs = 15000) {
76
+ return new Promise((resolve, reject) => {
77
+ if (this.sock?.readyState !== 1) {
78
+ reject(new Error("还没连上服务器,稍等再试"));
79
+ return;
80
+ }
81
+ const rid = ++this.rid;
82
+ this.waiters.set(rid, { resolve, reject });
83
+ this.sock.send(JSON.stringify({ ...msg, rid }));
84
+ setTimeout(() => {
85
+ if (this.waiters.delete(rid)) reject(new Error("服务器没有响应"));
86
+ }, timeoutMs);
87
+ });
88
+ },
89
+
90
+ subscribe(key, since) {
91
+ this.raw({ t: "sub", key, since, epoch: this.epoch });
92
+ },
93
+
94
+ /** 告诉服务器我是否在前台、正看着哪条会话:正看着的会话不推系统通知 */
95
+ presence() {
96
+ const visible = document.visibilityState === "visible" && document.hasFocus();
97
+ this.raw({ t: "presence", visible, key: S.current ?? "" });
98
+ },
99
+ };
100
+
101
+ for (const ev of ["focus", "blur"]) window.addEventListener(ev, () => WS.presence());
102
+
103
+ /** 打开 / 切回页面:清掉桌面图标角标和计数(计数由 sw.js 在收到推送时累加) */
104
+ export function clearAppBadge() {
105
+ if (document.visibilityState !== "visible") return;
106
+ navigator.clearAppBadge?.().catch(() => {});
107
+ if ("caches" in window) caches.delete("aim-badge").catch(() => {});
108
+ }
109
+
110
+ document.addEventListener("visibilitychange", () => {
111
+ WS.presence();
112
+ clearAppBadge();
113
+ // 手机切回前台:连接多半已经被系统掐了,立刻重连而不是等退避
114
+ if (document.visibilityState === "visible" && WS.sock?.readyState !== 1) {
115
+ WS.backoff = 300;
116
+ WS.connect();
117
+ }
118
+ });
119
+
120
+ /** 重连后:以服务器上的会话列表和待处理授权为准,清掉本地已失效的 */
121
+ async function resync() {
122
+ try {
123
+ const data = await api("/api/bootstrap");
124
+ S.convs = new Map(data.conversations.map((c) => [c.key, c]));
125
+ const live = new Set(data.pending.map((i) => i.id));
126
+ for (const ix of S.pending.values()) {
127
+ // 快照生成之后才出现的授权,快照里当然没有,不能当成「已在别处处理」
128
+ if (!ix.settled && !live.has(ix.id) && ix.createdAt <= data.serverTime) markGone(ix);
129
+ }
130
+ for (const ix of data.pending) upsertInteraction(ix);
131
+ renderSidebar();
132
+ } catch {
133
+ // 下次重连再说
134
+ }
135
+ }
136
+
137
+ /** 本地以为还挂着、服务器上已经没了的授权:断线期间在别处处理掉了,或者服务重启过 */
138
+ function markGone(ix) {
139
+ upsertInteraction({ ...ix, settled: { settlement: "gone", by: "已在别处处理或已失效", at: Date.now() } });
140
+ }
141
+
142
+ function onWsMessage(msg) {
143
+ switch (msg.t) {
144
+ case "hello": {
145
+ if (checkServerVersion(msg.version)) return;
146
+ const restarted = WS.epoch && WS.epoch !== msg.epoch;
147
+ WS.epoch = msg.epoch;
148
+ WS.hellos += 1;
149
+ WS.presence();
150
+ if (restarted) {
151
+ // 服务重启过:seq 从头计数了,旧游标全部作废,当前会话整页重拉
152
+ S.seq.clear();
153
+ if (S.current) void openConversation(S.current);
154
+ } else if (S.current) {
155
+ WS.subscribe(S.current, S.seq.get(S.current) ?? 0);
156
+ }
157
+ if (WS.hellos > 1 || restarted) void resync();
158
+ return;
159
+ }
160
+ case "ack": {
161
+ const w = WS.waiters.get(msg.rid);
162
+ if (!w) return;
163
+ WS.waiters.delete(msg.rid);
164
+ if (msg.ok) w.resolve(msg);
165
+ else w.reject(new Error(msg.error ?? "操作失败"));
166
+ return;
167
+ }
168
+ case "ev":
169
+ applyEvent(msg);
170
+ return;
171
+ case "replay":
172
+ if (msg.key !== S.current) return;
173
+ if (msg.reset) {
174
+ void openConversation(msg.key);
175
+ return;
176
+ }
177
+ for (const env of msg.events) applyEvent(env);
178
+ {
179
+ const live = new Set(msg.pending.map((i) => i.id));
180
+ for (const ix of S.pending.values()) {
181
+ if (ix.convKey === msg.key && !ix.settled && !live.has(ix.id)) markGone(ix);
182
+ }
183
+ }
184
+ for (const ix of msg.pending) upsertInteraction(ix);
185
+ setBusy(msg.busy);
186
+ return;
187
+ case "conv":
188
+ if (S.convs.get(msg.info.key)?.busy && !msg.info.busy) scheduleQuota();
189
+ S.convs.set(msg.info.key, msg.info);
190
+ if (msg.info.key === S.current) {
191
+ renderTopbar();
192
+ setBusy(msg.info.busy);
193
+ renderScheduled();
194
+ }
195
+ scheduleSidebar();
196
+ return;
197
+ case "ix":
198
+ upsertInteraction(msg.interaction);
199
+ return;
200
+ case "notice":
201
+ // 正看着这条会话就不打扰;普通网页靠 Web Push,这里只给桌面端
202
+ if (!desktop) return;
203
+ if (document.visibilityState === "visible" && document.hasFocus() && msg.key === S.current) return;
204
+ desktop.notify({ key: msg.key, title: msg.title, body: msg.body, tag: msg.tag });
205
+ return;
206
+ }
207
+ }
208
+
209
+ function setConnBanner(text, onclick) {
210
+ ui.banner?.remove();
211
+ ui.banner = null;
212
+ if (!text || !ui.main) return;
213
+ ui.banner = onclick ? h("button", { class: "conn-banner clickable", onclick }, text) : h("div", { class: "conn-banner" }, text);
214
+ ui.main.append(ui.banner);
215
+ }
216
+
217
+ /** 页面加载时连上的服务端版本。服务端升级重启后版本变了,页面上跑的还是旧前端,得刷新 */
218
+ let loadedServerVersion = null;
219
+
220
+ /** 服务端换了版本:刷新页面换上新前端。返回 true 表示已经在刷新了,后面不用再处理 */
221
+ function checkServerVersion(version) {
222
+ if (!version) return false;
223
+ if (!loadedServerVersion) loadedServerVersion = version;
224
+ if (version === loadedServerVersion) return false;
225
+ // 输入框里有没发出去的字:别直接刷掉,提示一下让用户自己点
226
+ if (ui.input?.value.trim()) {
227
+ setConnBanner(`服务端已升级到 ${version},点这里刷新页面`, () => location.reload());
228
+ return false;
229
+ }
230
+ location.reload();
231
+ return true;
232
+ }
@@ -0,0 +1,210 @@
1
+ import { fill, h, toast } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { copyText } from "../lib/markdown.js";
4
+ import { S } from "../state.js";
5
+ import { defaultEntryName, usableAgents } from "../views/topbar.js";
6
+ import { renderEmpty } from "../views/messages.js";
7
+ import { field, saveConfig } from "./common.js";
8
+ import { modelPicker } from "./commands.js";
9
+
10
+ /**
11
+ * 设置 → Agent:勾选哪些模型出现在输入框的模型列表里。
12
+ * 全部勾上存成空数组 = 「全部显示」,以后 agent 新增的模型也会自动出现;
13
+ * 只勾了一部分就只显示这些。一个都不勾不允许保存。
14
+ */
15
+ function modelChecklist(agent, selected) {
16
+ const box = h("div", { class: "model-checks" }, h("small", {}, "正在读取模型列表…"));
17
+ let boxes = null;
18
+ api(`/api/agents/${agent}/models`)
19
+ .then((data) => {
20
+ const models = data?.models ?? [];
21
+ if (!models.length) return fill(box, h("small", {}, "没读到模型列表"));
22
+ boxes = models.map((m) => ({ id: m.id, el: h("input", { type: "checkbox", checked: !selected.length || selected.includes(m.id) }) }));
23
+ fill(
24
+ box,
25
+ boxes.map(({ el }, i) => {
26
+ const m = models[i];
27
+ return h(
28
+ "label",
29
+ { class: "model-check" },
30
+ el,
31
+ h("span", { class: "t" }, m.label || m.id),
32
+ h("span", { class: "s" }, m.id === "default" && defaultEntryName(m) ? `跟随默认 · 现在是 ${defaultEntryName(m)}` : m.id),
33
+ );
34
+ }),
35
+ h("small", {}, "只影响输入框里的模型选择列表;全部勾上 = 以后新出的模型也自动显示"),
36
+ );
37
+ })
38
+ .catch((err) => fill(box, h("small", {}, err.status === 409 ? err.message : `读取模型列表失败:${err.message ?? err}`)));
39
+ return {
40
+ el: box,
41
+ /** 列表没读到时原样保留;null = 一个都没勾 */
42
+ value: () => {
43
+ if (!boxes) return selected;
44
+ const on = boxes.filter((b) => b.el.checked).map((b) => b.id);
45
+ if (on.length === 0) return null;
46
+ return on.length === boxes.length ? [] : on;
47
+ },
48
+ };
49
+ }
50
+
51
+ /** Agent 运行环境:装没装、登没登录;没好的给出在终端里执行的命令 */
52
+ function agentHealthSection() {
53
+ const box = h("div", { class: "section" });
54
+ const load = async (refresh) => {
55
+ const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "重新检查");
56
+ fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), h("span", { class: "spinner" })));
57
+ let data;
58
+ try {
59
+ data = await api(`/api/agents/health${refresh ? "?refresh=1" : ""}`);
60
+ } catch (err) {
61
+ fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck), h("div", { class: "error-text" }, err.message));
62
+ return;
63
+ }
64
+ // 体检结果顺手同步到新对话的 agent 列表:刚装好 / 登录好的立刻能选,不行的立刻消失
65
+ for (const a of data.agents) {
66
+ const known = S.agents.find((x) => x.id === a.id);
67
+ if (known) known.available = a.installed && a.loggedIn !== false;
68
+ }
69
+ if (!usableAgents().some((a) => a.id === S.draftAgent)) S.draftAgent = usableAgents()[0]?.id ?? null;
70
+ if (!S.current) renderEmpty();
71
+ fill(
72
+ box,
73
+ h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck),
74
+ ...data.agents.map((a) => {
75
+ const ok = a.installed && a.loggedIn === true;
76
+ const state = !a.installed ? "未安装" : a.loggedIn === false ? "未登录" : a.loggedIn === null ? "无法确认" : "正常";
77
+ return h(
78
+ "div",
79
+ { class: "health-row" },
80
+ h(
81
+ "div",
82
+ { class: "health-head" },
83
+ h("span", { class: `health-dot ${ok ? "ok" : "bad"}` }),
84
+ h("b", {}, a.label),
85
+ h("span", { class: "health-state" }, state),
86
+ a.enabled ? null : h("span", { class: "health-off" }, "(未启用)"),
87
+ ),
88
+ a.account || a.version ? h("div", { class: "health-meta" }, [a.account, a.version].filter(Boolean).join(" · ")) : null,
89
+ a.problem ? h("div", { class: "health-meta" }, a.problem) : null,
90
+ a.fix
91
+ ? h(
92
+ "div",
93
+ { class: "health-fix" },
94
+ h("div", {}, `${a.fix.title}:在运行 agent-loft 的机器上打开终端执行${a.fix.commands.length > 1 ? "(任选一条)" : ""}`),
95
+ a.fix.commands.map((cmd) => {
96
+ const copy = h("button", { class: "btn small", onclick: () => copyText(cmd, copy) }, "复制");
97
+ return h("div", { class: "health-cmd" }, h("code", {}, cmd), copy);
98
+ }),
99
+ a.fix.note ? h("small", {}, a.fix.note) : null,
100
+ a.id === "codex" && a.fix.action === "install" ? installCodexButton(() => load(true)) : null,
101
+ )
102
+ : null,
103
+ );
104
+ }),
105
+ );
106
+ };
107
+ void load(false);
108
+ return box;
109
+ }
110
+
111
+ /** 直接在服务端那台机器上装 Codex(Homebrew 或 npm),装完重新检查 */
112
+ function installCodexButton(after) {
113
+ const btn = h(
114
+ "button",
115
+ {
116
+ class: "btn small primary",
117
+ onclick: async () => {
118
+ btn.disabled = true;
119
+ btn.textContent = "正在安装,可能要几分钟…";
120
+ try {
121
+ const r = await api("/api/agents/codex/install", { method: "POST" });
122
+ toast(r.ok ? `已用 ${r.via} 装好 Codex` : `安装失败:${r.output.slice(-200)}`, 6000);
123
+ after();
124
+ } catch (err) {
125
+ toast(`安装失败:${err.message}`, 6000);
126
+ btn.disabled = false;
127
+ btn.textContent = "直接在服务器上安装";
128
+ }
129
+ },
130
+ },
131
+ "直接在服务器上安装",
132
+ );
133
+ return h("div", {}, btn);
134
+ }
135
+
136
+ export function agentsTab(cfg, panel) {
137
+ panel.append(agentHealthSection());
138
+ const a = structuredClone(cfg.agents);
139
+ const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
140
+ const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
141
+ const claudeModels = modelChecklist("claude", a.claude.models ?? []);
142
+ const codexOn = h("input", { type: "checkbox", checked: a.codex.enabled });
143
+ const codexPath = h("input", { value: a.codex.path });
144
+ const codexHome = h("input", { value: a.codex.home, placeholder: "留空用 ~/.codex" });
145
+ const sandbox = h(
146
+ "select",
147
+ {},
148
+ ["read-only", "workspace-write", "danger-full-access"].map((m) => h("option", { value: m, selected: m === a.codex.sandbox }, m)),
149
+ );
150
+ const codexPick = modelPicker("codex", a.codex.defaultModel, a.codex.defaultEffort, "跟随 config.toml");
151
+ const codexModels = modelChecklist("codex", a.codex.models ?? []);
152
+ // 一组勾选框不能放进 <label>(点哪都会切到第一个),所以不用 field()
153
+ const checksField = (list) => h("div", { class: "field" }, h("span", {}, "输入框可选模型"), list.el);
154
+ panel.append(
155
+ h(
156
+ "div",
157
+ { class: "section" },
158
+ h("h4", {}, "Claude Code", h("span", { class: "grow" }), h("label", { class: "check" }, claudeOn, "启用")),
159
+ field("默认模型", claudePick.modelEl),
160
+ field("默认思考程度", claudePick.effortEl),
161
+ checksField(claudeModels),
162
+ ),
163
+ h(
164
+ "div",
165
+ { class: "section" },
166
+ h("h4", {}, "Codex", h("span", { class: "grow" }), h("label", { class: "check" }, codexOn, "启用")),
167
+ field("codex 可执行文件", codexPath, "以系统服务运行时 PATH 很短,建议写绝对路径"),
168
+ field("CODEX_HOME", codexHome),
169
+ field("沙箱", sandbox, "workspace-write:目录内可读写,越界才弹授权。danger-full-access 等于关掉沙箱"),
170
+ field("默认模型", codexPick.modelEl),
171
+ field("默认思考程度", codexPick.effortEl),
172
+ checksField(codexModels),
173
+ ),
174
+ h(
175
+ "div",
176
+ { class: "row" },
177
+ h(
178
+ "button",
179
+ {
180
+ class: "btn primary",
181
+ onclick: () => {
182
+ const claudeList = claudeModels.value();
183
+ const codexList = codexModels.value();
184
+ if (!claudeList || !codexList) return toast(`${!claudeList ? "Claude Code" : "Codex"} 至少勾选一个模型`);
185
+ return saveConfig(cfg, {
186
+ agents: {
187
+ claude: {
188
+ enabled: claudeOn.checked,
189
+ defaultModel: claudePick.model(),
190
+ defaultEffort: claudePick.effort(),
191
+ models: claudeList,
192
+ },
193
+ codex: {
194
+ enabled: codexOn.checked,
195
+ path: codexPath.value.trim() || "codex",
196
+ home: codexHome.value.trim(),
197
+ sandbox: sandbox.value,
198
+ defaultModel: codexPick.model(),
199
+ defaultEffort: codexPick.effort(),
200
+ models: codexList,
201
+ },
202
+ },
203
+ });
204
+ },
205
+ },
206
+ "保存",
207
+ ),
208
+ ),
209
+ );
210
+ }