agent-loft 0.2.14 → 0.2.16
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/package.json +1 -1
- package/src/agents/claude/account.ts +13 -3
- package/src/agents/codex/account.ts +25 -1
- package/src/agents/codex/driver.ts +3 -2
- package/src/agents/health.ts +14 -0
- package/src/channels/web/api.ts +6 -2
- package/src/core/account-key.ts +13 -0
- package/src/core/types.ts +5 -0
- package/src/main.ts +3 -0
- package/web/app.js +68 -18
- package/web/style.css +19 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 注意:SDK 把它标成了 EXPERIMENTAL,方法名以后会改。改了的话这里只会读失败,
|
|
7
7
|
// /status 少几行而已,不影响别的。
|
|
8
|
+
import { accountKey } from "../../core/account-key.js";
|
|
8
9
|
import type { QuotaInfo } from "../../core/types.js";
|
|
9
10
|
import { cachedProbe, withProbe } from "./probe.js";
|
|
10
11
|
|
|
@@ -22,6 +23,8 @@ type LimitRow = {
|
|
|
22
23
|
export type ClaudeUsage = {
|
|
23
24
|
subscriptionType: string | null;
|
|
24
25
|
rows: { label: string; short: string; percent: number; resetsAt: string | null; severity?: string }[];
|
|
26
|
+
/** 账号标识(见 core/account-key.ts);读不到账号信息时没有 */
|
|
27
|
+
account?: string;
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
function labelOf(row: LimitRow): string {
|
|
@@ -70,10 +73,16 @@ function normalize(res: {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
async function fetchUsage(): Promise<ClaudeUsage | null> {
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
// 账号信息顺带在同一个探针进程里问,不多起进程;读不到不影响额度
|
|
77
|
+
const [res, info] = await withProbe((q) =>
|
|
78
|
+
Promise.all([
|
|
79
|
+
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }),
|
|
80
|
+
q.accountInfo().catch(() => null),
|
|
81
|
+
]),
|
|
75
82
|
);
|
|
76
|
-
|
|
83
|
+
const usage = normalize(res as Parameters<typeof normalize>[0]);
|
|
84
|
+
const account = info?.email ? accountKey("claude", info.email, info.organization) : undefined;
|
|
85
|
+
return usage && account ? { ...usage, account } : usage;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/**
|
|
@@ -107,6 +116,7 @@ export function toQuota(usage: ClaudeUsage | null): QuotaInfo | null {
|
|
|
107
116
|
return { label: r.label, short: r.short, percent: r.percent, ...(Number.isNaN(at) ? {} : { resetsAt: at }) };
|
|
108
117
|
}),
|
|
109
118
|
...(usage.rows.some((r) => r.percent >= 100) ? { reached: true } : {}),
|
|
119
|
+
...(usage.account ? { account: usage.account } : {}),
|
|
110
120
|
};
|
|
111
121
|
}
|
|
112
122
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 而agent-loft的 `/status` 是另一条命令(同名而已),本来只报会话和目录。
|
|
5
5
|
// 用的人不会关心这个区别,他只知道「同样敲 /status,这边少了额度」。
|
|
6
6
|
// 与其解释,不如把它显示出来 —— app-server 本来就给了。
|
|
7
|
+
import { accountKey } from "../../core/account-key.js";
|
|
7
8
|
import type { QuotaInfo } from "../../core/types.js";
|
|
8
9
|
import { appServer } from "./app-server.js";
|
|
9
10
|
|
|
@@ -47,6 +48,28 @@ export async function readRateLimits(): Promise<RateLimits | null> {
|
|
|
47
48
|
return cache.value;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
type Account = { type: "chatgpt"; email: string | null; planType?: string | null } | { type: string } | null;
|
|
52
|
+
|
|
53
|
+
let accountCache: { at: number; value: string | undefined } | null = null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 账号标识(见 core/account-key.ts):只有 ChatGPT 登录、拿得到邮箱时才有。
|
|
57
|
+
* app-server 常驻着,问一下很便宜;和额度同样缓存 60 秒,换了账号一分钟内就能反映出来。
|
|
58
|
+
*/
|
|
59
|
+
export async function readAccountKey(): Promise<string | undefined> {
|
|
60
|
+
if (accountCache && Date.now() - accountCache.at < CACHE_MS) return accountCache.value;
|
|
61
|
+
let value: string | undefined;
|
|
62
|
+
try {
|
|
63
|
+
const res = await appServer.call<{ account?: Account }>("account/read", {});
|
|
64
|
+
const acc = res.account;
|
|
65
|
+
if (acc?.type === "chatgpt" && "email" in acc) value = accountKey("codex", acc.email, acc.planType);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error("[账号] 读取失败:", err instanceof Error ? err.message : err);
|
|
68
|
+
}
|
|
69
|
+
accountCache = { at: Date.now(), value };
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/** 300 → 「5h」,10080 → 「7d」 */
|
|
51
74
|
function windowShort(mins: number): string {
|
|
52
75
|
if (mins % (60 * 24) === 0) return `${mins / (60 * 24)}d`;
|
|
@@ -55,7 +78,7 @@ function windowShort(mins: number): string {
|
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
/** 侧栏用的结构化额度 */
|
|
58
|
-
export function toQuota(limits: RateLimits | null): QuotaInfo | null {
|
|
81
|
+
export function toQuota(limits: RateLimits | null, account?: string): QuotaInfo | null {
|
|
59
82
|
if (!limits) return null;
|
|
60
83
|
const windows = [limits.primary, limits.secondary]
|
|
61
84
|
.filter((w): w is UsageWindow => !!w)
|
|
@@ -73,6 +96,7 @@ export function toQuota(limits: RateLimits | null): QuotaInfo | null {
|
|
|
73
96
|
windows,
|
|
74
97
|
...(extra ? { extra } : {}),
|
|
75
98
|
...(limits.rateLimitReachedType ? { reached: true } : {}),
|
|
99
|
+
...(account ? { account } : {}),
|
|
76
100
|
};
|
|
77
101
|
}
|
|
78
102
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 不用 @openai/codex-sdk:它是 `codex exec --json` 的薄封装,没有任何审批回调。
|
|
4
4
|
import { appServer } from "./app-server.js";
|
|
5
|
-
import { describeRateLimits, readRateLimits, toQuota } from "./account.js";
|
|
5
|
+
import { describeRateLimits, readAccountKey, readRateLimits, toQuota } from "./account.js";
|
|
6
6
|
import { diagnose } from "./errors.js";
|
|
7
7
|
import { CodexSession, summarize } from "./session.js";
|
|
8
8
|
import { config, onConfigChange } from "../../core/config.js";
|
|
@@ -209,7 +209,8 @@ export class CodexDriver implements AgentDriver {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
async quota(): Promise<QuotaInfo | null> {
|
|
212
|
-
|
|
212
|
+
const [limits, account] = await Promise.all([readRateLimits(), readAccountKey()]);
|
|
213
|
+
return toQuota(limits, account);
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
diagnose(err: unknown) {
|
package/src/agents/health.ts
CHANGED
|
@@ -134,6 +134,20 @@ async function codexHealth(): Promise<AgentHealth> {
|
|
|
134
134
|
|
|
135
135
|
let cache: { at: number; value: AgentHealth[] } | null = null;
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 不等体检、立刻给出的「这个 agent 能不能用」:用最近一次体检的结果(多旧都算),
|
|
139
|
+
* 结果超过 5 分钟就顺手在后台再查一次。还没查过返回 null(当作能用,不挡人)。
|
|
140
|
+
* 新对话里只列出能用的 agent,没装、没登录的不出现。
|
|
141
|
+
*/
|
|
142
|
+
export function agentAvailability(id: AgentHealth["id"]): { available: boolean; reason?: string } | null {
|
|
143
|
+
if (!cache || Date.now() - cache.at > 5 * 60_000) void agentHealth().catch(() => {});
|
|
144
|
+
const h = cache?.value.find((a) => a.id === id);
|
|
145
|
+
if (!h) return null;
|
|
146
|
+
if (!h.installed) return { available: false, reason: "未安装" };
|
|
147
|
+
if (h.loggedIn === false) return { available: false, reason: "未登录" };
|
|
148
|
+
return { available: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
/** 装了 / 登录了什么之后,下次体检不用缓存 */
|
|
138
152
|
export function invalidateHealth(): void {
|
|
139
153
|
cache = null;
|
package/src/channels/web/api.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { getConversation, updateConversation } from "../../core/store.js";
|
|
|
12
12
|
import { resolveUploadFile, saveBuffer, sniff } from "../../core/uploads.js";
|
|
13
13
|
import type { SavedAttachment } from "../../core/types.js";
|
|
14
14
|
import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
|
|
15
|
-
import { agentHealth, invalidateHealth } from "../../agents/health.js";
|
|
15
|
+
import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
|
|
16
16
|
import { installCodex, upgrade, useInstalledCodex, versionInfo } from "../../core/upgrade.js";
|
|
17
17
|
import { feishuBots } from "../feishu/index.js";
|
|
18
18
|
import { beginRegistration, initRegistration, pollRegistration, qrSvg } from "../feishu/register.js";
|
|
@@ -317,6 +317,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
317
317
|
capabilities: d?.capabilities ?? {},
|
|
318
318
|
// 输入框模型列表里显示哪些,空 = 全部
|
|
319
319
|
models: config().agents[id].models.filter((m) => typeof m === "string"),
|
|
320
|
+
// 没装 / 没登录:新对话里不出现(null = 还没查过,当作能用)
|
|
321
|
+
...(agentAvailability(id) ?? {}),
|
|
320
322
|
};
|
|
321
323
|
}),
|
|
322
324
|
bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
|
|
@@ -563,8 +565,10 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
563
565
|
return;
|
|
564
566
|
}
|
|
565
567
|
if (p === "/api/quota" && method === "GET") {
|
|
568
|
+
// ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
|
|
569
|
+
const only = url.searchParams.get("agent")?.split(",").filter(Boolean);
|
|
566
570
|
const results = await Promise.all(
|
|
567
|
-
hub.enabledAgents().map(async (d) => {
|
|
571
|
+
hub.enabledAgents().filter((d) => !only || only.includes(d.id)).map(async (d) => {
|
|
568
572
|
if (!d.quota) return null;
|
|
569
573
|
try {
|
|
570
574
|
const quota = await Promise.race([
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// 额度对应的账号标识:桌面端连着多台服务器时,靠它认出「这几台登的是同一个账号」,
|
|
2
|
+
// 同一个账号只问一台,结果分给其他几台(额度是跟着账号走的,问哪台都一样)。
|
|
3
|
+
//
|
|
4
|
+
// 只给哈希、不给原文:接口返回里不出现邮箱。不同服务器上同一个账号算出来的值相同,
|
|
5
|
+
// 这正是要的效果,所以不加盐。
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/** 没有能认出账号的信息(API key、第三方云之类)时返回 undefined,桌面端就照旧逐台去问 */
|
|
9
|
+
export function accountKey(agent: string, ...parts: (string | null | undefined)[]): string | undefined {
|
|
10
|
+
if (!parts[0]) return undefined;
|
|
11
|
+
const text = [agent, ...parts.map((p) => (p ?? "").trim().toLowerCase())].join("\n");
|
|
12
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
13
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -116,6 +116,11 @@ export type QuotaInfo = {
|
|
|
116
116
|
extra?: string;
|
|
117
117
|
/** 已撞上限 */
|
|
118
118
|
reached?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* 账号标识(邮箱等信息的哈希,见 core/account-key.ts)。多台服务器登的同一个账号时相同,
|
|
121
|
+
* 桌面端据此只问其中一台。认不出账号时不给。
|
|
122
|
+
*/
|
|
123
|
+
account?: string;
|
|
119
124
|
};
|
|
120
125
|
|
|
121
126
|
// ---------- agent ----------
|
package/src/main.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { flushStore, getConversation, isExpired } from "./core/store.js";
|
|
|
5
5
|
import { sweepUploads } from "./core/uploads.js";
|
|
6
6
|
import { ClaudeDriver } from "./agents/claude/driver.js";
|
|
7
7
|
import { CodexDriver } from "./agents/codex/driver.js";
|
|
8
|
+
import { agentHealth } from "./agents/health.js";
|
|
8
9
|
import { startFeishu, stopFeishu } from "./channels/feishu/index.js";
|
|
9
10
|
import { startWeb, stopWeb } from "./channels/web/server.js";
|
|
10
11
|
import { home } from "./home.js";
|
|
@@ -40,6 +41,8 @@ async function main(): Promise<void> {
|
|
|
40
41
|
|
|
41
42
|
startFeishu();
|
|
42
43
|
if (config().web.enabled) await startWeb();
|
|
44
|
+
// 先查一遍 agent 装没装、登没登录,网页打开时新对话里就只列出能用的
|
|
45
|
+
void agentHealth().catch(() => {});
|
|
43
46
|
|
|
44
47
|
console.log(`[启动] workspace=${config().workspaceRoot}`);
|
|
45
48
|
if (config().workspaceRoot === process.cwd()) {
|
package/web/app.js
CHANGED
|
@@ -265,6 +265,10 @@ const ui = {};
|
|
|
265
265
|
* 桌面端收不到 Web Push,系统通知、Dock 角标、菜单栏的待处理数都交给它。
|
|
266
266
|
*/
|
|
267
267
|
const desktop = window.agentLoftDesktop ?? null;
|
|
268
|
+
/** 新版桌面端:额度由它统一查好推过来,页面自己不查(老版本桌面端没有 onQuota,照旧自己查) */
|
|
269
|
+
const desktopQuota = typeof desktop?.onQuota === "function";
|
|
270
|
+
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
|
|
271
|
+
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
268
272
|
|
|
269
273
|
// ---------- WebSocket ----------
|
|
270
274
|
|
|
@@ -528,12 +532,13 @@ async function boot() {
|
|
|
528
532
|
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
529
533
|
S.convs = new Map(data.conversations.map((c) => [c.key, c]));
|
|
530
534
|
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
531
|
-
const enabled =
|
|
535
|
+
const enabled = usableAgents().map((a) => a.id);
|
|
532
536
|
if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
|
|
533
537
|
renderLayout();
|
|
534
538
|
reportDesktop();
|
|
535
539
|
WS.connect();
|
|
536
|
-
|
|
540
|
+
if (desktopQuota) desktop.onQuota(applyDesktopQuota);
|
|
541
|
+
else void loadQuota();
|
|
537
542
|
const fromHash = decodeURIComponent(location.hash.slice(1));
|
|
538
543
|
// 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
|
|
539
544
|
const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
|
|
@@ -664,12 +669,19 @@ function stopMarquee() {
|
|
|
664
669
|
// - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
|
|
665
670
|
// - 切回前台:距上次超过一个刷新间隔就拉;
|
|
666
671
|
// - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
|
|
672
|
+
// 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
|
|
673
|
+
// 查好推过来;点一下额度区域是请桌面端去刷新。
|
|
667
674
|
|
|
668
675
|
const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
|
|
669
676
|
const QUOTA_SERVER_CACHE = 60_000;
|
|
670
677
|
const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
|
|
671
678
|
|
|
672
679
|
async function loadQuota() {
|
|
680
|
+
if (desktopQuota) {
|
|
681
|
+
ui.quota?.classList.add("loading");
|
|
682
|
+
desktop.refreshQuota();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
673
685
|
if (quotaState.loading) return;
|
|
674
686
|
quotaState.loading = true;
|
|
675
687
|
ui.quota?.classList.add("loading");
|
|
@@ -688,10 +700,20 @@ async function loadQuota() {
|
|
|
688
700
|
}
|
|
689
701
|
}
|
|
690
702
|
|
|
703
|
+
/** 桌面端推来的额度。查失败(error)时保留旧数据 */
|
|
704
|
+
function applyDesktopQuota(payload) {
|
|
705
|
+
if (Array.isArray(payload?.quotas) && !payload.error) {
|
|
706
|
+
quotaState.data = payload.quotas;
|
|
707
|
+
quotaState.at = payload.at || Date.now();
|
|
708
|
+
}
|
|
709
|
+
ui.quota?.classList.remove("loading");
|
|
710
|
+
renderQuota();
|
|
711
|
+
}
|
|
712
|
+
|
|
691
713
|
/** 空闲定时:每次拉完重新计时,所以刚因为跑完一轮拉过,就不会紧接着又拉 */
|
|
692
714
|
function scheduleIdleQuota() {
|
|
693
715
|
clearTimeout(quotaState.idle);
|
|
694
|
-
if (!quotaInterval()) return;
|
|
716
|
+
if (desktopQuota || !quotaInterval()) return;
|
|
695
717
|
quotaState.idle = setTimeout(() => {
|
|
696
718
|
// 页面不可见就先不拉,等切回来再说
|
|
697
719
|
if (document.visibilityState === "visible") void loadQuota();
|
|
@@ -700,13 +722,15 @@ function scheduleIdleQuota() {
|
|
|
700
722
|
|
|
701
723
|
/** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
|
|
702
724
|
function scheduleQuota() {
|
|
725
|
+
// 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
|
|
726
|
+
if (desktopQuota) return;
|
|
703
727
|
const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
|
|
704
728
|
clearTimeout(quotaState.timer);
|
|
705
729
|
quotaState.timer = setTimeout(() => void loadQuota(), wait);
|
|
706
730
|
}
|
|
707
731
|
|
|
708
732
|
document.addEventListener("visibilitychange", () => {
|
|
709
|
-
if (document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
|
|
733
|
+
if (desktopQuota || document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
|
|
710
734
|
if (Date.now() - quotaState.at >= quotaInterval()) void loadQuota();
|
|
711
735
|
});
|
|
712
736
|
|
|
@@ -1113,8 +1137,18 @@ function currentCwd() {
|
|
|
1113
1137
|
return c ? c.cwd : (S.draftCwd ?? S.workspaceRoot ?? "");
|
|
1114
1138
|
}
|
|
1115
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* 新对话能选的 agent:启用了、并且服务端查过是装好登录了的(available 为 false 的不出现)。
|
|
1142
|
+
* 一个能用的都没有时退回到全部启用的,至少能看到报错,而不是什么都选不了。
|
|
1143
|
+
*/
|
|
1144
|
+
function usableAgents() {
|
|
1145
|
+
const enabled = S.agents.filter((a) => a.enabled);
|
|
1146
|
+
const usable = enabled.filter((a) => a.available !== false);
|
|
1147
|
+
return usable.length ? usable : enabled;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1116
1150
|
function pickAgentMenu(anchor) {
|
|
1117
|
-
const allowed =
|
|
1151
|
+
const allowed = usableAgents().map((a) => a.id);
|
|
1118
1152
|
if (allowed.length < 2) return;
|
|
1119
1153
|
if (ui.menu?.dataset.for === "agent") return closeMenu();
|
|
1120
1154
|
const pop = showPopover(anchor, { align: "left" });
|
|
@@ -1205,7 +1239,7 @@ function renderContext() {
|
|
|
1205
1239
|
if (!ui.ctxBar) return;
|
|
1206
1240
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
1207
1241
|
const cwd = currentCwd();
|
|
1208
|
-
const multi =
|
|
1242
|
+
const multi = usableAgents().length > 1;
|
|
1209
1243
|
// 会话里不能换 agent(两个 agent 的会话互不相通),只显示;新对话里可以选
|
|
1210
1244
|
const agentId = c ? c.agent : S.draftAgent;
|
|
1211
1245
|
const agentEl = c || !multi
|
|
@@ -1437,7 +1471,7 @@ function newDraft() {
|
|
|
1437
1471
|
}
|
|
1438
1472
|
|
|
1439
1473
|
function renderEmpty() {
|
|
1440
|
-
const enabled =
|
|
1474
|
+
const enabled = usableAgents();
|
|
1441
1475
|
const cwd = S.draftCwd ?? S.workspaceRoot ?? "";
|
|
1442
1476
|
fill(ui.msgInner,
|
|
1443
1477
|
h(
|
|
@@ -2382,7 +2416,7 @@ function oneLine(text, max = 80) {
|
|
|
2382
2416
|
|
|
2383
2417
|
function currentAgentId() {
|
|
2384
2418
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
2385
|
-
return c?.agent ?? S.draftAgent ??
|
|
2419
|
+
return c?.agent ?? S.draftAgent ?? usableAgents()[0]?.id ?? "claude";
|
|
2386
2420
|
}
|
|
2387
2421
|
|
|
2388
2422
|
async function loadCommands(agent) {
|
|
@@ -2593,14 +2627,14 @@ async function switchAgent(id) {
|
|
|
2593
2627
|
await doAction("agent", id);
|
|
2594
2628
|
}
|
|
2595
2629
|
|
|
2596
|
-
function modal({ title, body, foot, wide }) {
|
|
2630
|
+
function modal({ title, body, foot, wide, tall }) {
|
|
2597
2631
|
const close = () => overlay.remove();
|
|
2598
2632
|
const overlay = h(
|
|
2599
2633
|
"div",
|
|
2600
2634
|
{ class: "overlay", onclick: (e) => e.target === overlay && close() },
|
|
2601
2635
|
h(
|
|
2602
2636
|
"div",
|
|
2603
|
-
{ class: `modal${wide ? " wide" : ""}` },
|
|
2637
|
+
{ class: `modal${wide ? " wide" : ""}${tall ? " tall" : ""}` },
|
|
2604
2638
|
h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: close }, icon("x"))),
|
|
2605
2639
|
body,
|
|
2606
2640
|
foot ?? null,
|
|
@@ -3045,7 +3079,8 @@ async function deleteConv(key = S.current) {
|
|
|
3045
3079
|
async function openSettings() {
|
|
3046
3080
|
const nav = h("div", { class: "settings-nav" });
|
|
3047
3081
|
const panel = h("div", { class: "settings-body" });
|
|
3048
|
-
|
|
3082
|
+
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
3083
|
+
const close = modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
3049
3084
|
let cfg;
|
|
3050
3085
|
try {
|
|
3051
3086
|
cfg = await api("/api/config");
|
|
@@ -3232,12 +3267,12 @@ function field(label, input, hint) {
|
|
|
3232
3267
|
function serverVersionSection() {
|
|
3233
3268
|
const box = h("div", { class: "section" });
|
|
3234
3269
|
const load = async (refresh) => {
|
|
3235
|
-
box
|
|
3270
|
+
fill(box, h("h4", {}, "服务端版本", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
3236
3271
|
let v;
|
|
3237
3272
|
try {
|
|
3238
3273
|
v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
|
|
3239
3274
|
} catch (err) {
|
|
3240
|
-
box
|
|
3275
|
+
fill(box, h("h4", {}, "服务端版本"), h("div", { class: "error-text" }, err.message));
|
|
3241
3276
|
return;
|
|
3242
3277
|
}
|
|
3243
3278
|
const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "检查更新");
|
|
@@ -3269,7 +3304,8 @@ function serverVersionSection() {
|
|
|
3269
3304
|
"升级",
|
|
3270
3305
|
)
|
|
3271
3306
|
: null;
|
|
3272
|
-
|
|
3307
|
+
fill(
|
|
3308
|
+
box,
|
|
3273
3309
|
h("h4", {}, "服务端版本", h("span", { class: "grow" }), upgradeBtn, recheck),
|
|
3274
3310
|
h("div", { class: "health-meta" }, `当前 ${v.version} · ${status}`),
|
|
3275
3311
|
v.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "health-meta" }, v.reason) : null,
|
|
@@ -3294,7 +3330,13 @@ function generalTab(cfg, panel) {
|
|
|
3294
3330
|
field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
|
|
3295
3331
|
field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
|
|
3296
3332
|
field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
|
|
3297
|
-
field(
|
|
3333
|
+
field(
|
|
3334
|
+
"额度自动刷新间隔(分钟)",
|
|
3335
|
+
quotaMin,
|
|
3336
|
+
desktopQuota
|
|
3337
|
+
? "在桌面端里额度由桌面端统一刷新,以桌面端设置里的间隔为准;这里只对浏览器打开的页面生效"
|
|
3338
|
+
: "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)",
|
|
3339
|
+
),
|
|
3298
3340
|
h(
|
|
3299
3341
|
"div",
|
|
3300
3342
|
{ class: "row" },
|
|
@@ -3767,15 +3809,23 @@ function agentHealthSection() {
|
|
|
3767
3809
|
const box = h("div", { class: "section" });
|
|
3768
3810
|
const load = async (refresh) => {
|
|
3769
3811
|
const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "重新检查");
|
|
3770
|
-
box
|
|
3812
|
+
fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
3771
3813
|
let data;
|
|
3772
3814
|
try {
|
|
3773
3815
|
data = await api(`/api/agents/health${refresh ? "?refresh=1" : ""}`);
|
|
3774
3816
|
} catch (err) {
|
|
3775
|
-
box
|
|
3817
|
+
fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck), h("div", { class: "error-text" }, err.message));
|
|
3776
3818
|
return;
|
|
3777
3819
|
}
|
|
3778
|
-
|
|
3820
|
+
// 体检结果顺手同步到新对话的 agent 列表:刚装好 / 登录好的立刻能选,不行的立刻消失
|
|
3821
|
+
for (const a of data.agents) {
|
|
3822
|
+
const known = S.agents.find((x) => x.id === a.id);
|
|
3823
|
+
if (known) known.available = a.installed && a.loggedIn !== false;
|
|
3824
|
+
}
|
|
3825
|
+
if (!usableAgents().some((a) => a.id === S.draftAgent)) S.draftAgent = usableAgents()[0]?.id ?? null;
|
|
3826
|
+
if (!S.current) renderEmpty();
|
|
3827
|
+
fill(
|
|
3828
|
+
box,
|
|
3779
3829
|
h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck),
|
|
3780
3830
|
...data.agents.map((a) => {
|
|
3781
3831
|
const ok = a.installed && a.loggedIn === true;
|
package/web/style.css
CHANGED
|
@@ -2300,6 +2300,11 @@ button.ctx-item:hover {
|
|
|
2300
2300
|
max-width: 860px;
|
|
2301
2301
|
}
|
|
2302
2302
|
|
|
2303
|
+
/* 设置弹窗:固定高度 */
|
|
2304
|
+
.modal.tall {
|
|
2305
|
+
height: 86vh;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2303
2308
|
.modal-head {
|
|
2304
2309
|
display: flex;
|
|
2305
2310
|
align-items: center;
|
|
@@ -2747,6 +2752,10 @@ button.list-item:hover {
|
|
|
2747
2752
|
align-items: center;
|
|
2748
2753
|
}
|
|
2749
2754
|
|
|
2755
|
+
.modal.tall {
|
|
2756
|
+
height: 92vh;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2750
2759
|
.modal,
|
|
2751
2760
|
.modal.wide {
|
|
2752
2761
|
max-width: none;
|
|
@@ -3214,3 +3223,13 @@ button.list-item:hover {
|
|
|
3214
3223
|
background: var(--code-bg);
|
|
3215
3224
|
font-size: 12px;
|
|
3216
3225
|
}
|
|
3226
|
+
|
|
3227
|
+
/* 桌面端(Agent Loft for macOS)里:顶栏和侧栏顶部按住能拖动窗口,里面的按钮照常能点 */
|
|
3228
|
+
.in-desktop .topbar,
|
|
3229
|
+
.in-desktop .side-top {
|
|
3230
|
+
-webkit-app-region: drag;
|
|
3231
|
+
}
|
|
3232
|
+
.in-desktop .topbar :is(button, a, input, select, textarea, label, .conv-name),
|
|
3233
|
+
.in-desktop .side-top :is(button, a, input, select, textarea, label) {
|
|
3234
|
+
-webkit-app-region: no-drag;
|
|
3235
|
+
}
|