agent-loft 0.2.26 → 0.2.28
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 +4 -4
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +1 -1
- package/src/agents/codex/driver.ts +1 -1
- package/src/channels/web/api.ts +7 -0
- package/src/core/config.ts +3 -1
- package/src/core/memory-curator.ts +15 -3
- package/src/core/memory.ts +7 -1
- package/web/app.js +65 -32
- package/web/index.html +1 -1
- package/web/style.css +48 -8
package/README.md
CHANGED
|
@@ -40,14 +40,14 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
40
40
|
- 思考和工具调用显示在实时刷新的过程卡片里,最终回复是普通消息。
|
|
41
41
|
- 陌生人发消息会拿到配对码,在设置页批准后才能使用;每个机器人单独授权。
|
|
42
42
|
- **Web 工作台**:电脑和手机都能用,界面参照 ChatGPT。
|
|
43
|
-
- 回复逐字显示,markdown
|
|
43
|
+
- 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动);自己发的消息也能一键复制。
|
|
44
44
|
- 授权卡、提问卡直接在页面上点。
|
|
45
45
|
- 附件:粘贴、拖拽,手机上可以拍照;可以预览和下载。附件存在服务端的专用上传目录里,开新会话或切换目录时清理。
|
|
46
46
|
- 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
|
|
47
47
|
- 新对话页:点大标题里的目录名,在目录树里逐层选工作目录;输入框上方选 agent,右下角选模型和思考程度。会话进行中用 `/agent` 切换 agent,模型和思考程度随时可换。
|
|
48
48
|
- 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
|
|
49
49
|
- 滚动和 ChatGPT 一样:发出的问题顶到最上面、下面留白,回复长到屏幕底部才跟着往下滚;回复过程中自己滚动了就不再自动滚,下次发消息再恢复。
|
|
50
|
-
-
|
|
50
|
+
- 定时发送:右上角「更多」→「定时发送」,默认排在 5 小时额度重置后 5 分钟(弹窗里提示 5 小时 / 7 天额度的重置时间),由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
|
|
51
51
|
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
|
|
52
52
|
- 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
|
|
53
53
|
- 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
|
|
@@ -56,8 +56,8 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
56
56
|
- **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
|
|
57
57
|
- **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
|
|
58
58
|
- **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
|
|
59
|
-
- **聊天模式 /
|
|
60
|
-
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4
|
|
59
|
+
- **聊天模式 / 工作模式**:右上角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
|
|
60
|
+
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
|
|
61
61
|
- **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
|
|
62
62
|
|
|
63
63
|
## 安装
|
package/package.json
CHANGED
|
@@ -224,7 +224,7 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
async statusLines(): Promise<string[]> {
|
|
227
|
-
return describeUsage(await readUsage());
|
|
227
|
+
return config().web.quotaEnabled ? describeUsage(await readUsage()) : [];
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
async quota(): Promise<QuotaInfo | null> {
|
|
@@ -212,7 +212,7 @@ export class CodexDriver implements AgentDriver {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
async statusLines(): Promise<string[]> {
|
|
215
|
-
const quota = describeRateLimits(await readRateLimits());
|
|
215
|
+
const quota = config().web.quotaEnabled ? describeRateLimits(await readRateLimits()) : [];
|
|
216
216
|
return [`沙箱:${config().agents.codex.sandbox}`, ...(quota.length > 0 ? ["", ...quota] : [])];
|
|
217
217
|
}
|
|
218
218
|
|
package/src/channels/web/api.ts
CHANGED
|
@@ -218,6 +218,7 @@ function applyConfig(input: Partial<AppConfig>): void {
|
|
|
218
218
|
if (Number.isInteger(Number(port)) && Number(port) >= 1 && Number(port) <= 65535) next.web.port = Number(port);
|
|
219
219
|
const q = Number(next.web.quotaRefreshMin);
|
|
220
220
|
next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
|
|
221
|
+
next.web.quotaEnabled = next.web.quotaEnabled !== false;
|
|
221
222
|
}
|
|
222
223
|
if (input.commands) {
|
|
223
224
|
// 只收字符串数组,别的一律当没传
|
|
@@ -484,6 +485,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
484
485
|
bots: admin ? config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })) : [],
|
|
485
486
|
workspaceRoot: config().workspaceRoot,
|
|
486
487
|
quotaRefreshMin: config().web.quotaRefreshMin,
|
|
488
|
+
quotaEnabled: config().web.quotaEnabled,
|
|
487
489
|
conversations: hub.list(visible),
|
|
488
490
|
pending: interactions.listPending().filter((i) => visible(getConversation(i.convKey))),
|
|
489
491
|
user: publicMe(user),
|
|
@@ -783,6 +785,11 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
783
785
|
}
|
|
784
786
|
if (p === "/api/quota" && method === "GET") {
|
|
785
787
|
// ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
|
|
788
|
+
// 设置里关了查额度:不去问任何 agent
|
|
789
|
+
if (!config().web.quotaEnabled) {
|
|
790
|
+
sendJson(res, 200, { quotas: [], at: Date.now(), disabled: true });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
786
793
|
const only = url.searchParams.get("agent")?.split(",").filter(Boolean);
|
|
787
794
|
const results = await Promise.all(
|
|
788
795
|
hub.enabledAgents().filter((d) => !only || only.includes(d.id)).map(async (d) => {
|
package/src/core/config.ts
CHANGED
|
@@ -67,6 +67,8 @@ export type AppConfig = {
|
|
|
67
67
|
trustedProxies: string[];
|
|
68
68
|
/** 侧栏额度空闲时多久自动刷新一次(分钟),0 = 不自动刷新 */
|
|
69
69
|
quotaRefreshMin: number;
|
|
70
|
+
/** 查不查 agent 的账号额度。关了侧栏、/status、桌面端都不再去问 agent */
|
|
71
|
+
quotaEnabled: boolean;
|
|
70
72
|
};
|
|
71
73
|
/** 输入「/」时命令列表的顺序和隐藏项,按 agent 分开(设置 → 命令) */
|
|
72
74
|
commands: { claude: CommandPrefs; codex: CommandPrefs };
|
|
@@ -96,7 +98,7 @@ export function defaultConfig(): AppConfig {
|
|
|
96
98
|
models: [],
|
|
97
99
|
},
|
|
98
100
|
},
|
|
99
|
-
web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10 },
|
|
101
|
+
web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10, quotaEnabled: true },
|
|
100
102
|
commands: { claude: { order: [], hidden: [], custom: [] }, codex: { order: [], hidden: [], custom: [] } },
|
|
101
103
|
feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
|
|
102
104
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// 3. 整理:把上次整理之后新说的话交给模型(用户在记忆设置里选 agent 和模型,默认最省的),
|
|
9
9
|
// 让它对照现有记忆给出新增 / 更新 / 删除。用户自己加的条目它动不了。什么时候整理也由用户定
|
|
10
10
|
// (CurateSettings.when):每段聊完后 / 每天一次把当天的合起来 / 只在点「现在整理」时 / 不整理。
|
|
11
|
+
// 整理哪些也由用户定(CurateSettings.scope):全部 / 只整理聊天模式的 / 只整理工作模式的。
|
|
11
12
|
import { hub } from "./hub.js";
|
|
12
13
|
import {
|
|
13
14
|
appendRecords,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
readMemory,
|
|
28
29
|
removeMemoryItem,
|
|
29
30
|
updateMemoryItem,
|
|
31
|
+
type CurateScope,
|
|
30
32
|
type CurateSettings,
|
|
31
33
|
type UserMemory,
|
|
32
34
|
} from "./memory.js";
|
|
@@ -361,12 +363,18 @@ export async function curateBatch(
|
|
|
361
363
|
return log;
|
|
362
364
|
}
|
|
363
365
|
|
|
364
|
-
/**
|
|
366
|
+
/** 这段对话在不在用户选的整理范围里(按现在的模式算,中途切过模式的整段归现在的) */
|
|
367
|
+
function inScope(conv: { mode?: "chat" }, scope: CurateScope): boolean {
|
|
368
|
+
return scope === "all" || (scope === "chat") === (conv.mode === "chat");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 某个用户等着整理的对话。删掉的、不在整理范围里的直接记成整理过(之后改范围也不补整理);正在回复的这次先不碰 */
|
|
365
372
|
function collect(userId: string, settleMs: number, now?: number): Pending[] {
|
|
373
|
+
const scope = readMemory(userId).curate.scope;
|
|
366
374
|
const out: Pending[] = [];
|
|
367
375
|
for (const p of pendingConversations(userId, settleMs, now)) {
|
|
368
376
|
const conv = hub.conversation(p.key);
|
|
369
|
-
if (!conv || conv.hidden) {
|
|
377
|
+
if (!conv || conv.hidden || !inScope(conv, scope)) {
|
|
370
378
|
markExtracted(userId, p.key, p.last);
|
|
371
379
|
continue;
|
|
372
380
|
}
|
|
@@ -426,7 +434,11 @@ export function curateNow(userId: string, opts: { now?: number } = {}): Promise<
|
|
|
426
434
|
|
|
427
435
|
/** 还没整理的对话有几段(设置页显示「有 N 段对话等着整理」) */
|
|
428
436
|
export function pendingCount(userId: string): number {
|
|
429
|
-
|
|
437
|
+
const scope = readMemory(userId).curate.scope;
|
|
438
|
+
return pendingConversations(userId, 0).filter((p) => {
|
|
439
|
+
const conv = hub.conversation(p.key);
|
|
440
|
+
return conv && !conv.hidden && inScope(conv, scope);
|
|
441
|
+
}).length;
|
|
430
442
|
}
|
|
431
443
|
|
|
432
444
|
export function startMemoryCurator(): void {
|
package/src/core/memory.ts
CHANGED
|
@@ -44,8 +44,12 @@ export type UserMemory = {
|
|
|
44
44
|
*/
|
|
45
45
|
export type CurateWhen = "idle" | "daily" | "manual" | "off";
|
|
46
46
|
|
|
47
|
+
/** 整理哪些对话:全部 / 只整理聊天模式的 / 只整理工作模式的(按整理那一刻对话的模式算) */
|
|
48
|
+
export type CurateScope = "all" | "chat" | "work";
|
|
49
|
+
|
|
47
50
|
export type CurateSettings = {
|
|
48
51
|
when: CurateWhen;
|
|
52
|
+
scope: CurateScope;
|
|
49
53
|
idleMinutes: number;
|
|
50
54
|
dailyHour: number;
|
|
51
55
|
/** auto:Claude 能用就用 Claude,否则 Codex */
|
|
@@ -54,17 +58,19 @@ export type CurateSettings = {
|
|
|
54
58
|
model: string;
|
|
55
59
|
};
|
|
56
60
|
|
|
57
|
-
export const DEFAULT_CURATE: CurateSettings = { when: "daily", idleMinutes: 30, dailyHour: 4, agent: "auto", model: "" };
|
|
61
|
+
export const DEFAULT_CURATE: CurateSettings = { when: "daily", scope: "all", idleMinutes: 30, dailyHour: 4, agent: "auto", model: "" };
|
|
58
62
|
|
|
59
63
|
function cleanCurate(raw: Partial<CurateSettings> | undefined, base: CurateSettings): CurateSettings {
|
|
60
64
|
const r = raw ?? {};
|
|
61
65
|
const when = (["idle", "daily", "manual", "off"] as const).find((w) => w === r.when) ?? base.when;
|
|
66
|
+
const scope = (["all", "chat", "work"] as const).find((s) => s === r.scope) ?? base.scope;
|
|
62
67
|
const idle = Math.round(Number(r.idleMinutes ?? base.idleMinutes));
|
|
63
68
|
const hour = Math.round(Number(r.dailyHour ?? base.dailyHour));
|
|
64
69
|
const agent = (["auto", "claude", "codex"] as const).find((a) => a === r.agent) ?? base.agent;
|
|
65
70
|
const model = typeof r.model === "string" ? r.model.trim().slice(0, 100) : base.model;
|
|
66
71
|
return {
|
|
67
72
|
when,
|
|
73
|
+
scope,
|
|
68
74
|
idleMinutes: Number.isFinite(idle) ? Math.max(5, Math.min(24 * 60, idle)) : base.idleMinutes,
|
|
69
75
|
dailyHour: Number.isFinite(hour) ? Math.max(0, Math.min(23, hour)) : base.dailyHour,
|
|
70
76
|
agent,
|
package/web/app.js
CHANGED
|
@@ -38,6 +38,7 @@ const ICON = {
|
|
|
38
38
|
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
|
|
39
39
|
clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
|
|
40
40
|
mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
|
|
41
|
+
more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>',
|
|
41
42
|
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
|
42
43
|
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
|
|
43
44
|
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>',
|
|
@@ -574,6 +575,7 @@ async function boot() {
|
|
|
574
575
|
S.bots = data.bots;
|
|
575
576
|
S.workspaceRoot = data.workspaceRoot;
|
|
576
577
|
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
578
|
+
S.quotaEnabled = data.quotaEnabled !== false;
|
|
577
579
|
S.convs = new Map(data.conversations.map((c) => [c.key, c]));
|
|
578
580
|
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
579
581
|
const enabled = usableAgents().map((a) => a.id);
|
|
@@ -683,6 +685,8 @@ function renderLayout() {
|
|
|
683
685
|
h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
|
|
684
686
|
);
|
|
685
687
|
ui.topbar = h("div", { class: "topbar" });
|
|
688
|
+
// 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
|
|
689
|
+
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
686
690
|
ui.msgInner = h("div", { class: "messages-inner" });
|
|
687
691
|
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
688
692
|
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
@@ -746,14 +750,20 @@ function stopMarquee() {
|
|
|
746
750
|
// - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
|
|
747
751
|
// - 切回前台:距上次超过一个刷新间隔就拉;
|
|
748
752
|
// - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
|
|
753
|
+
// 设置 → 通用里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
|
|
749
754
|
// 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
|
|
750
755
|
// 查好推过来;点一下额度区域是请桌面端去刷新。
|
|
751
756
|
|
|
752
757
|
const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
|
|
753
758
|
const QUOTA_SERVER_CACHE = 60_000;
|
|
754
|
-
const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
|
|
759
|
+
const quotaInterval = () => (S.quotaEnabled ? (S.quotaRefreshMin ?? 10) * 60_000 : 0);
|
|
755
760
|
|
|
756
761
|
async function loadQuota() {
|
|
762
|
+
if (!S.quotaEnabled) {
|
|
763
|
+
quotaState.data = null;
|
|
764
|
+
renderQuota();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
757
767
|
if (desktopQuota) {
|
|
758
768
|
ui.quota?.classList.add("loading");
|
|
759
769
|
desktop.refreshQuota();
|
|
@@ -800,7 +810,7 @@ function scheduleIdleQuota() {
|
|
|
800
810
|
/** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
|
|
801
811
|
function scheduleQuota() {
|
|
802
812
|
// 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
|
|
803
|
-
if (desktopQuota) return;
|
|
813
|
+
if (desktopQuota || !S.quotaEnabled) return;
|
|
804
814
|
const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
|
|
805
815
|
clearTimeout(quotaState.timer);
|
|
806
816
|
quotaState.timer = setTimeout(() => void loadQuota(), wait);
|
|
@@ -828,7 +838,7 @@ function quotaLevel(p) {
|
|
|
828
838
|
|
|
829
839
|
function renderQuota() {
|
|
830
840
|
if (!ui.quota) return;
|
|
831
|
-
const list = quotaState.data ?? [];
|
|
841
|
+
const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
|
|
832
842
|
if (list.length === 0) return fill(ui.quota);
|
|
833
843
|
const rows = list.map((q) =>
|
|
834
844
|
h(
|
|
@@ -1290,10 +1300,22 @@ function renderTopbar() {
|
|
|
1290
1300
|
fill(ui.topbar,
|
|
1291
1301
|
h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
|
|
1292
1302
|
h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
|
|
1303
|
+
ui.modeSlot,
|
|
1304
|
+
h("button", { class: "icon-btn top-more", title: "更多", onclick: (e) => openTopMore(e.currentTarget) }, icon("more")),
|
|
1293
1305
|
);
|
|
1294
1306
|
renderContext();
|
|
1295
1307
|
}
|
|
1296
1308
|
|
|
1309
|
+
/** 顶栏右上角「更多」 */
|
|
1310
|
+
function openTopMore(anchor) {
|
|
1311
|
+
if (ui.menu?.anchor === anchor) return closeMenu();
|
|
1312
|
+
showMenu(anchor, [{ label: "定时发送…", onclick: () => openSchedule() }]);
|
|
1313
|
+
ui.menu.anchor = anchor;
|
|
1314
|
+
// 贴着按钮右边对齐,别超出屏幕
|
|
1315
|
+
const r = anchor.getBoundingClientRect();
|
|
1316
|
+
ui.menu.style.left = `${Math.max(8, r.right - ui.menu.offsetWidth)}px`;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1297
1319
|
function baseName(p) {
|
|
1298
1320
|
return (p ?? "").split(/[\\/]/).filter(Boolean).pop() || p || "";
|
|
1299
1321
|
}
|
|
@@ -2024,6 +2046,9 @@ function renderItem(item) {
|
|
|
2024
2046
|
!item.text && origin ? h("span", { class: "origin" }, origin) : null,
|
|
2025
2047
|
renderUserFiles(item.files),
|
|
2026
2048
|
item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
|
|
2049
|
+
item.text
|
|
2050
|
+
? h("div", { class: "user-actions" }, h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(item.text) }, icon("copy")))
|
|
2051
|
+
: null,
|
|
2027
2052
|
);
|
|
2028
2053
|
}
|
|
2029
2054
|
case "ai":
|
|
@@ -2279,10 +2304,10 @@ function renderInteraction(item) {
|
|
|
2279
2304
|
: h(
|
|
2280
2305
|
"div",
|
|
2281
2306
|
{ class: "ix-buttons" },
|
|
2282
|
-
h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
|
|
2283
2307
|
spec.allowSession
|
|
2284
|
-
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
|
|
2308
|
+
? h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
|
|
2285
2309
|
: null,
|
|
2310
|
+
h("button", { class: `btn${spec.allowSession ? "" : " accent"}`, onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
|
|
2286
2311
|
// 会落盘、对所有人生效的「总是允许」只给管理员
|
|
2287
2312
|
spec.alwaysLabel && (!spec.alwaysGlobal || isAdmin())
|
|
2288
2313
|
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
|
|
@@ -2380,7 +2405,6 @@ function renderComposer() {
|
|
|
2380
2405
|
: null;
|
|
2381
2406
|
ui.ctxBar = h("div", { class: "ctx-bar" });
|
|
2382
2407
|
ui.modelSlot = h("span", { class: "model-slot" });
|
|
2383
|
-
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
2384
2408
|
ui.ctxRing = h("span", { class: "ctx-ring-slot" });
|
|
2385
2409
|
ui.chips = h("div", { class: "chips" });
|
|
2386
2410
|
ui.schedBar = h("div", { class: "sched-bar", hidden: true });
|
|
@@ -2407,11 +2431,9 @@ function renderComposer() {
|
|
|
2407
2431
|
"div",
|
|
2408
2432
|
{ class: "composer-row" },
|
|
2409
2433
|
h("button", { class: "icon-btn add-btn", title: "添加附件", onclick: () => ui.fileInput.click() }, icon("plus")),
|
|
2410
|
-
h("button", { class: "icon-btn add-btn", title: "定时发送", onclick: () => openSchedule() }, icon("clock")),
|
|
2411
2434
|
ui.fileInput,
|
|
2412
2435
|
h("span", { class: "spacer" }),
|
|
2413
2436
|
ui.ctxRing,
|
|
2414
|
-
ui.modeSlot,
|
|
2415
2437
|
ui.modelSlot,
|
|
2416
2438
|
ui.micBtn,
|
|
2417
2439
|
ui.sendBtn,
|
|
@@ -2489,33 +2511,25 @@ function openSchedule(edit) {
|
|
|
2489
2511
|
const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
|
|
2490
2512
|
text.value = edit ? edit.text : ui.input.value;
|
|
2491
2513
|
const when = h("input", { type: "datetime-local", class: "sched-time" });
|
|
2492
|
-
|
|
2493
|
-
setAt(edit ? edit.at : Date.now() + 5 * 3600_000);
|
|
2494
|
-
// 快捷时间:当前 agent 的额度窗口重置后一分钟,再加几个常用的
|
|
2514
|
+
// 当前 agent 的额度窗口(5 小时 / 7 天)什么时候重置:小字提示,默认排在 5 小时窗口重置后 5 分钟
|
|
2495
2515
|
const agent = currentAgentId();
|
|
2496
|
-
const
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
if (w.resetsAt && w.resetsAt > Date.now()) presets.push({ label: `${w.label}重置后(${fmtWhen(w.resetsAt + 60_000)})`, at: w.resetsAt + 60_000 });
|
|
2501
|
-
}
|
|
2502
|
-
}
|
|
2503
|
-
const tomorrow9 = new Date();
|
|
2504
|
-
tomorrow9.setDate(tomorrow9.getDate() + 1);
|
|
2505
|
-
tomorrow9.setHours(9, 0, 0, 0);
|
|
2506
|
-
presets.push(
|
|
2507
|
-
{ label: "1 小时后", at: Date.now() + 3600_000 },
|
|
2508
|
-
{ label: "3 小时后", at: Date.now() + 3 * 3600_000 },
|
|
2509
|
-
{ label: "5 小时后", at: Date.now() + 5 * 3600_000 },
|
|
2510
|
-
{ label: "明早 9:00", at: tomorrow9.getTime() },
|
|
2511
|
-
);
|
|
2516
|
+
const windows = (quotaState.data ?? []).find((q) => q.agent === agent)?.windows ?? [];
|
|
2517
|
+
const resets = windows.filter((w) => /^(5h|7d)$/.test(w.short) && w.resetsAt > Date.now());
|
|
2518
|
+
const session = resets.find((w) => w.short === "5h");
|
|
2519
|
+
when.value = toLocalInput(edit ? edit.at : session ? session.resetsAt + 5 * 60_000 : Date.now() + 5 * 3600_000);
|
|
2512
2520
|
const body = h(
|
|
2513
2521
|
"div",
|
|
2514
2522
|
{ class: "modal-body sched-body" },
|
|
2515
2523
|
text,
|
|
2516
|
-
h("div", { class: "sched-presets" }, presets.map((p) => h("button", { class: "btn", onclick: () => setAt(p.at) }, p.label))),
|
|
2517
2524
|
h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
|
|
2518
|
-
h(
|
|
2525
|
+
h(
|
|
2526
|
+
"div",
|
|
2527
|
+
{ class: "muted sched-hint" },
|
|
2528
|
+
resets.length
|
|
2529
|
+
? `额度重置:${resets.map((w) => `${w.short === "5h" ? "5 小时" : "7 天"}额度 ${fmtWhen(w.resetsAt)}`).join(",")}。`
|
|
2530
|
+
: "",
|
|
2531
|
+
"到点由服务端发出,关掉页面也会发。",
|
|
2532
|
+
),
|
|
2519
2533
|
);
|
|
2520
2534
|
const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
|
|
2521
2535
|
const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
@@ -3828,9 +3842,14 @@ async function saveConfig(cfg, patch) {
|
|
|
3828
3842
|
S.agents = boot2.agents;
|
|
3829
3843
|
S.bots = boot2.bots;
|
|
3830
3844
|
S.workspaceRoot = boot2.workspaceRoot;
|
|
3831
|
-
|
|
3845
|
+
const quotaOn = boot2.quotaEnabled !== false;
|
|
3846
|
+
if (boot2.quotaRefreshMin !== S.quotaRefreshMin || quotaOn !== S.quotaEnabled) {
|
|
3847
|
+
const turnedOn = quotaOn && !S.quotaEnabled;
|
|
3832
3848
|
S.quotaRefreshMin = boot2.quotaRefreshMin ?? 10;
|
|
3833
|
-
|
|
3849
|
+
S.quotaEnabled = quotaOn;
|
|
3850
|
+
// 刚打开就马上查一次;关掉就清掉侧栏的额度
|
|
3851
|
+
if (turnedOn || !quotaOn) void loadQuota();
|
|
3852
|
+
else scheduleIdleQuota();
|
|
3834
3853
|
}
|
|
3835
3854
|
renderTopbar();
|
|
3836
3855
|
renderSidebar();
|
|
@@ -3904,6 +3923,7 @@ function generalTab(cfg, panel) {
|
|
|
3904
3923
|
const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
|
|
3905
3924
|
const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
|
|
3906
3925
|
const quotaMin = h("input", { type: "number", min: 0, max: 1440, value: cfg.web?.quotaRefreshMin ?? 10 });
|
|
3926
|
+
const quotaOn = h("input", { type: "checkbox", checked: cfg.web?.quotaEnabled !== false });
|
|
3907
3927
|
panel.append(
|
|
3908
3928
|
h(
|
|
3909
3929
|
"div",
|
|
@@ -3912,6 +3932,8 @@ function generalTab(cfg, panel) {
|
|
|
3912
3932
|
field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
|
|
3913
3933
|
field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
|
|
3914
3934
|
field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
|
|
3935
|
+
h("label", { class: "check" }, quotaOn, "查询账号额度"),
|
|
3936
|
+
h("div", { class: "muted" }, "关掉后侧栏、/status、桌面端都不再去问 agent 的额度(Claude 每次要临时起一个 CLI 进程才查得到)"),
|
|
3915
3937
|
field(
|
|
3916
3938
|
"额度自动刷新间隔(分钟)",
|
|
3917
3939
|
quotaMin,
|
|
@@ -3931,7 +3953,7 @@ function generalTab(cfg, panel) {
|
|
|
3931
3953
|
workspaceRoot: root.value,
|
|
3932
3954
|
approvalTimeoutMs: Number(approval.value) * 60000,
|
|
3933
3955
|
sessionTtlMs: Number(ttl.value) * 3600000,
|
|
3934
|
-
web: { quotaRefreshMin: Number(quotaMin.value) },
|
|
3956
|
+
web: { quotaRefreshMin: Number(quotaMin.value), quotaEnabled: quotaOn.checked },
|
|
3935
3957
|
}),
|
|
3936
3958
|
},
|
|
3937
3959
|
"保存",
|
|
@@ -5175,6 +5197,16 @@ function curateSection(mem, reload) {
|
|
|
5175
5197
|
],
|
|
5176
5198
|
(v) => save({ when: v }),
|
|
5177
5199
|
);
|
|
5200
|
+
// 换了范围,等着整理的段数也跟着变:重新读一遍
|
|
5201
|
+
const scope = select(
|
|
5202
|
+
c.scope,
|
|
5203
|
+
[
|
|
5204
|
+
["all", "全部对话"],
|
|
5205
|
+
["chat", "只整理聊天模式的对话"],
|
|
5206
|
+
["work", "只整理工作模式的对话"],
|
|
5207
|
+
],
|
|
5208
|
+
(v) => save({ scope: v }).then(reload),
|
|
5209
|
+
);
|
|
5178
5210
|
const timing =
|
|
5179
5211
|
c.when === "idle"
|
|
5180
5212
|
? field("停下来多久算聊完", select(c.idleMinutes, [10, 30, 60, 120, 240].map((m) => [m, m < 60 ? `${m} 分钟` : `${m / 60} 小时`]), (v) => save({ idleMinutes: Number(v) })))
|
|
@@ -5252,6 +5284,7 @@ function curateSection(mem, reload) {
|
|
|
5252
5284
|
}[c.when],
|
|
5253
5285
|
),
|
|
5254
5286
|
timing,
|
|
5287
|
+
c.when === "off" ? null : field("整理哪些对话", scope, "按整理时对话所在的模式算;不在范围里的对话直接跳过,之后改回来也不会补整理"),
|
|
5255
5288
|
c.when === "off" ? null : field("用哪个 agent", agent),
|
|
5256
5289
|
c.when === "off" ? null : model,
|
|
5257
5290
|
h("div", { class: "row", style: { alignItems: "center", gap: "10px" } }, nowBtn, h("span", { class: "muted" }, `${mem.pending ? `有 ${mem.pending} 段对话等着整理。` : ""}${lastText}`)),
|
package/web/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="zh-CN">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
6
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
|
7
7
|
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
|
|
8
8
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
package/web/style.css
CHANGED
|
@@ -1682,6 +1682,35 @@ a.att-file:hover {
|
|
|
1682
1682
|
}
|
|
1683
1683
|
}
|
|
1684
1684
|
|
|
1685
|
+
/* 用户消息下面的复制:hover 时才出来,和回复的操作栏一样用 opacity 占着位置 */
|
|
1686
|
+
.user-actions {
|
|
1687
|
+
display: flex;
|
|
1688
|
+
margin-top: -4px;
|
|
1689
|
+
opacity: 0;
|
|
1690
|
+
transition: opacity 0.15s;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
.msg-user-group:hover .user-actions,
|
|
1694
|
+
.user-actions:focus-within {
|
|
1695
|
+
opacity: 1;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
@media (hover: none) {
|
|
1699
|
+
.user-actions {
|
|
1700
|
+
opacity: 1;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
.user-actions .icon-btn {
|
|
1705
|
+
width: 26px;
|
|
1706
|
+
height: 26px;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
.user-actions .icon-btn svg {
|
|
1710
|
+
width: 15px;
|
|
1711
|
+
height: 15px;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1685
1714
|
.ai-actions .footer-note {
|
|
1686
1715
|
margin-left: 4px;
|
|
1687
1716
|
}
|
|
@@ -2038,6 +2067,16 @@ button.ctx-item:hover {
|
|
|
2038
2067
|
color: var(--accent);
|
|
2039
2068
|
}
|
|
2040
2069
|
|
|
2070
|
+
.topbar .mode-slot {
|
|
2071
|
+
display: inline-flex;
|
|
2072
|
+
flex-shrink: 0;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
.topbar .top-more svg {
|
|
2076
|
+
width: 20px;
|
|
2077
|
+
height: 20px;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2041
2080
|
.composer.drag {
|
|
2042
2081
|
border-color: var(--accent);
|
|
2043
2082
|
}
|
|
@@ -3432,14 +3471,9 @@ button.list-item:hover {
|
|
|
3432
3471
|
font: inherit;
|
|
3433
3472
|
}
|
|
3434
3473
|
|
|
3435
|
-
.sched-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
gap: 6px;
|
|
3439
|
-
}
|
|
3440
|
-
|
|
3441
|
-
.sched-presets .btn {
|
|
3442
|
-
font-size: 13px;
|
|
3474
|
+
.sched-hint {
|
|
3475
|
+
font-size: 12px;
|
|
3476
|
+
line-height: 1.6;
|
|
3443
3477
|
}
|
|
3444
3478
|
|
|
3445
3479
|
.sched-row {
|
|
@@ -3458,3 +3492,9 @@ button.list-item:hover {
|
|
|
3458
3492
|
font: inherit;
|
|
3459
3493
|
color-scheme: light dark;
|
|
3460
3494
|
}
|
|
3495
|
+
|
|
3496
|
+
/* iOS 上双击会放大页面:关掉双击缩放(双指缩放照常) */
|
|
3497
|
+
html,
|
|
3498
|
+
body {
|
|
3499
|
+
touch-action: manipulation;
|
|
3500
|
+
}
|