agent-loft 0.2.15 → 0.2.17
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/channels/web/api.ts +45 -5
- package/src/channels/web/listen.ts +120 -0
- package/src/channels/web/server.ts +15 -5
- package/src/core/account-key.ts +13 -0
- package/src/core/types.ts +5 -0
- package/web/app.js +115 -4
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/channels/web/api.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js"
|
|
|
15
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
|
+
import { fromThisMachine, isLoopbackHost, lanAddresses, listening, rebind } from "./listen.js";
|
|
18
19
|
import { beginRegistration, initRegistration, pollRegistration, qrSvg } from "../feishu/register.js";
|
|
19
20
|
import { FeishuClient } from "../feishu/client.js";
|
|
20
21
|
import {
|
|
@@ -176,8 +177,11 @@ function applyConfig(input: Partial<AppConfig>): void {
|
|
|
176
177
|
}
|
|
177
178
|
if (input.web) {
|
|
178
179
|
// 密码只能走专门的接口改
|
|
179
|
-
|
|
180
|
+
// 监听地址、端口已经在 applyListen 里校验并换好了,这里只收形状对的
|
|
181
|
+
const { passwordHash: _ignored, host, port, ...web } = input.web as AppConfig["web"];
|
|
180
182
|
next.web = { ...next.web, ...web, passwordHash: current.web.passwordHash };
|
|
183
|
+
if (typeof host === "string" && host.trim()) next.web.host = host.trim();
|
|
184
|
+
if (Number.isInteger(Number(port)) && Number(port) >= 1 && Number(port) <= 65535) next.web.port = Number(port);
|
|
181
185
|
const q = Number(next.web.quotaRefreshMin);
|
|
182
186
|
next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
|
|
183
187
|
}
|
|
@@ -217,6 +221,38 @@ function applyConfig(input: Partial<AppConfig>): void {
|
|
|
217
221
|
saveConfig(next);
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
// ---------- 监听地址(局域网访问、端口) ----------
|
|
225
|
+
|
|
226
|
+
/** 设置页「网络访问」要显示的:这台机器的局域网地址、当前请求是不是从本机来的 */
|
|
227
|
+
function networkInfo(req: IncomingMessage) {
|
|
228
|
+
return { lan: lanAddresses(), local: fromThisMachine(req.socket.remoteAddress), listening: listening() };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 改了监听地址 / 端口:先真的换过去,成功了配置才保存(applyConfig 里只是照抄)。
|
|
233
|
+
* 端口被占用之类的失败直接报给页面,旧地址照常工作。
|
|
234
|
+
*/
|
|
235
|
+
async function applyListen(req: IncomingMessage, web: Partial<AppConfig["web"]> | undefined): Promise<void> {
|
|
236
|
+
if (!web || (web.host === undefined && web.port === undefined)) return;
|
|
237
|
+
const current = config().web;
|
|
238
|
+
const host = web.host === undefined ? current.host : String(web.host).trim();
|
|
239
|
+
const port = web.port === undefined ? current.port : Number(web.port);
|
|
240
|
+
if (!host) throw new HttpError(400, "监听地址不能为空");
|
|
241
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new HttpError(400, "端口要是 1 到 65535 之间的整数");
|
|
242
|
+
if (host === current.host && port === current.port) return;
|
|
243
|
+
// 从局域网里的另一台设备上关掉局域网访问:这个页面马上就连不上了,而且没法在这台设备上改回来
|
|
244
|
+
if (isLoopbackHost(host) && !isLoopbackHost(current.host) && !fromThisMachine(req.socket.remoteAddress)) {
|
|
245
|
+
throw new HttpError(400, "你现在是从局域网里的另一台设备访问的,关掉局域网访问后这个页面会立刻断开、也没法再改回来。请在运行 agent-loft 的这台机器上操作。");
|
|
246
|
+
}
|
|
247
|
+
if (config().web.enabled) {
|
|
248
|
+
try {
|
|
249
|
+
await rebind(host, port);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
220
256
|
// ---------- 鉴权 ----------
|
|
221
257
|
|
|
222
258
|
function setCookie(req: IncomingMessage, res: ServerResponse, token: string, maxAgeSec: number): void {
|
|
@@ -534,12 +570,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
534
570
|
|
|
535
571
|
// ----- 设置 -----
|
|
536
572
|
if (p === "/api/config" && method === "GET") {
|
|
537
|
-
sendJson(res, 200, publicConfig());
|
|
573
|
+
sendJson(res, 200, { ...(publicConfig() as object), network: networkInfo(req) });
|
|
538
574
|
return;
|
|
539
575
|
}
|
|
540
576
|
if (p === "/api/config" && method === "PUT") {
|
|
541
|
-
|
|
542
|
-
|
|
577
|
+
const input = await readJsonBody<Partial<AppConfig>>(req);
|
|
578
|
+
await applyListen(req, input.web);
|
|
579
|
+
applyConfig(input);
|
|
580
|
+
sendJson(res, 200, { ...(publicConfig() as object), network: networkInfo(req) });
|
|
543
581
|
return;
|
|
544
582
|
}
|
|
545
583
|
// 侧栏的账号额度:各 agent 自己缓存(60 秒),这里并发问一遍,单个失败 / 超时不影响别的
|
|
@@ -565,8 +603,10 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
565
603
|
return;
|
|
566
604
|
}
|
|
567
605
|
if (p === "/api/quota" && method === "GET") {
|
|
606
|
+
// ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
|
|
607
|
+
const only = url.searchParams.get("agent")?.split(",").filter(Boolean);
|
|
568
608
|
const results = await Promise.all(
|
|
569
|
-
hub.enabledAgents().map(async (d) => {
|
|
609
|
+
hub.enabledAgents().filter((d) => !only || only.includes(d.id)).map(async (d) => {
|
|
570
610
|
if (!d.quota) return null;
|
|
571
611
|
try {
|
|
572
612
|
const quota = await Promise.race([
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Web 服务监听在哪个地址、哪个端口,以及不重启进程就换地址。
|
|
2
|
+
//
|
|
3
|
+
// http.Server 自己不 listen,由一个 net.Server 收连接再交给它(emit "connection")。
|
|
4
|
+
// 这样换地址时可以先在新地址上监听成功、再关掉旧的监听:
|
|
5
|
+
// - 新端口被占用 / 没权限:直接报错,旧的照常工作,配置也不改;
|
|
6
|
+
// - 已经连着的页面、WebSocket 属于 http.Server,关掉旧监听不影响它们(只是不再接新连接)。
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import type http from "node:http";
|
|
10
|
+
|
|
11
|
+
let target: http.Server | null = null;
|
|
12
|
+
let listener: net.Server | null = null;
|
|
13
|
+
let bound: { host: string; port: number } | null = null;
|
|
14
|
+
|
|
15
|
+
/** 只有本机能连的地址 */
|
|
16
|
+
export function isLoopbackHost(host: string): boolean {
|
|
17
|
+
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
18
|
+
return h === "localhost" || h === "::1" || /^127\./.test(h);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 这条连接是不是从本机来的(直连本机,或者经由本机上的反代 / 隧道) */
|
|
22
|
+
export function fromThisMachine(remote: string | undefined): boolean {
|
|
23
|
+
if (!remote) return false;
|
|
24
|
+
return isLoopbackHost(remote.replace(/^::ffff:/, ""));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function open(host: string, port: number): Promise<net.Server> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const srv = net.createServer((socket) => target?.emit("connection", socket));
|
|
30
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
31
|
+
srv.close();
|
|
32
|
+
if (err.code === "EADDRINUSE") reject(new Error(`端口 ${port} 已经被别的程序占用了`));
|
|
33
|
+
else if (err.code === "EACCES") reject(new Error(`没有权限监听端口 ${port}(1024 以下的端口一般需要管理员权限)`));
|
|
34
|
+
else if (err.code === "EADDRNOTAVAIL") reject(new Error(`这台机器上没有地址 ${host}`));
|
|
35
|
+
else reject(err);
|
|
36
|
+
};
|
|
37
|
+
srv.once("error", onError);
|
|
38
|
+
srv.listen(port, host, () => {
|
|
39
|
+
srv.off("error", onError);
|
|
40
|
+
srv.on("error", (err) => console.error("[web] 监听出错:", err));
|
|
41
|
+
resolve(srv);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 关掉监听。不等 close 的回调:它要等这个监听接过的所有连接都断开才回调,
|
|
48
|
+
* 而连接已经交给 http.Server 了(包括正在请求改地址的这一条),等它会卡死。
|
|
49
|
+
*/
|
|
50
|
+
function shut(srv: net.Server): void {
|
|
51
|
+
srv.close();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 刚关掉的端口要等一下才真正放出来:被占用就稍等重试几次 */
|
|
55
|
+
async function openRetry(host: string, port: number): Promise<net.Server> {
|
|
56
|
+
for (let i = 0; ; i++) {
|
|
57
|
+
try {
|
|
58
|
+
return await open(host, port);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (i >= 20 || !String((err as Error).message).includes("占用")) throw err;
|
|
61
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 启动时第一次监听 */
|
|
67
|
+
export async function listen(server: http.Server, host: string, port: number): Promise<void> {
|
|
68
|
+
target = server;
|
|
69
|
+
listener = await open(host, port);
|
|
70
|
+
bound = { host, port };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 换到新的地址 / 端口:新的监听成功了才关旧的;失败抛出能直接给人看的错误 */
|
|
74
|
+
export async function rebind(host: string, port: number): Promise<void> {
|
|
75
|
+
if (!target) throw new Error("Web 服务没有在运行");
|
|
76
|
+
if (bound && bound.host === host && bound.port === port) return;
|
|
77
|
+
const old = listener;
|
|
78
|
+
// 同一个端口只换地址(比如 0.0.0.0 → 127.0.0.1):两个监听会冲突,只能先关旧的;
|
|
79
|
+
// 新的失败了再把旧的开回来
|
|
80
|
+
if (old && bound?.port === port) {
|
|
81
|
+
const prev = bound;
|
|
82
|
+
shut(old);
|
|
83
|
+
try {
|
|
84
|
+
listener = await openRetry(host, port);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
listener = await openRetry(prev.host, prev.port);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
listener = await open(host, port);
|
|
91
|
+
if (old) shut(old);
|
|
92
|
+
}
|
|
93
|
+
bound = { host, port };
|
|
94
|
+
console.log(`[web] 已改为监听 http://${host}:${port}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function listening(): { host: string; port: number } | null {
|
|
98
|
+
return bound;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function closeListener(): void {
|
|
102
|
+
const l = listener;
|
|
103
|
+
listener = null;
|
|
104
|
+
bound = null;
|
|
105
|
+
target = null;
|
|
106
|
+
if (l) shut(l);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 这台机器在局域网里的 IPv4 地址(给设置页显示「其他设备用这个地址访问」) */
|
|
110
|
+
export function lanAddresses(): string[] {
|
|
111
|
+
const out: string[] = [];
|
|
112
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
113
|
+
for (const a of list ?? []) {
|
|
114
|
+
// 169.254:没拿到地址时的自分配地址;198.18/15:Clash、Surge 等代理软件虚拟网卡用的,都不是真的局域网地址
|
|
115
|
+
if (a.family !== "IPv4" || a.internal || /^169\.254\.|^198\.1[89]\./.test(a.address)) continue;
|
|
116
|
+
out.push(a.address);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...new Set(out)];
|
|
120
|
+
}
|
|
@@ -8,7 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { WebSocketServer, type WebSocket } from "ws";
|
|
11
|
-
import { config } from "../../core/config.js";
|
|
11
|
+
import { config, onConfigChange } from "../../core/config.js";
|
|
12
12
|
import { parseCommand } from "../../core/commands.js";
|
|
13
13
|
import { hub, type EventEnvelope } from "../../core/hub.js";
|
|
14
14
|
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
@@ -20,6 +20,7 @@ import { cookies } from "./http.js";
|
|
|
20
20
|
import { COOKIE } from "./api.js";
|
|
21
21
|
import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
22
22
|
import { hasSubscriptions, pushToAll } from "./push.js";
|
|
23
|
+
import { closeListener, listen, listening, rebind } from "./listen.js";
|
|
23
24
|
|
|
24
25
|
const require = createRequire(import.meta.url);
|
|
25
26
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../web");
|
|
@@ -336,6 +337,7 @@ function handleClientMessage(client: Client, raw: string): void {
|
|
|
336
337
|
let server: http.Server | null = null;
|
|
337
338
|
let wss: WebSocketServer | null = null;
|
|
338
339
|
let heartbeat: NodeJS.Timeout | null = null;
|
|
340
|
+
let stopWatchingListen: (() => void) | null = null;
|
|
339
341
|
|
|
340
342
|
export async function startWeb(): Promise<void> {
|
|
341
343
|
const { host, port } = config().web;
|
|
@@ -426,11 +428,14 @@ export async function startWeb(): Promise<void> {
|
|
|
426
428
|
interactions.on("updated", broadcastInteraction);
|
|
427
429
|
interactions.on("settled", broadcastInteraction);
|
|
428
430
|
|
|
429
|
-
await
|
|
430
|
-
server!.once("error", reject);
|
|
431
|
-
server!.listen(port, host, () => resolve());
|
|
432
|
-
});
|
|
431
|
+
await listen(server, host, port);
|
|
433
432
|
console.log(`[web] 已监听 http://${host}:${port}`);
|
|
433
|
+
// 配置文件被手改了地址 / 端口:跟着换(设置页保存时已经先换好了,这里会直接跳过)
|
|
434
|
+
stopWatchingListen = onConfigChange((next) => {
|
|
435
|
+
const now = listening();
|
|
436
|
+
if (!now || (now.host === next.web.host && now.port === next.web.port)) return;
|
|
437
|
+
rebind(next.web.host, next.web.port).catch((err) => console.error("[web] 换监听地址失败:", err instanceof Error ? err.message : err));
|
|
438
|
+
});
|
|
434
439
|
const token = ensureSetupToken();
|
|
435
440
|
if (token) {
|
|
436
441
|
console.log("=".repeat(56));
|
|
@@ -455,6 +460,11 @@ export async function stopWeb(): Promise<void> {
|
|
|
455
460
|
for (const client of clients) client.ws.close(1001, "服务重启");
|
|
456
461
|
clients.clear();
|
|
457
462
|
wss?.close();
|
|
463
|
+
stopWatchingListen?.();
|
|
464
|
+
stopWatchingListen = null;
|
|
465
|
+
closeListener();
|
|
466
|
+
// http.Server 自己没有 listen(连接是 listen.ts 交过来的),close 只为断开还连着的
|
|
458
467
|
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
|
|
468
|
+
server?.closeAllConnections();
|
|
459
469
|
server = null;
|
|
460
470
|
}
|
|
@@ -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/web/app.js
CHANGED
|
@@ -265,6 +265,8 @@ 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";
|
|
268
270
|
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
|
|
269
271
|
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
270
272
|
|
|
@@ -535,7 +537,8 @@ async function boot() {
|
|
|
535
537
|
renderLayout();
|
|
536
538
|
reportDesktop();
|
|
537
539
|
WS.connect();
|
|
538
|
-
|
|
540
|
+
if (desktopQuota) desktop.onQuota(applyDesktopQuota);
|
|
541
|
+
else void loadQuota();
|
|
539
542
|
const fromHash = decodeURIComponent(location.hash.slice(1));
|
|
540
543
|
// 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
|
|
541
544
|
const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
|
|
@@ -666,12 +669,19 @@ function stopMarquee() {
|
|
|
666
669
|
// - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
|
|
667
670
|
// - 切回前台:距上次超过一个刷新间隔就拉;
|
|
668
671
|
// - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
|
|
672
|
+
// 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
|
|
673
|
+
// 查好推过来;点一下额度区域是请桌面端去刷新。
|
|
669
674
|
|
|
670
675
|
const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
|
|
671
676
|
const QUOTA_SERVER_CACHE = 60_000;
|
|
672
677
|
const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
|
|
673
678
|
|
|
674
679
|
async function loadQuota() {
|
|
680
|
+
if (desktopQuota) {
|
|
681
|
+
ui.quota?.classList.add("loading");
|
|
682
|
+
desktop.refreshQuota();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
675
685
|
if (quotaState.loading) return;
|
|
676
686
|
quotaState.loading = true;
|
|
677
687
|
ui.quota?.classList.add("loading");
|
|
@@ -690,10 +700,20 @@ async function loadQuota() {
|
|
|
690
700
|
}
|
|
691
701
|
}
|
|
692
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
|
+
|
|
693
713
|
/** 空闲定时:每次拉完重新计时,所以刚因为跑完一轮拉过,就不会紧接着又拉 */
|
|
694
714
|
function scheduleIdleQuota() {
|
|
695
715
|
clearTimeout(quotaState.idle);
|
|
696
|
-
if (!quotaInterval()) return;
|
|
716
|
+
if (desktopQuota || !quotaInterval()) return;
|
|
697
717
|
quotaState.idle = setTimeout(() => {
|
|
698
718
|
// 页面不可见就先不拉,等切回来再说
|
|
699
719
|
if (document.visibilityState === "visible") void loadQuota();
|
|
@@ -702,13 +722,15 @@ function scheduleIdleQuota() {
|
|
|
702
722
|
|
|
703
723
|
/** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
|
|
704
724
|
function scheduleQuota() {
|
|
725
|
+
// 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
|
|
726
|
+
if (desktopQuota) return;
|
|
705
727
|
const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
|
|
706
728
|
clearTimeout(quotaState.timer);
|
|
707
729
|
quotaState.timer = setTimeout(() => void loadQuota(), wait);
|
|
708
730
|
}
|
|
709
731
|
|
|
710
732
|
document.addEventListener("visibilitychange", () => {
|
|
711
|
-
if (document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
|
|
733
|
+
if (desktopQuota || document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
|
|
712
734
|
if (Date.now() - quotaState.at >= quotaInterval()) void loadQuota();
|
|
713
735
|
});
|
|
714
736
|
|
|
@@ -3308,7 +3330,13 @@ function generalTab(cfg, panel) {
|
|
|
3308
3330
|
field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
|
|
3309
3331
|
field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
|
|
3310
3332
|
field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
|
|
3311
|
-
field(
|
|
3333
|
+
field(
|
|
3334
|
+
"额度自动刷新间隔(分钟)",
|
|
3335
|
+
quotaMin,
|
|
3336
|
+
desktopQuota
|
|
3337
|
+
? "在桌面端里额度由桌面端统一刷新,以桌面端设置里的间隔为准;这里只对浏览器打开的页面生效"
|
|
3338
|
+
: "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)",
|
|
3339
|
+
),
|
|
3312
3340
|
h(
|
|
3313
3341
|
"div",
|
|
3314
3342
|
{ class: "row" },
|
|
@@ -3329,6 +3357,89 @@ function generalTab(cfg, panel) {
|
|
|
3329
3357
|
),
|
|
3330
3358
|
),
|
|
3331
3359
|
);
|
|
3360
|
+
panel.append(networkSection(cfg));
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
/** 只有本机能连的监听地址(和服务端 listen.ts 的判断一致) */
|
|
3364
|
+
function loopbackHost(host) {
|
|
3365
|
+
const v = String(host ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
3366
|
+
return v === "localhost" || v === "::1" || /^127\./.test(v);
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* 网络访问:要不要让局域网里的其他设备打开、监听哪个端口。保存后服务端当场换过去,不用重启;
|
|
3371
|
+
* 端口被占用之类的失败会直接报出来,原来的照常工作。
|
|
3372
|
+
*/
|
|
3373
|
+
function networkSection(cfg) {
|
|
3374
|
+
const net = cfg.network ?? { lan: [], local: true, listening: null };
|
|
3375
|
+
const box = h("div", { class: "section" });
|
|
3376
|
+
const lanOn = h("input", { type: "checkbox", checked: !loopbackHost(cfg.web.host) });
|
|
3377
|
+
const port = h("input", { type: "number", min: 1, max: 65535, value: cfg.web.port });
|
|
3378
|
+
const hint = h("small", {});
|
|
3379
|
+
// 现在这个页面是不是直接连的这个端口(不是经由反代 / 隧道)
|
|
3380
|
+
const pagePort = Number(location.port || (location.protocol === "https:" ? 443 : 80));
|
|
3381
|
+
const direct = pagePort === cfg.web.port;
|
|
3382
|
+
|
|
3383
|
+
const update = () => {
|
|
3384
|
+
const p = Number(port.value) || cfg.web.port;
|
|
3385
|
+
const lines = [];
|
|
3386
|
+
if (lanOn.checked) {
|
|
3387
|
+
lines.push(
|
|
3388
|
+
net.lan.length
|
|
3389
|
+
? `局域网里的其他设备用 ${net.lan.map((ip) => `http://${ip}:${p}`).join(" 或 ")} 打开`
|
|
3390
|
+
: "这台机器现在没连局域网,连上后其他设备可以用它的局域网地址打开",
|
|
3391
|
+
);
|
|
3392
|
+
} else {
|
|
3393
|
+
lines.push("只有这台机器自己能打开。这台机器上的反向代理、Cloudflare Tunnel 之类不受影响");
|
|
3394
|
+
}
|
|
3395
|
+
if (!net.local && !lanOn.checked && !loopbackHost(cfg.web.host)) {
|
|
3396
|
+
lines.push("⚠️ 你现在是从局域网里的另一台设备访问的,不能在这里关掉,请到运行 agent-loft 的机器上操作");
|
|
3397
|
+
}
|
|
3398
|
+
if (!direct && p !== cfg.web.port) lines.push(`⚠️ 你是经由反向代理 / 隧道访问的,改端口后要把它指向新端口 ${p},否则这个页面会打不开`);
|
|
3399
|
+
fill(hint, lines.map((l, i) => [i ? h("br") : null, l]));
|
|
3400
|
+
};
|
|
3401
|
+
lanOn.addEventListener("change", update);
|
|
3402
|
+
port.addEventListener("input", update);
|
|
3403
|
+
update();
|
|
3404
|
+
|
|
3405
|
+
const save = async () => {
|
|
3406
|
+
const p = Number(port.value);
|
|
3407
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) return toast("端口要是 1 到 65535 之间的整数");
|
|
3408
|
+
// 打开:原来是本机地址就改成所有网卡;原来就是某个具体地址(手改过配置)就保留
|
|
3409
|
+
const host = lanOn.checked ? (loopbackHost(cfg.web.host) ? "0.0.0.0" : cfg.web.host) : "127.0.0.1";
|
|
3410
|
+
if (host === cfg.web.host && p === cfg.web.port) return toast("没有改动");
|
|
3411
|
+
const oldPort = cfg.web.port;
|
|
3412
|
+
let next;
|
|
3413
|
+
try {
|
|
3414
|
+
next = await api("/api/config", { method: "PUT", body: { web: { host, port: p } } });
|
|
3415
|
+
} catch (err) {
|
|
3416
|
+
toast(err.message, 5000);
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
Object.assign(cfg, next);
|
|
3420
|
+
if (p !== oldPort && direct) {
|
|
3421
|
+
// 旧端口已经不再接新连接:带着当前会话跳到新端口
|
|
3422
|
+
const url = new URL(location.href);
|
|
3423
|
+
url.port = String(p);
|
|
3424
|
+
toast(`已改为端口 ${p},正在跳转…`);
|
|
3425
|
+
// 桌面端里由它改掉存着的服务器地址再重新打开(直接跳过去会被当成外部链接)
|
|
3426
|
+
if (typeof desktop?.serverMoved === "function") desktop.serverMoved(url.origin);
|
|
3427
|
+
else setTimeout(() => (location.href = url.href), 600);
|
|
3428
|
+
return;
|
|
3429
|
+
}
|
|
3430
|
+
toast(p !== oldPort ? `已改为端口 ${p},记得把反向代理 / 隧道也指向它` : "已保存", p !== oldPort ? 6000 : 2600);
|
|
3431
|
+
box.replaceWith(networkSection(cfg));
|
|
3432
|
+
};
|
|
3433
|
+
|
|
3434
|
+
fill(
|
|
3435
|
+
box,
|
|
3436
|
+
h("h4", {}, "网络访问"),
|
|
3437
|
+
h("label", { class: "check" }, lanOn, "允许局域网内的其他设备访问"),
|
|
3438
|
+
hint,
|
|
3439
|
+
field("端口", port, "改了立即生效,不用重启"),
|
|
3440
|
+
h("div", { class: "row" }, h("button", { class: "btn primary", onclick: () => void save() }, "保存")),
|
|
3441
|
+
);
|
|
3442
|
+
return box;
|
|
3332
3443
|
}
|
|
3333
3444
|
|
|
3334
3445
|
/**
|