agent-loft 0.2.13 → 0.2.15
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 +14 -0
- package/package.json +1 -1
- package/src/agents/health.ts +169 -0
- package/src/channels/web/api.ts +25 -0
- package/src/channels/web/server.ts +21 -7
- package/src/core/upgrade.ts +200 -0
- package/src/main.ts +3 -0
- package/web/app.js +193 -9
- package/web/style.css +81 -0
package/README.md
CHANGED
|
@@ -49,6 +49,20 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
|
|
|
49
49
|
|
|
50
50
|
打开 `http://<机器 IP>:8800`:第一次需要输入日志里的设置码,然后设一个登录密码。
|
|
51
51
|
|
|
52
|
+
设置 → 通用 里能看到服务端版本,有新版本时点「升级」即可(`npm install -g agent-loft@latest`,以系统服务运行时会自动重启);设置 → Agent 里能检查 Claude Code、Codex 装好、登录了没有,Codex 可以一键装到服务端上。
|
|
53
|
+
|
|
54
|
+
### macOS 桌面端
|
|
55
|
+
|
|
56
|
+
到 [Releases](https://github.com/qijieinnet/agent-loft/releases) 下载 `desktop-v*` 里对应芯片的 DMG(Apple 芯片选 arm64,Intel 选 x64),拖进「应用程序」。还没有 Apple 开发者签名,第一次打开如果提示无法验证,到 系统设置 → 隐私与安全性 里点「仍要打开」。
|
|
57
|
+
|
|
58
|
+
- 同时连多台服务器,左边一栏切换;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知。
|
|
59
|
+
- 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话。
|
|
60
|
+
- 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
|
|
61
|
+
- 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。
|
|
62
|
+
- 服务端有新版本时在服务器列表里点「升级服务端」;桌面端自己的更新在 设置 → 更新 里点「更新并重启」。
|
|
63
|
+
|
|
64
|
+
桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
|
|
65
|
+
|
|
52
66
|
### 从旧桥接器迁移
|
|
53
67
|
|
|
54
68
|
```bash
|
package/package.json
CHANGED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Agent 环境体检:装没装、登没登录、怎么修。给 Web 设置页和桌面端的「Agent 环境」用。
|
|
2
|
+
//
|
|
3
|
+
// 修复办法只给命令(给人看、给人复制),这里不替人执行:安装要联网、登录要在浏览器里点,
|
|
4
|
+
// 都得在用户眼皮底下的终端里做。
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { agentEnvironment } from "../core/agent-env.js";
|
|
9
|
+
import { config } from "../core/config.js";
|
|
10
|
+
import { withProbe } from "./claude/probe.js";
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
export type AgentFix = {
|
|
15
|
+
action: "install" | "login";
|
|
16
|
+
title: string;
|
|
17
|
+
/** 每一条是一种做法,任选其一(比如 Homebrew 和 npm 两种装法) */
|
|
18
|
+
commands: string[];
|
|
19
|
+
note?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type AgentHealth = {
|
|
23
|
+
id: "claude" | "codex";
|
|
24
|
+
label: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
installed: boolean;
|
|
27
|
+
version?: string;
|
|
28
|
+
/** null:判断不了(探测出错、超时) */
|
|
29
|
+
loggedIn: boolean | null;
|
|
30
|
+
account?: string;
|
|
31
|
+
problem?: string;
|
|
32
|
+
fix?: AgentFix;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function run(cmd: string, args: string[], timeoutMs: number, env?: NodeJS.ProcessEnv): Promise<{ code: number; out: string }> {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let out = "";
|
|
38
|
+
let child;
|
|
39
|
+
try {
|
|
40
|
+
child = spawn(cmd, args, { env: agentEnvironment(env ?? process.env), stdio: ["ignore", "pipe", "pipe"] });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
resolve({ code: -1, out: err instanceof Error ? err.message : String(err) });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
46
|
+
child.stdout.on("data", (d) => (out += d));
|
|
47
|
+
child.stderr.on("data", (d) => (out += d));
|
|
48
|
+
child.on("error", (err) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve({ code: -1, out: err.message });
|
|
51
|
+
});
|
|
52
|
+
child.on("close", (code) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve({ code: code ?? -1, out: out.trim() });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function timeout<T>(ms: number, what: string): Promise<T> {
|
|
60
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`${what}超时`)), ms).unref());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function quote(p: string): string {
|
|
64
|
+
return /^[\w./-]+$/.test(p) ? p : `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 登录用哪个 claude:系统里装了 Claude Code 就用它,否则用 SDK 自带的那份(同一个程序) */
|
|
68
|
+
export function claudeBinary(): string {
|
|
69
|
+
if (spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" }).stdout.trim()) return "claude";
|
|
70
|
+
try {
|
|
71
|
+
const pkg = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/package.json`;
|
|
72
|
+
return path.join(path.dirname(require.resolve(pkg)), "claude");
|
|
73
|
+
} catch {
|
|
74
|
+
return "claude";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function claudeHealth(): Promise<AgentHealth> {
|
|
79
|
+
const base = { id: "claude" as const, label: "Claude Code", enabled: config().agents.claude.enabled, installed: true };
|
|
80
|
+
const loginFix: AgentFix = {
|
|
81
|
+
action: "login",
|
|
82
|
+
title: "登录 Claude",
|
|
83
|
+
commands: [quote(claudeBinary())],
|
|
84
|
+
note: "打开后输入 /login,按提示在浏览器里登录,完成后输入 /exit。用 API Key 的话,把 ANTHROPIC_API_KEY 写进服务端的环境变量也行。",
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
const info = await Promise.race([withProbe((q) => q.accountInfo()), timeout<never>(25_000, "读取 Claude 账号")]);
|
|
88
|
+
const thirdParty = info.apiProvider && info.apiProvider !== "firstParty";
|
|
89
|
+
const loggedIn = !!(info.email || info.apiKeySource || thirdParty || (info.tokenSource && info.tokenSource !== "none"));
|
|
90
|
+
const account = info.email
|
|
91
|
+
? `${info.email}${info.subscriptionType ? `(${info.subscriptionType})` : ""}`
|
|
92
|
+
: info.apiKeySource
|
|
93
|
+
? `API Key(${info.apiKeySource})`
|
|
94
|
+
: thirdParty
|
|
95
|
+
? String(info.apiProvider)
|
|
96
|
+
: undefined;
|
|
97
|
+
return { ...base, loggedIn, ...(account ? { account } : {}), ...(loggedIn ? {} : { fix: loginFix }) };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ...base, loggedIn: null, problem: err instanceof Error ? err.message : String(err), fix: loginFix };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function codexHealth(): Promise<AgentHealth> {
|
|
104
|
+
const cfg = config().agents.codex;
|
|
105
|
+
const base = { id: "codex" as const, label: "Codex", enabled: cfg.enabled };
|
|
106
|
+
const env = { ...process.env, ...(cfg.home ? { CODEX_HOME: cfg.home } : {}) };
|
|
107
|
+
const ver = await run(cfg.path, ["--version"], 10_000, env);
|
|
108
|
+
if (ver.code !== 0) {
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
installed: false,
|
|
112
|
+
loggedIn: null,
|
|
113
|
+
problem: `找不到 ${cfg.path}`,
|
|
114
|
+
fix: {
|
|
115
|
+
action: "install",
|
|
116
|
+
title: "安装 Codex",
|
|
117
|
+
commands: ["brew install --cask codex", "npm install -g @openai/codex"],
|
|
118
|
+
note: "有 Homebrew 用第一条,否则用第二条(需要 Node.js)。agent-loft 以系统服务运行时 PATH 很短,装好后在下面把「codex 可执行文件」填成绝对路径(终端里 command -v codex 可以查到)。",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const version = ver.out.split("\n")[0]?.trim();
|
|
123
|
+
const st = await run(cfg.path, ["login", "status"], 10_000, env);
|
|
124
|
+
const loggedIn = st.code === 0 && !/not logged in/i.test(st.out);
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
installed: true,
|
|
128
|
+
...(version ? { version } : {}),
|
|
129
|
+
loggedIn,
|
|
130
|
+
...(loggedIn ? { account: st.out.split("\n")[0]?.trim() } : {}),
|
|
131
|
+
...(loggedIn ? {} : { fix: { action: "login" as const, title: "登录 Codex", commands: [`${quote(cfg.path)} login`], note: "会打开浏览器,用 ChatGPT 账号登录;也可以 codex login --with-api-key 用 API Key。" } }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let cache: { at: number; value: AgentHealth[] } | null = null;
|
|
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
|
+
|
|
151
|
+
/** 装了 / 登录了什么之后,下次体检不用缓存 */
|
|
152
|
+
export function invalidateHealth(): void {
|
|
153
|
+
cache = null;
|
|
154
|
+
}
|
|
155
|
+
let inflight: Promise<AgentHealth[]> | null = null;
|
|
156
|
+
|
|
157
|
+
/** 两个 agent 一起查,结果缓存一分钟;refresh 跳过缓存(点了「重新检查」) */
|
|
158
|
+
export function agentHealth(refresh = false): Promise<AgentHealth[]> {
|
|
159
|
+
if (!refresh && cache && Date.now() - cache.at < 60_000) return Promise.resolve(cache.value);
|
|
160
|
+
inflight ??= Promise.all([claudeHealth(), codexHealth()])
|
|
161
|
+
.then((value) => {
|
|
162
|
+
cache = { at: Date.now(), value };
|
|
163
|
+
return value;
|
|
164
|
+
})
|
|
165
|
+
.finally(() => {
|
|
166
|
+
inflight = null;
|
|
167
|
+
});
|
|
168
|
+
return inflight;
|
|
169
|
+
}
|
package/src/channels/web/api.ts
CHANGED
|
@@ -12,6 +12,8 @@ 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 { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
|
|
16
|
+
import { installCodex, upgrade, useInstalledCodex, versionInfo } from "../../core/upgrade.js";
|
|
15
17
|
import { feishuBots } from "../feishu/index.js";
|
|
16
18
|
import { beginRegistration, initRegistration, pollRegistration, qrSvg } from "../feishu/register.js";
|
|
17
19
|
import { FeishuClient } from "../feishu/client.js";
|
|
@@ -315,6 +317,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
315
317
|
capabilities: d?.capabilities ?? {},
|
|
316
318
|
// 输入框模型列表里显示哪些,空 = 全部
|
|
317
319
|
models: config().agents[id].models.filter((m) => typeof m === "string"),
|
|
320
|
+
// 没装 / 没登录:新对话里不出现(null = 还没查过,当作能用)
|
|
321
|
+
...(agentAvailability(id) ?? {}),
|
|
318
322
|
};
|
|
319
323
|
}),
|
|
320
324
|
bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
|
|
@@ -539,6 +543,27 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
539
543
|
return;
|
|
540
544
|
}
|
|
541
545
|
// 侧栏的账号额度:各 agent 自己缓存(60 秒),这里并发问一遍,单个失败 / 超时不影响别的
|
|
546
|
+
if (p === "/api/server/version" && method === "GET") {
|
|
547
|
+
sendJson(res, 200, await versionInfo(url.searchParams.get("refresh") === "1"));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (p === "/api/server/upgrade" && method === "POST") {
|
|
551
|
+
sendJson(res, 200, await upgrade().catch((err) => {
|
|
552
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
553
|
+
}));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (p === "/api/agents/codex/install" && method === "POST") {
|
|
557
|
+
const result = await installCodex();
|
|
558
|
+
if (result.ok) useInstalledCodex();
|
|
559
|
+
invalidateHealth();
|
|
560
|
+
sendJson(res, 200, result);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (p === "/api/agents/health" && method === "GET") {
|
|
564
|
+
sendJson(res, 200, { agents: await agentHealth(url.searchParams.get("refresh") === "1") });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
542
567
|
if (p === "/api/quota" && method === "GET") {
|
|
543
568
|
const results = await Promise.all(
|
|
544
569
|
hub.enabledAgents().map(async (d) => {
|
|
@@ -180,31 +180,45 @@ function convName(key: string): string {
|
|
|
180
180
|
return conv.title?.trim() || hub.label(conv.agent);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
/**
|
|
184
|
-
function
|
|
185
|
-
return getConversation(key)?.channel === "web"
|
|
183
|
+
/** 只提醒 Web 自己的会话:飞书会话的动静飞书已经会提醒 */
|
|
184
|
+
function isWebConv(key: string): boolean {
|
|
185
|
+
return getConversation(key)?.channel === "web";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type Notice = { title: string; body: string; tag: string };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 一条要提醒的动静,走两条路:
|
|
192
|
+
* - 广播给所有连着的页面(t: "notice"):桌面端据此弹系统通知,要不要弹由它自己判断
|
|
193
|
+
* (它知道自己是不是正看着这条会话)。普通网页忽略。
|
|
194
|
+
* - Web Push:只在没有页面正看着这条会话时推。
|
|
195
|
+
*/
|
|
196
|
+
function notify(key: string, notice: Notice): void {
|
|
197
|
+
if (!isWebConv(key)) return;
|
|
198
|
+
for (const client of clients) send(client, { t: "notice", key, ...notice });
|
|
199
|
+
if (hasSubscriptions() && !watching(key)) void pushToAll({ ...notice, url: `/#${encodeURIComponent(key)}` });
|
|
186
200
|
}
|
|
187
201
|
|
|
188
202
|
function pushEvent(env: EventEnvelope): void {
|
|
189
203
|
const ev = env.ev;
|
|
190
204
|
if (ev.type !== "result" && ev.type !== "error") return;
|
|
191
|
-
if (!
|
|
205
|
+
if (!isWebConv(env.key)) return;
|
|
192
206
|
const name = convName(env.key);
|
|
193
207
|
const payload =
|
|
194
208
|
ev.type === "result"
|
|
195
209
|
? { title: `${name} · 回复完成`, body: plainPreview(ev.text) || "(没有文字回复)" }
|
|
196
210
|
: { title: `${name} · 出错了`, body: plainPreview(ev.message) };
|
|
197
|
-
|
|
211
|
+
notify(env.key, { ...payload, tag: `reply:${env.key}` });
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
function pushInteraction(i: Interaction): void {
|
|
201
|
-
if (!
|
|
215
|
+
if (!isWebConv(i.convKey)) return;
|
|
202
216
|
const name = convName(i.convKey);
|
|
203
217
|
const payload =
|
|
204
218
|
i.spec.kind === "approval"
|
|
205
219
|
? { title: `${name} · 需要授权`, body: plainPreview(i.spec.title) }
|
|
206
220
|
: { title: `${name} · 有问题要你回答`, body: plainPreview(i.spec.questions[0]?.question ?? i.spec.title) };
|
|
207
|
-
|
|
221
|
+
notify(i.convKey, { ...payload, tag: `ix:${i.id}` });
|
|
208
222
|
}
|
|
209
223
|
|
|
210
224
|
const BY = "已在 Web 处理";
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// 服务端自升级:查 npm 上的最新版本,npm install -g 升级,跑在系统服务下就自动重启。
|
|
2
|
+
//
|
|
3
|
+
// 只支持 npm 全局安装(npm install -g agent-loft):从源码跑的(git clone)该用 git pull,
|
|
4
|
+
// 别的装法不知道怎么升级,都不碰。
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { config, saveConfig } from "./config.js";
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
12
|
+
|
|
13
|
+
function readPackage(): { name: string; version: string } {
|
|
14
|
+
return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 当前进程跑的版本(升级后磁盘上的文件变了,进程里的还是旧的,直到重启) */
|
|
18
|
+
export const RUNNING_VERSION = readPackage().version;
|
|
19
|
+
const NAME = readPackage().name;
|
|
20
|
+
|
|
21
|
+
/** npm 和 node 放在一起(官方安装包、Homebrew、fnm、nvm 都是),用同一套,装到同一个地方 */
|
|
22
|
+
function npmBin(): string {
|
|
23
|
+
const beside = path.join(path.dirname(process.execPath), process.platform === "win32" ? "npm.cmd" : "npm");
|
|
24
|
+
return fs.existsSync(beside) ? beside : "npm";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function npmEnv(): NodeJS.ProcessEnv {
|
|
28
|
+
// 系统服务下 PATH 很短:npm 是个 node 脚本,得让它找得到 node
|
|
29
|
+
return { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number; out: string }> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let out = "";
|
|
35
|
+
const child = spawn(cmd, args, { env: npmEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
|
36
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
37
|
+
child.stdout.on("data", (d) => (out += d));
|
|
38
|
+
child.stderr.on("data", (d) => (out += d));
|
|
39
|
+
child.on("error", (err) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve({ code: -1, out: err.message });
|
|
42
|
+
});
|
|
43
|
+
child.on("close", (code) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
resolve({ code: code ?? -1, out: out.trim() });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 由谁托管:launchd 会把标签放进 XPC_SERVICE_NAME,systemd 会设 INVOCATION_ID */
|
|
51
|
+
export function serviceManager(): "launchd" | "systemd" | null {
|
|
52
|
+
const xpc = process.env.XPC_SERVICE_NAME ?? "";
|
|
53
|
+
if (/^com\.(agent-loft|im-agent)/.test(xpc)) return "launchd";
|
|
54
|
+
if (process.env.INVOCATION_ID) return "systemd";
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* npm 全局安装的目录结构:<prefix>/lib/node_modules/agent-loft,命令链接在 <prefix>/bin/agent-loft
|
|
60
|
+
* (Windows 上是 <prefix>/node_modules/agent-loft 和 <prefix>/agent-loft.cmd)。
|
|
61
|
+
* 按结构认,不去问 npm root -g:npm 会把输出里像密钥的串(比如路径里的 UUID)打成 ***。
|
|
62
|
+
*/
|
|
63
|
+
function globalPrefix(): string | null {
|
|
64
|
+
const real = (p: string) => {
|
|
65
|
+
try {
|
|
66
|
+
return fs.realpathSync(p);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const self = real(root);
|
|
72
|
+
if (!self || path.basename(path.dirname(self)) !== "node_modules") return null;
|
|
73
|
+
if (process.platform === "win32") {
|
|
74
|
+
const prefix = path.dirname(path.dirname(self));
|
|
75
|
+
return fs.existsSync(path.join(prefix, `${NAME}.cmd`)) ? prefix : null;
|
|
76
|
+
}
|
|
77
|
+
if (path.basename(path.dirname(path.dirname(self))) !== "lib") return null;
|
|
78
|
+
const prefix = path.dirname(path.dirname(path.dirname(self)));
|
|
79
|
+
const link = real(path.join(prefix, "bin", NAME));
|
|
80
|
+
return link && link === real(path.join(self, "bin/cli.js")) ? prefix : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 怎么装的:npm 全局安装才能在线升级 */
|
|
84
|
+
async function installKind(): Promise<"npm-global" | "source" | "unknown"> {
|
|
85
|
+
if (fs.existsSync(path.join(root, ".git"))) return "source";
|
|
86
|
+
return globalPrefix() ? "npm-global" : "unknown";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let latestCache: { at: number; version: string | null; error?: string } | null = null;
|
|
90
|
+
|
|
91
|
+
/** npm 上的最新版。用 npm view 而不是直接请求 registry.npmjs.org:用户配的镜像源(npmmirror 等)照样生效 */
|
|
92
|
+
async function latestVersion(refresh: boolean): Promise<{ version: string | null; error?: string }> {
|
|
93
|
+
if (!refresh && latestCache && Date.now() - latestCache.at < 30 * 60_000) return latestCache;
|
|
94
|
+
const r = await run(npmBin(), ["view", NAME, "version"], 30_000);
|
|
95
|
+
const version = r.code === 0 ? r.out.split("\n").find((l) => /^\d+\.\d+\.\d+/.test(l.trim()))?.trim() : undefined;
|
|
96
|
+
latestCache = version ? { at: Date.now(), version } : { at: Date.now(), version: null, error: r.out.slice(-300) || "查询失败" };
|
|
97
|
+
return latestCache;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** "0.2.10" > "0.2.9" */
|
|
101
|
+
export function newer(a: string, b: string): boolean {
|
|
102
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
103
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
104
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
105
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type VersionInfo = {
|
|
111
|
+
version: string;
|
|
112
|
+
latest: string | null;
|
|
113
|
+
latestError?: string;
|
|
114
|
+
updateAvailable: boolean;
|
|
115
|
+
installKind: "npm-global" | "source" | "unknown";
|
|
116
|
+
service: "launchd" | "systemd" | null;
|
|
117
|
+
canUpgrade: boolean;
|
|
118
|
+
/** 不能在线升级时说明原因和手动办法 */
|
|
119
|
+
reason?: string;
|
|
120
|
+
upgrading: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let upgrading = false;
|
|
124
|
+
|
|
125
|
+
export async function versionInfo(refresh = false): Promise<VersionInfo> {
|
|
126
|
+
const [kind, latest] = await Promise.all([installKind(), latestVersion(refresh)]);
|
|
127
|
+
const updateAvailable = !!latest.version && newer(latest.version, RUNNING_VERSION);
|
|
128
|
+
const reason =
|
|
129
|
+
kind === "source"
|
|
130
|
+
? "从源码运行的,用 git pull 更新后重启"
|
|
131
|
+
: kind === "unknown"
|
|
132
|
+
? "不是用 npm install -g 装的,没法在线升级"
|
|
133
|
+
: undefined;
|
|
134
|
+
return {
|
|
135
|
+
version: RUNNING_VERSION,
|
|
136
|
+
latest: latest.version,
|
|
137
|
+
...(latest.error ? { latestError: latest.error } : {}),
|
|
138
|
+
updateAvailable,
|
|
139
|
+
installKind: kind,
|
|
140
|
+
service: serviceManager(),
|
|
141
|
+
canUpgrade: kind === "npm-global",
|
|
142
|
+
...(reason ? { reason } : {}),
|
|
143
|
+
upgrading,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 升级到最新版。跑在 launchd / systemd 下的,回完话就退出,由它们拉起新版本;
|
|
149
|
+
* 前台手动跑的(agent-loft start)没人拉起,只能请用户自己重启。
|
|
150
|
+
*/
|
|
151
|
+
export async function upgrade(): Promise<{ from: string; to: string; restarting: boolean; output: string }> {
|
|
152
|
+
if (upgrading) throw new Error("正在升级,稍等");
|
|
153
|
+
const info = await versionInfo(true);
|
|
154
|
+
if (!info.canUpgrade) throw new Error(info.reason ?? "没法在线升级");
|
|
155
|
+
if (!info.latest) throw new Error(`查不到最新版本:${info.latestError ?? ""}`);
|
|
156
|
+
if (!info.updateAvailable) throw new Error(`已经是最新版本 ${RUNNING_VERSION}`);
|
|
157
|
+
upgrading = true;
|
|
158
|
+
try {
|
|
159
|
+
// 明确指定 prefix:装回原来那个位置,不受当前 npm 配置影响
|
|
160
|
+
const prefix = globalPrefix()!;
|
|
161
|
+
const r = await run(npmBin(), ["install", "-g", "--prefix", prefix, `${NAME}@${info.latest}`, "--no-audit", "--no-fund"], 10 * 60_000);
|
|
162
|
+
const installed = readPackage().version;
|
|
163
|
+
if (r.code !== 0 || installed !== info.latest) {
|
|
164
|
+
throw new Error(`npm install 失败:${r.out.slice(-600) || `退出码 ${r.code}`}`);
|
|
165
|
+
}
|
|
166
|
+
const restarting = !!info.service;
|
|
167
|
+
if (restarting) {
|
|
168
|
+
console.log(`[升级] 已装好 ${installed},退出进程,由 ${info.service} 拉起新版本`);
|
|
169
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
170
|
+
}
|
|
171
|
+
return { from: RUNNING_VERSION, to: installed, restarting, output: r.out.slice(-2000) };
|
|
172
|
+
} finally {
|
|
173
|
+
upgrading = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 在服务端这台机器上安装 Codex:有 Homebrew 用它,否则用 npm。登录要在浏览器里点,这里不管。 */
|
|
178
|
+
export async function installCodex(): Promise<{ ok: boolean; via: string; output: string }> {
|
|
179
|
+
const brew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find((p) => fs.existsSync(p));
|
|
180
|
+
const r =
|
|
181
|
+
process.platform === "darwin" && brew
|
|
182
|
+
? await run(brew, ["install", "--cask", "codex"], 10 * 60_000)
|
|
183
|
+
: await run(npmBin(), ["install", "-g", "@openai/codex", "--no-audit", "--no-fund"], 10 * 60_000);
|
|
184
|
+
return { ok: r.code === 0, via: process.platform === "darwin" && brew ? "Homebrew" : "npm", output: r.out.slice(-2000) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 装好 Codex 之后:配置里的 codex(默认就是 "codex")在服务的 PATH 里找不到的话,
|
|
189
|
+
* 换成刚装好的那个的绝对路径。改配置会让 codex app-server 按新路径重拉。
|
|
190
|
+
*/
|
|
191
|
+
export function useInstalledCodex(): void {
|
|
192
|
+
const cfg = config();
|
|
193
|
+
if (spawnSync(cfg.agents.codex.path, ["--version"], { env: npmEnv(), timeout: 10_000 }).status === 0) return;
|
|
194
|
+
const found = [path.join(path.dirname(process.execPath), "codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex"].find((p) => fs.existsSync(p));
|
|
195
|
+
if (!found) return;
|
|
196
|
+
const next = structuredClone(cfg);
|
|
197
|
+
next.agents.codex.path = found;
|
|
198
|
+
saveConfig(next);
|
|
199
|
+
console.log(`[codex] 已装好,路径改成 ${found}`);
|
|
200
|
+
}
|
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
|
@@ -260,6 +260,14 @@ const S = {
|
|
|
260
260
|
|
|
261
261
|
const ui = {};
|
|
262
262
|
|
|
263
|
+
/**
|
|
264
|
+
* 运行在 macOS 桌面端(agent-loft-desktop)里时,由它的 preload 注入。
|
|
265
|
+
* 桌面端收不到 Web Push,系统通知、Dock 角标、菜单栏的待处理数都交给它。
|
|
266
|
+
*/
|
|
267
|
+
const desktop = window.agentLoftDesktop ?? null;
|
|
268
|
+
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
|
|
269
|
+
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
270
|
+
|
|
263
271
|
// ---------- WebSocket ----------
|
|
264
272
|
|
|
265
273
|
const WS = {
|
|
@@ -445,6 +453,12 @@ function onWsMessage(msg) {
|
|
|
445
453
|
case "ix":
|
|
446
454
|
upsertInteraction(msg.interaction);
|
|
447
455
|
return;
|
|
456
|
+
case "notice":
|
|
457
|
+
// 正看着这条会话就不打扰;普通网页靠 Web Push,这里只给桌面端
|
|
458
|
+
if (!desktop) return;
|
|
459
|
+
if (document.visibilityState === "visible" && document.hasFocus() && msg.key === S.current) return;
|
|
460
|
+
desktop.notify({ key: msg.key, title: msg.title, body: msg.body, tag: msg.tag });
|
|
461
|
+
return;
|
|
448
462
|
}
|
|
449
463
|
}
|
|
450
464
|
|
|
@@ -516,9 +530,10 @@ async function boot() {
|
|
|
516
530
|
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
517
531
|
S.convs = new Map(data.conversations.map((c) => [c.key, c]));
|
|
518
532
|
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
519
|
-
const enabled =
|
|
533
|
+
const enabled = usableAgents().map((a) => a.id);
|
|
520
534
|
if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
|
|
521
535
|
renderLayout();
|
|
536
|
+
reportDesktop();
|
|
522
537
|
WS.connect();
|
|
523
538
|
void loadQuota();
|
|
524
539
|
const fromHash = decodeURIComponent(location.hash.slice(1));
|
|
@@ -527,6 +542,10 @@ async function boot() {
|
|
|
527
542
|
if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
|
|
528
543
|
else if (latest) await openConversation(latest.key);
|
|
529
544
|
else newDraft();
|
|
545
|
+
// 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
|
|
546
|
+
desktop?.onOpen((key) => {
|
|
547
|
+
if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
|
|
548
|
+
});
|
|
530
549
|
if ("serviceWorker" in navigator) {
|
|
531
550
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
532
551
|
clearAppBadge();
|
|
@@ -660,6 +679,7 @@ async function loadQuota() {
|
|
|
660
679
|
const data = await api("/api/quota");
|
|
661
680
|
quotaState.data = data.quotas;
|
|
662
681
|
quotaState.at = Date.now();
|
|
682
|
+
desktop?.setQuota(data.quotas);
|
|
663
683
|
} catch {
|
|
664
684
|
// 保留旧数据
|
|
665
685
|
} finally {
|
|
@@ -829,6 +849,7 @@ function scheduleSidebar() {
|
|
|
829
849
|
requestAnimationFrame(() => {
|
|
830
850
|
sidebarScheduled = false;
|
|
831
851
|
renderSidebar();
|
|
852
|
+
reportDesktop();
|
|
832
853
|
});
|
|
833
854
|
}
|
|
834
855
|
|
|
@@ -923,6 +944,16 @@ let autoOpenedFor = null;
|
|
|
923
944
|
const RECENT_LIMIT = 10;
|
|
924
945
|
const PROJECT_LIMIT = 5;
|
|
925
946
|
|
|
947
|
+
/** 待处理的授权 / 提问和正在跑的会话,报给桌面端显示在菜单栏里 */
|
|
948
|
+
function reportDesktop() {
|
|
949
|
+
if (!desktop) return;
|
|
950
|
+
const pending = [...S.pending.values()]
|
|
951
|
+
.filter((i) => !i.settled)
|
|
952
|
+
.map((i) => ({ id: i.id, key: i.convKey, title: i.spec.title, conv: convTitle(S.convs.get(i.convKey)) }));
|
|
953
|
+
const busy = [...S.convs.values()].filter((c) => c.busy).length;
|
|
954
|
+
desktop.setState({ pending, busy });
|
|
955
|
+
}
|
|
956
|
+
|
|
926
957
|
function renderSidebar() {
|
|
927
958
|
if (!ui.convList) return;
|
|
928
959
|
const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
|
|
@@ -1084,8 +1115,18 @@ function currentCwd() {
|
|
|
1084
1115
|
return c ? c.cwd : (S.draftCwd ?? S.workspaceRoot ?? "");
|
|
1085
1116
|
}
|
|
1086
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* 新对话能选的 agent:启用了、并且服务端查过是装好登录了的(available 为 false 的不出现)。
|
|
1120
|
+
* 一个能用的都没有时退回到全部启用的,至少能看到报错,而不是什么都选不了。
|
|
1121
|
+
*/
|
|
1122
|
+
function usableAgents() {
|
|
1123
|
+
const enabled = S.agents.filter((a) => a.enabled);
|
|
1124
|
+
const usable = enabled.filter((a) => a.available !== false);
|
|
1125
|
+
return usable.length ? usable : enabled;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1087
1128
|
function pickAgentMenu(anchor) {
|
|
1088
|
-
const allowed =
|
|
1129
|
+
const allowed = usableAgents().map((a) => a.id);
|
|
1089
1130
|
if (allowed.length < 2) return;
|
|
1090
1131
|
if (ui.menu?.dataset.for === "agent") return closeMenu();
|
|
1091
1132
|
const pop = showPopover(anchor, { align: "left" });
|
|
@@ -1176,7 +1217,7 @@ function renderContext() {
|
|
|
1176
1217
|
if (!ui.ctxBar) return;
|
|
1177
1218
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
1178
1219
|
const cwd = currentCwd();
|
|
1179
|
-
const multi =
|
|
1220
|
+
const multi = usableAgents().length > 1;
|
|
1180
1221
|
// 会话里不能换 agent(两个 agent 的会话互不相通),只显示;新对话里可以选
|
|
1181
1222
|
const agentId = c ? c.agent : S.draftAgent;
|
|
1182
1223
|
const agentEl = c || !multi
|
|
@@ -1408,7 +1449,7 @@ function newDraft() {
|
|
|
1408
1449
|
}
|
|
1409
1450
|
|
|
1410
1451
|
function renderEmpty() {
|
|
1411
|
-
const enabled =
|
|
1452
|
+
const enabled = usableAgents();
|
|
1412
1453
|
const cwd = S.draftCwd ?? S.workspaceRoot ?? "";
|
|
1413
1454
|
fill(ui.msgInner,
|
|
1414
1455
|
h(
|
|
@@ -1838,7 +1879,8 @@ function upsertInteraction(ix) {
|
|
|
1838
1879
|
}
|
|
1839
1880
|
|
|
1840
1881
|
function notifyIfHidden(ix) {
|
|
1841
|
-
|
|
1882
|
+
// 桌面端由服务端的 notice 统一弹,这里再弹就重复了
|
|
1883
|
+
if (desktop || document.visibilityState === "visible" || !("Notification" in window)) return;
|
|
1842
1884
|
if (Notification.permission === "granted") {
|
|
1843
1885
|
try {
|
|
1844
1886
|
new Notification("Agent Loft 需要你处理", { body: ix.spec.title, tag: ix.id });
|
|
@@ -2352,7 +2394,7 @@ function oneLine(text, max = 80) {
|
|
|
2352
2394
|
|
|
2353
2395
|
function currentAgentId() {
|
|
2354
2396
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
2355
|
-
return c?.agent ?? S.draftAgent ??
|
|
2397
|
+
return c?.agent ?? S.draftAgent ?? usableAgents()[0]?.id ?? "claude";
|
|
2356
2398
|
}
|
|
2357
2399
|
|
|
2358
2400
|
async function loadCommands(agent) {
|
|
@@ -2563,14 +2605,14 @@ async function switchAgent(id) {
|
|
|
2563
2605
|
await doAction("agent", id);
|
|
2564
2606
|
}
|
|
2565
2607
|
|
|
2566
|
-
function modal({ title, body, foot, wide }) {
|
|
2608
|
+
function modal({ title, body, foot, wide, tall }) {
|
|
2567
2609
|
const close = () => overlay.remove();
|
|
2568
2610
|
const overlay = h(
|
|
2569
2611
|
"div",
|
|
2570
2612
|
{ class: "overlay", onclick: (e) => e.target === overlay && close() },
|
|
2571
2613
|
h(
|
|
2572
2614
|
"div",
|
|
2573
|
-
{ class: `modal${wide ? " wide" : ""}` },
|
|
2615
|
+
{ class: `modal${wide ? " wide" : ""}${tall ? " tall" : ""}` },
|
|
2574
2616
|
h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: close }, icon("x"))),
|
|
2575
2617
|
body,
|
|
2576
2618
|
foot ?? null,
|
|
@@ -3015,7 +3057,8 @@ async function deleteConv(key = S.current) {
|
|
|
3015
3057
|
async function openSettings() {
|
|
3016
3058
|
const nav = h("div", { class: "settings-nav" });
|
|
3017
3059
|
const panel = h("div", { class: "settings-body" });
|
|
3018
|
-
|
|
3060
|
+
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
3061
|
+
const close = modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
3019
3062
|
let cfg;
|
|
3020
3063
|
try {
|
|
3021
3064
|
cfg = await api("/api/config");
|
|
@@ -3047,6 +3090,7 @@ async function openSettings() {
|
|
|
3047
3090
|
function pushSupport() {
|
|
3048
3091
|
const ios = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
3049
3092
|
const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
|
|
3093
|
+
if (desktop) return { ok: false, why: "桌面端会直接弹出 macOS 系统通知,不需要在这里开启。" };
|
|
3050
3094
|
if (!window.isSecureContext) return { ok: false, why: "通知需要 HTTPS 访问(localhost 除外)。请通过 HTTPS 的反向代理地址打开。" };
|
|
3051
3095
|
if (ios && !standalone) {
|
|
3052
3096
|
return { ok: false, why: "iPhone / iPad 上要先在 Safari 里点「分享 → 添加到主屏幕」,再从桌面图标打开,才能开启通知(需 iOS 16.4+)。" };
|
|
@@ -3197,7 +3241,61 @@ function field(label, input, hint) {
|
|
|
3197
3241
|
return h("label", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
|
|
3198
3242
|
}
|
|
3199
3243
|
|
|
3244
|
+
/** 服务端版本:有新版就能一键升级(npm install -g,系统服务下自动重启) */
|
|
3245
|
+
function serverVersionSection() {
|
|
3246
|
+
const box = h("div", { class: "section" });
|
|
3247
|
+
const load = async (refresh) => {
|
|
3248
|
+
fill(box, h("h4", {}, "服务端版本", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
3249
|
+
let v;
|
|
3250
|
+
try {
|
|
3251
|
+
v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
|
|
3252
|
+
} catch (err) {
|
|
3253
|
+
fill(box, h("h4", {}, "服务端版本"), h("div", { class: "error-text" }, err.message));
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "检查更新");
|
|
3257
|
+
const status = v.updateAvailable
|
|
3258
|
+
? `有新版本 ${v.latest}`
|
|
3259
|
+
: v.latest
|
|
3260
|
+
? "已是最新版本"
|
|
3261
|
+
: `查不到最新版本${v.latestError ? `:${v.latestError}` : ""}`;
|
|
3262
|
+
const upgradeBtn =
|
|
3263
|
+
v.updateAvailable && v.canUpgrade
|
|
3264
|
+
? h(
|
|
3265
|
+
"button",
|
|
3266
|
+
{
|
|
3267
|
+
class: "btn small primary",
|
|
3268
|
+
onclick: async () => {
|
|
3269
|
+
upgradeBtn.disabled = true;
|
|
3270
|
+
upgradeBtn.textContent = "正在升级…";
|
|
3271
|
+
try {
|
|
3272
|
+
const r = await api("/api/server/upgrade", { method: "POST" });
|
|
3273
|
+
toast(r.restarting ? `已升级到 ${r.to},服务正在重启,页面会自动重连` : `已升级到 ${r.to},重启 agent-loft 后生效`, 6000);
|
|
3274
|
+
void load(false);
|
|
3275
|
+
} catch (err) {
|
|
3276
|
+
toast(`升级失败:${err.message}`, 6000);
|
|
3277
|
+
upgradeBtn.disabled = false;
|
|
3278
|
+
upgradeBtn.textContent = "升级";
|
|
3279
|
+
}
|
|
3280
|
+
},
|
|
3281
|
+
},
|
|
3282
|
+
"升级",
|
|
3283
|
+
)
|
|
3284
|
+
: null;
|
|
3285
|
+
fill(
|
|
3286
|
+
box,
|
|
3287
|
+
h("h4", {}, "服务端版本", h("span", { class: "grow" }), upgradeBtn, recheck),
|
|
3288
|
+
h("div", { class: "health-meta" }, `当前 ${v.version} · ${status}`),
|
|
3289
|
+
v.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "health-meta" }, v.reason) : null,
|
|
3290
|
+
v.updateAvailable && v.canUpgrade && !v.service ? h("div", { class: "health-meta" }, "agent-loft 是在前台手动运行的,升级后要自己重启一下。") : null,
|
|
3291
|
+
);
|
|
3292
|
+
};
|
|
3293
|
+
void load(false);
|
|
3294
|
+
return box;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3200
3297
|
function generalTab(cfg, panel) {
|
|
3298
|
+
panel.append(serverVersionSection());
|
|
3201
3299
|
const root = h("input", { value: cfg.workspaceRoot });
|
|
3202
3300
|
const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
|
|
3203
3301
|
const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
|
|
@@ -3678,7 +3776,93 @@ function modelChecklist(agent, selected) {
|
|
|
3678
3776
|
};
|
|
3679
3777
|
}
|
|
3680
3778
|
|
|
3779
|
+
/** Agent 运行环境:装没装、登没登录;没好的给出在终端里执行的命令 */
|
|
3780
|
+
function agentHealthSection() {
|
|
3781
|
+
const box = h("div", { class: "section" });
|
|
3782
|
+
const load = async (refresh) => {
|
|
3783
|
+
const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "重新检查");
|
|
3784
|
+
fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
3785
|
+
let data;
|
|
3786
|
+
try {
|
|
3787
|
+
data = await api(`/api/agents/health${refresh ? "?refresh=1" : ""}`);
|
|
3788
|
+
} catch (err) {
|
|
3789
|
+
fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck), h("div", { class: "error-text" }, err.message));
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
// 体检结果顺手同步到新对话的 agent 列表:刚装好 / 登录好的立刻能选,不行的立刻消失
|
|
3793
|
+
for (const a of data.agents) {
|
|
3794
|
+
const known = S.agents.find((x) => x.id === a.id);
|
|
3795
|
+
if (known) known.available = a.installed && a.loggedIn !== false;
|
|
3796
|
+
}
|
|
3797
|
+
if (!usableAgents().some((a) => a.id === S.draftAgent)) S.draftAgent = usableAgents()[0]?.id ?? null;
|
|
3798
|
+
if (!S.current) renderEmpty();
|
|
3799
|
+
fill(
|
|
3800
|
+
box,
|
|
3801
|
+
h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck),
|
|
3802
|
+
...data.agents.map((a) => {
|
|
3803
|
+
const ok = a.installed && a.loggedIn === true;
|
|
3804
|
+
const state = !a.installed ? "未安装" : a.loggedIn === false ? "未登录" : a.loggedIn === null ? "无法确认" : "正常";
|
|
3805
|
+
return h(
|
|
3806
|
+
"div",
|
|
3807
|
+
{ class: "health-row" },
|
|
3808
|
+
h(
|
|
3809
|
+
"div",
|
|
3810
|
+
{ class: "health-head" },
|
|
3811
|
+
h("span", { class: `health-dot ${ok ? "ok" : "bad"}` }),
|
|
3812
|
+
h("b", {}, a.label),
|
|
3813
|
+
h("span", { class: "health-state" }, state),
|
|
3814
|
+
a.enabled ? null : h("span", { class: "health-off" }, "(未启用)"),
|
|
3815
|
+
),
|
|
3816
|
+
a.account || a.version ? h("div", { class: "health-meta" }, [a.account, a.version].filter(Boolean).join(" · ")) : null,
|
|
3817
|
+
a.problem ? h("div", { class: "health-meta" }, a.problem) : null,
|
|
3818
|
+
a.fix
|
|
3819
|
+
? h(
|
|
3820
|
+
"div",
|
|
3821
|
+
{ class: "health-fix" },
|
|
3822
|
+
h("div", {}, `${a.fix.title}:在运行 agent-loft 的机器上打开终端执行${a.fix.commands.length > 1 ? "(任选一条)" : ""}`),
|
|
3823
|
+
a.fix.commands.map((cmd) => {
|
|
3824
|
+
const copy = h("button", { class: "btn small", onclick: () => copyText(cmd, copy) }, "复制");
|
|
3825
|
+
return h("div", { class: "health-cmd" }, h("code", {}, cmd), copy);
|
|
3826
|
+
}),
|
|
3827
|
+
a.fix.note ? h("small", {}, a.fix.note) : null,
|
|
3828
|
+
a.id === "codex" && a.fix.action === "install" ? installCodexButton(() => load(true)) : null,
|
|
3829
|
+
)
|
|
3830
|
+
: null,
|
|
3831
|
+
);
|
|
3832
|
+
}),
|
|
3833
|
+
);
|
|
3834
|
+
};
|
|
3835
|
+
void load(false);
|
|
3836
|
+
return box;
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
/** 直接在服务端那台机器上装 Codex(Homebrew 或 npm),装完重新检查 */
|
|
3840
|
+
function installCodexButton(after) {
|
|
3841
|
+
const btn = h(
|
|
3842
|
+
"button",
|
|
3843
|
+
{
|
|
3844
|
+
class: "btn small primary",
|
|
3845
|
+
onclick: async () => {
|
|
3846
|
+
btn.disabled = true;
|
|
3847
|
+
btn.textContent = "正在安装,可能要几分钟…";
|
|
3848
|
+
try {
|
|
3849
|
+
const r = await api("/api/agents/codex/install", { method: "POST" });
|
|
3850
|
+
toast(r.ok ? `已用 ${r.via} 装好 Codex` : `安装失败:${r.output.slice(-200)}`, 6000);
|
|
3851
|
+
after();
|
|
3852
|
+
} catch (err) {
|
|
3853
|
+
toast(`安装失败:${err.message}`, 6000);
|
|
3854
|
+
btn.disabled = false;
|
|
3855
|
+
btn.textContent = "直接在服务器上安装";
|
|
3856
|
+
}
|
|
3857
|
+
},
|
|
3858
|
+
},
|
|
3859
|
+
"直接在服务器上安装",
|
|
3860
|
+
);
|
|
3861
|
+
return h("div", {}, btn);
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3681
3864
|
function agentsTab(cfg, panel) {
|
|
3865
|
+
panel.append(agentHealthSection());
|
|
3682
3866
|
const a = structuredClone(cfg.agents);
|
|
3683
3867
|
const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
|
|
3684
3868
|
const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
|
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;
|
|
@@ -3152,3 +3161,75 @@ button.list-item:hover {
|
|
|
3152
3161
|
.lightbox-dl:hover {
|
|
3153
3162
|
background: rgba(255, 255, 255, 0.26);
|
|
3154
3163
|
}
|
|
3164
|
+
|
|
3165
|
+
/* 设置 → Agent → 运行环境 */
|
|
3166
|
+
.health-row {
|
|
3167
|
+
display: flex;
|
|
3168
|
+
flex-direction: column;
|
|
3169
|
+
gap: 6px;
|
|
3170
|
+
}
|
|
3171
|
+
.health-row + .health-row {
|
|
3172
|
+
border-top: 1px solid var(--border);
|
|
3173
|
+
padding-top: 12px;
|
|
3174
|
+
}
|
|
3175
|
+
.health-head {
|
|
3176
|
+
display: flex;
|
|
3177
|
+
align-items: center;
|
|
3178
|
+
gap: 8px;
|
|
3179
|
+
}
|
|
3180
|
+
.health-dot {
|
|
3181
|
+
width: 8px;
|
|
3182
|
+
height: 8px;
|
|
3183
|
+
border-radius: 50%;
|
|
3184
|
+
background: var(--danger);
|
|
3185
|
+
flex: none;
|
|
3186
|
+
}
|
|
3187
|
+
.health-dot.ok {
|
|
3188
|
+
background: var(--ok);
|
|
3189
|
+
}
|
|
3190
|
+
.health-state {
|
|
3191
|
+
color: var(--fg-soft);
|
|
3192
|
+
}
|
|
3193
|
+
.health-off,
|
|
3194
|
+
.health-meta {
|
|
3195
|
+
color: var(--fg-faint);
|
|
3196
|
+
font-size: 12px;
|
|
3197
|
+
}
|
|
3198
|
+
.health-fix {
|
|
3199
|
+
display: flex;
|
|
3200
|
+
flex-direction: column;
|
|
3201
|
+
gap: 6px;
|
|
3202
|
+
padding: 10px 12px;
|
|
3203
|
+
border-radius: 10px;
|
|
3204
|
+
background: var(--warn-bg);
|
|
3205
|
+
border: 1px solid var(--warn-border);
|
|
3206
|
+
font-size: 13px;
|
|
3207
|
+
}
|
|
3208
|
+
.health-fix small {
|
|
3209
|
+
color: var(--fg-soft);
|
|
3210
|
+
}
|
|
3211
|
+
.health-cmd {
|
|
3212
|
+
display: flex;
|
|
3213
|
+
align-items: center;
|
|
3214
|
+
gap: 8px;
|
|
3215
|
+
}
|
|
3216
|
+
.health-cmd code {
|
|
3217
|
+
flex: 1;
|
|
3218
|
+
min-width: 0;
|
|
3219
|
+
overflow-x: auto;
|
|
3220
|
+
white-space: nowrap;
|
|
3221
|
+
padding: 5px 8px;
|
|
3222
|
+
border-radius: 6px;
|
|
3223
|
+
background: var(--code-bg);
|
|
3224
|
+
font-size: 12px;
|
|
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
|
+
}
|