agent-loft 0.2.25 → 0.2.27
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 +3 -1
- package/package.json +1 -1
- package/src/channels/web/api.ts +26 -0
- package/src/core/hub.ts +7 -0
- package/src/core/scheduled.ts +113 -0
- package/src/main.ts +8 -0
- package/web/app.js +234 -22
- package/web/index.html +1 -1
- package/web/style.css +150 -0
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
46
46
|
- 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
|
|
47
47
|
- 新对话页:点大标题里的目录名,在目录树里逐层选工作目录;输入框上方选 agent,右下角选模型和思考程度。会话进行中用 `/agent` 切换 agent,模型和思考程度随时可换。
|
|
48
48
|
- 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
|
|
49
|
+
- 滚动和 ChatGPT 一样:发出的问题顶到最上面、下面留白,回复长到屏幕底部才跟着往下滚;回复过程中自己滚动了就不再自动滚,下次发消息再恢复。
|
|
50
|
+
- 定时发送:右上角「更多」→「定时发送」,默认排在 5 小时额度重置后 5 分钟(弹窗里提示 5 小时 / 7 天额度的重置时间),由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
|
|
49
51
|
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
|
|
50
52
|
- 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
|
|
51
53
|
- 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
|
|
@@ -54,7 +56,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
54
56
|
- **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
|
|
55
57
|
- **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
|
|
56
58
|
- **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
|
|
57
|
-
- **聊天模式 /
|
|
59
|
+
- **聊天模式 / 工作模式**:右上角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
|
|
58
60
|
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
|
|
59
61
|
- **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
|
|
60
62
|
|
package/package.json
CHANGED
package/src/channels/web/api.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { hub } from "../../core/hub.js";
|
|
|
9
9
|
import { interactions } from "../../core/interactions.js";
|
|
10
10
|
import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
|
|
11
11
|
import { getConversation, listConversations, updateConversation } from "../../core/store.js";
|
|
12
|
+
import { addScheduled, clearScheduled, removeScheduled } from "../../core/scheduled.js";
|
|
12
13
|
import { resolveUploadFile, saveBuffer, sniff, uploadKeyOf } from "../../core/uploads.js";
|
|
13
14
|
import {
|
|
14
15
|
createUser,
|
|
@@ -577,10 +578,35 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
577
578
|
// 只是从列表里藏起来:agent 侧的会话记录还在,随时能从「历史会话」恢复
|
|
578
579
|
await hub.drop(key, "会话已删除");
|
|
579
580
|
updateConversation(key, { hidden: true });
|
|
581
|
+
clearScheduled(key);
|
|
580
582
|
hub.emitEvent(key, { type: "meta" });
|
|
581
583
|
sendJson(res, 200, { ok: true });
|
|
582
584
|
return;
|
|
583
585
|
}
|
|
586
|
+
// ----- 定时发送:列表随会话信息(info.scheduled)下发,这里只管增删 -----
|
|
587
|
+
if (sub === "scheduled" && method === "POST") {
|
|
588
|
+
const body = await readJsonBody<{ text?: string; at?: number }>(req);
|
|
589
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
590
|
+
const at = Number(body.at);
|
|
591
|
+
if (!text.trim()) throw new HttpError(400, "定时发送的内容不能为空");
|
|
592
|
+
if (!Number.isFinite(at) || at < Date.now() - 60_000) throw new HttpError(400, "发送时间要在现在之后");
|
|
593
|
+
if (at > Date.now() + 30 * 86400_000) throw new HttpError(400, "最多只能排到 30 天以后");
|
|
594
|
+
try {
|
|
595
|
+
addScheduled(key, text, at);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
598
|
+
}
|
|
599
|
+
hub.emitEvent(key, { type: "meta" });
|
|
600
|
+
sendJson(res, 200, hub.info(key));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (sub === "scheduled" && method === "DELETE") {
|
|
604
|
+
const id = url.searchParams.get("id") ?? "";
|
|
605
|
+
if (!removeScheduled(key, id)) throw new HttpError(404, "这条定时消息已经发出或取消了");
|
|
606
|
+
hub.emitEvent(key, { type: "meta" });
|
|
607
|
+
sendJson(res, 200, hub.info(key));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
584
610
|
if (sub === "history" && method === "GET") {
|
|
585
611
|
// 读历史是异步的。要保证「历史快照 + 进行中这一轮的事件 + 之后的补发」首尾相接、
|
|
586
612
|
// 不重不漏,只在两种情况下接受这次读到的快照:
|
package/src/core/hub.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type {
|
|
|
45
45
|
} from "./types.js";
|
|
46
46
|
import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
47
47
|
import { helpText, type ParsedCommand } from "./commands.js";
|
|
48
|
+
import { listScheduled } from "./scheduled.js";
|
|
48
49
|
|
|
49
50
|
// ---------- 事件 ----------
|
|
50
51
|
|
|
@@ -138,6 +139,8 @@ export type ConversationInfo = {
|
|
|
138
139
|
mode: ConversationMode;
|
|
139
140
|
/** 这条对话有主人、会用上主人的记忆(主人自己关了记忆的话实际不会注入,见 personal.ts) */
|
|
140
141
|
personal?: boolean;
|
|
142
|
+
/** 排着的定时消息(见 core/scheduled.ts),按发送时间先后 */
|
|
143
|
+
scheduled?: { id: string; text: string; at: number }[];
|
|
141
144
|
};
|
|
142
145
|
|
|
143
146
|
/** 普通成员点了会落盘的「总是允许」 */
|
|
@@ -443,6 +446,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
443
446
|
...(this.drivers.get(conv.agent)?.forkAt ? { canFork: true } : {}),
|
|
444
447
|
mode: conv.mode ?? "work",
|
|
445
448
|
...(memoryOwnerOf(conv) ? { personal: true } : {}),
|
|
449
|
+
...(() => {
|
|
450
|
+
const list = listScheduled(key);
|
|
451
|
+
return list.length ? { scheduled: list.map(({ id, text, at }) => ({ id, text, at })) } : {};
|
|
452
|
+
})(),
|
|
446
453
|
};
|
|
447
454
|
}
|
|
448
455
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// 定时发送:额度用完了、要等刷新才能接着干活时,先把下一条消息排好,到点由服务端替用户发出去。
|
|
2
|
+
//
|
|
3
|
+
// 放在服务端而不是页面里:到点时浏览器可能早关了。落盘在 data/scheduled.json,重启后接着等;
|
|
4
|
+
// 服务停着的那段时间里到点的,启动后立刻补发。只支持纯文字(附件是临时上传的,放不了那么久)。
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { dataDir, readJson, writeJsonAtomic } from "../home.js";
|
|
8
|
+
|
|
9
|
+
export type ScheduledMessage = {
|
|
10
|
+
id: string;
|
|
11
|
+
key: string;
|
|
12
|
+
text: string;
|
|
13
|
+
/** 什么时候发(毫秒时间戳) */
|
|
14
|
+
at: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** 每条对话最多排几条 */
|
|
19
|
+
export const SCHEDULED_MAX = 20;
|
|
20
|
+
|
|
21
|
+
const storePath = path.join(dataDir, "scheduled.json");
|
|
22
|
+
let cache: ScheduledMessage[] | null = null;
|
|
23
|
+
let timer: NodeJS.Timeout | null = null;
|
|
24
|
+
let fire: ((m: ScheduledMessage) => void) | null = null;
|
|
25
|
+
|
|
26
|
+
function load(): ScheduledMessage[] {
|
|
27
|
+
cache ??= readJson<ScheduledMessage[]>(storePath, []).filter((m) => m && typeof m.text === "string" && Number.isFinite(m.at));
|
|
28
|
+
return cache;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function save(): void {
|
|
32
|
+
writeJsonAtomic(storePath, load());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listScheduled(key: string): ScheduledMessage[] {
|
|
36
|
+
return load()
|
|
37
|
+
.filter((m) => m.key === key)
|
|
38
|
+
.sort((a, b) => a.at - b.at);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addScheduled(key: string, text: string, at: number): ScheduledMessage {
|
|
42
|
+
if (!text.trim()) throw new Error("定时发送的内容不能为空");
|
|
43
|
+
if (!Number.isFinite(at)) throw new Error("时间不对");
|
|
44
|
+
if (listScheduled(key).length >= SCHEDULED_MAX) throw new Error(`这条对话最多排 ${SCHEDULED_MAX} 条定时消息`);
|
|
45
|
+
const item: ScheduledMessage = { id: crypto.randomBytes(6).toString("hex"), key, text, at, createdAt: Date.now() };
|
|
46
|
+
load().push(item);
|
|
47
|
+
save();
|
|
48
|
+
arm();
|
|
49
|
+
return item;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeScheduled(key: string, id: string): boolean {
|
|
53
|
+
const list = load();
|
|
54
|
+
const i = list.findIndex((m) => m.id === id && m.key === key);
|
|
55
|
+
if (i < 0) return false;
|
|
56
|
+
list.splice(i, 1);
|
|
57
|
+
save();
|
|
58
|
+
arm();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 对话被删 / 换了主人之类:把它排着的全清掉 */
|
|
63
|
+
export function clearScheduled(key: string): void {
|
|
64
|
+
const list = load();
|
|
65
|
+
const kept = list.filter((m) => m.key !== key);
|
|
66
|
+
if (kept.length === list.length) return;
|
|
67
|
+
cache = kept;
|
|
68
|
+
save();
|
|
69
|
+
arm();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 定时器只挂最近的那一条;setTimeout 最长约 24.8 天,更远的先睡到上限再重新算 */
|
|
73
|
+
function arm(): void {
|
|
74
|
+
if (timer) clearTimeout(timer);
|
|
75
|
+
timer = null;
|
|
76
|
+
if (!fire) return;
|
|
77
|
+
const next = load().reduce<number | null>((min, m) => (min === null || m.at < min ? m.at : min), null);
|
|
78
|
+
if (next === null) return;
|
|
79
|
+
const delay = Math.min(Math.max(next - Date.now(), 0), 2 ** 31 - 1);
|
|
80
|
+
timer = setTimeout(tick, delay);
|
|
81
|
+
timer.unref();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tick(): void {
|
|
85
|
+
timer = null;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const list = load();
|
|
88
|
+
const due = list.filter((m) => m.at <= now).sort((a, b) => a.at - b.at);
|
|
89
|
+
if (due.length) {
|
|
90
|
+
cache = list.filter((m) => m.at > now);
|
|
91
|
+
save();
|
|
92
|
+
for (const m of due) {
|
|
93
|
+
try {
|
|
94
|
+
fire?.(m);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[定时发送] ${m.key} 发送失败:`, err instanceof Error ? err.message : err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
arm();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 启动调度。onFire 负责真正把消息发出去(由 main.ts 接到 hub 上,这里不依赖 hub) */
|
|
104
|
+
export function startScheduler(onFire: (m: ScheduledMessage) => void): void {
|
|
105
|
+
fire = onFire;
|
|
106
|
+
arm();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function stopScheduler(): void {
|
|
110
|
+
fire = null;
|
|
111
|
+
if (timer) clearTimeout(timer);
|
|
112
|
+
timer = null;
|
|
113
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { startFeishu, stopFeishu } from "./channels/feishu/index.js";
|
|
|
10
10
|
import { startWeb, stopWeb } from "./channels/web/server.js";
|
|
11
11
|
import { home } from "./home.js";
|
|
12
12
|
import { startMemoryCurator } from "./core/memory-curator.js";
|
|
13
|
+
import { startScheduler, stopScheduler } from "./core/scheduled.js";
|
|
13
14
|
|
|
14
15
|
async function startDriver(id: "claude" | "codex"): Promise<void> {
|
|
15
16
|
const driver = hub.driver(id);
|
|
@@ -45,6 +46,12 @@ async function main(): Promise<void> {
|
|
|
45
46
|
|
|
46
47
|
startFeishu();
|
|
47
48
|
if (config().web.enabled) await startWeb();
|
|
49
|
+
// 定时发送:等 agent 和渠道都起来了再开始,服务停着时到点的这时补发
|
|
50
|
+
startScheduler((m) => {
|
|
51
|
+
if (!getConversation(m.key)) return;
|
|
52
|
+
hub.send(m.key, { text: m.text, attachments: [] }, { channel: "schedule", channelId: "schedule", name: "定时发送" });
|
|
53
|
+
hub.emitEvent(m.key, { type: "meta" });
|
|
54
|
+
});
|
|
48
55
|
// 先查一遍 agent 装没装、登没登录,网页打开时新对话里就只列出能用的
|
|
49
56
|
void agentHealth().catch(() => {});
|
|
50
57
|
|
|
@@ -60,6 +67,7 @@ async function main(): Promise<void> {
|
|
|
60
67
|
console.log(`[退出] 收到 ${signal},正在收尾…`);
|
|
61
68
|
const force = setTimeout(() => process.exit(0), 8000);
|
|
62
69
|
force.unref();
|
|
70
|
+
stopScheduler();
|
|
63
71
|
stopFeishu();
|
|
64
72
|
await stopWeb();
|
|
65
73
|
await hub.shutdown().catch(() => {});
|
package/web/app.js
CHANGED
|
@@ -38,6 +38,8 @@ 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>',
|
|
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>',
|
|
41
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>',
|
|
42
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>',
|
|
43
45
|
fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
|
|
@@ -461,6 +463,7 @@ function onWsMessage(msg) {
|
|
|
461
463
|
if (msg.info.key === S.current) {
|
|
462
464
|
renderTopbar();
|
|
463
465
|
setBusy(msg.info.busy);
|
|
466
|
+
renderScheduled();
|
|
464
467
|
}
|
|
465
468
|
scheduleSidebar();
|
|
466
469
|
return;
|
|
@@ -681,8 +684,14 @@ function renderLayout() {
|
|
|
681
684
|
h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
|
|
682
685
|
);
|
|
683
686
|
ui.topbar = h("div", { class: "topbar" });
|
|
687
|
+
// 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
|
|
688
|
+
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
684
689
|
ui.msgInner = h("div", { class: "messages-inner" });
|
|
685
|
-
ui.
|
|
690
|
+
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
691
|
+
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
692
|
+
ui.toBottom = h("button", { class: "to-bottom", title: "回到底部", hidden: true, onclick: () => scrollToBottom() }, icon("down"));
|
|
693
|
+
ui.follow = true;
|
|
694
|
+
bindScrollWatch();
|
|
686
695
|
ui.composerWrap = h("div", { class: "composer-wrap" });
|
|
687
696
|
ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
|
|
688
697
|
ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
|
|
@@ -1284,10 +1293,22 @@ function renderTopbar() {
|
|
|
1284
1293
|
fill(ui.topbar,
|
|
1285
1294
|
h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
|
|
1286
1295
|
h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
|
|
1296
|
+
ui.modeSlot,
|
|
1297
|
+
h("button", { class: "icon-btn top-more", title: "更多", onclick: (e) => openTopMore(e.currentTarget) }, icon("more")),
|
|
1287
1298
|
);
|
|
1288
1299
|
renderContext();
|
|
1289
1300
|
}
|
|
1290
1301
|
|
|
1302
|
+
/** 顶栏右上角「更多」 */
|
|
1303
|
+
function openTopMore(anchor) {
|
|
1304
|
+
if (ui.menu?.anchor === anchor) return closeMenu();
|
|
1305
|
+
showMenu(anchor, [{ label: "定时发送…", onclick: () => openSchedule() }]);
|
|
1306
|
+
ui.menu.anchor = anchor;
|
|
1307
|
+
// 贴着按钮右边对齐,别超出屏幕
|
|
1308
|
+
const r = anchor.getBoundingClientRect();
|
|
1309
|
+
ui.menu.style.left = `${Math.max(8, r.right - ui.menu.offsetWidth)}px`;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1291
1312
|
function baseName(p) {
|
|
1292
1313
|
return (p ?? "").split(/[\\/]/).filter(Boolean).pop() || p || "";
|
|
1293
1314
|
}
|
|
@@ -1711,7 +1732,11 @@ function newDraft() {
|
|
|
1711
1732
|
S.turnAi = null;
|
|
1712
1733
|
turnStartAt = null;
|
|
1713
1734
|
S.uploads = [];
|
|
1735
|
+
S.pinNext = false;
|
|
1736
|
+
ui.pinEl = null;
|
|
1737
|
+
ui.follow = true;
|
|
1714
1738
|
setBusy(false);
|
|
1739
|
+
renderScheduled();
|
|
1715
1740
|
history.replaceState(null, "", location.pathname);
|
|
1716
1741
|
WS.presence();
|
|
1717
1742
|
renderTopbar();
|
|
@@ -1751,7 +1776,11 @@ async function openConversation(key) {
|
|
|
1751
1776
|
S.turnAi = null;
|
|
1752
1777
|
turnStartAt = null;
|
|
1753
1778
|
S.uploads = [];
|
|
1779
|
+
S.pinNext = false;
|
|
1780
|
+
ui.pinEl = null;
|
|
1781
|
+
ui.follow = true;
|
|
1754
1782
|
renderChips();
|
|
1783
|
+
renderScheduled();
|
|
1755
1784
|
history.replaceState(null, "", `#${encodeURIComponent(key)}`);
|
|
1756
1785
|
WS.presence();
|
|
1757
1786
|
renderTopbar();
|
|
@@ -1800,18 +1829,82 @@ async function openConversation(key) {
|
|
|
1800
1829
|
setBusy(data.info.busy);
|
|
1801
1830
|
renderTopbar();
|
|
1802
1831
|
WS.subscribe(key, S.seq.get(key));
|
|
1803
|
-
|
|
1832
|
+
renderScheduled();
|
|
1833
|
+
scrollToBottom();
|
|
1804
1834
|
}
|
|
1805
1835
|
|
|
1806
1836
|
// ---------- 消息渲染 ----------
|
|
1807
1837
|
|
|
1808
|
-
|
|
1838
|
+
// 滚动规则(和 ChatGPT 一样):
|
|
1839
|
+
// - 自己发出一条消息后,把它顶到视口最上面,下面留白;回复往下长,长到屏幕底部之后才跟着往下滚。
|
|
1840
|
+
// 留白靠消息列表后面的 ui.spacer 撑出来:高度 = 刚好够那条消息滚到顶,回复越长它越矮,到 0 为止。
|
|
1841
|
+
// - 回复过程中只要用户自己滚了(滚轮、触摸、拖滚动条、键盘翻页),就不再自动滚,直到下一次发消息。
|
|
1842
|
+
// ui.follow 记着现在要不要自动滚。
|
|
1843
|
+
|
|
1844
|
+
function nearBottom(gap = 120) {
|
|
1809
1845
|
const m = ui.messages;
|
|
1810
|
-
return m.scrollHeight - m.scrollTop - m.clientHeight <
|
|
1846
|
+
return m.scrollHeight - m.scrollTop - m.clientHeight < gap;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** 内容变了之后:重算留白,要跟随的话贴到底(有留白时「底」正好就是被顶上去的那条消息) */
|
|
1850
|
+
function afterLayout() {
|
|
1851
|
+
if (!ui.messages) return;
|
|
1852
|
+
fitSpacer();
|
|
1853
|
+
if (ui.follow && performance.now() > (ui.smoothUntil ?? 0)) ui.messages.scrollTop = ui.messages.scrollHeight;
|
|
1854
|
+
updateToBottomBtn();
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function fitSpacer() {
|
|
1858
|
+
const pin = ui.pinEl;
|
|
1859
|
+
if (!pin?.isConnected) {
|
|
1860
|
+
ui.pinEl = null;
|
|
1861
|
+
ui.spacer.style.height = "0px";
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
// 顶栏是叠在消息区上面的:让出它的高度,再留一点空
|
|
1865
|
+
const covered = Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
|
|
1866
|
+
const top = Math.max(0, pin.offsetTop - covered - 12);
|
|
1867
|
+
const need = top + ui.messages.clientHeight - ui.msgInner.offsetHeight;
|
|
1868
|
+
ui.spacer.style.height = `${Math.max(0, Math.ceil(need))}px`;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** 回到跟随模式并贴到底:打开会话、点「回到底部」时用 */
|
|
1872
|
+
function scrollToBottom() {
|
|
1873
|
+
ui.follow = true;
|
|
1874
|
+
afterLayout();
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/** 把刚发出的这条消息顶到最上面 */
|
|
1878
|
+
function pinToTop(el) {
|
|
1879
|
+
ui.pinEl = el;
|
|
1880
|
+
ui.follow = true;
|
|
1881
|
+
fitSpacer();
|
|
1882
|
+
ui.smoothUntil = performance.now() + 450;
|
|
1883
|
+
ui.messages.scrollTo({ top: ui.messages.scrollHeight, behavior: "smooth" });
|
|
1884
|
+
updateToBottomBtn();
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/** 用户自己动了滚动:停止自动滚动 */
|
|
1888
|
+
function userScrolled() {
|
|
1889
|
+
ui.follow = false;
|
|
1890
|
+
ui.smoothUntil = 0;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function updateToBottomBtn() {
|
|
1894
|
+
if (ui.toBottom) ui.toBottom.hidden = nearBottom(200);
|
|
1811
1895
|
}
|
|
1812
1896
|
|
|
1813
|
-
function
|
|
1814
|
-
|
|
1897
|
+
function bindScrollWatch() {
|
|
1898
|
+
const m = ui.messages;
|
|
1899
|
+
m.addEventListener("wheel", userScrolled, { passive: true });
|
|
1900
|
+
m.addEventListener("touchmove", userScrolled, { passive: true });
|
|
1901
|
+
// 点在滚动条上时事件目标就是滚动容器本身(点在内容上的是里面的元素)
|
|
1902
|
+
m.addEventListener("pointerdown", (e) => e.target === m && userScrolled());
|
|
1903
|
+
m.addEventListener("keydown", (e) => {
|
|
1904
|
+
if (["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "].includes(e.key)) userScrolled();
|
|
1905
|
+
});
|
|
1906
|
+
m.addEventListener("scroll", updateToBottomBtn, { passive: true });
|
|
1907
|
+
addEventListener("resize", () => afterLayout());
|
|
1815
1908
|
}
|
|
1816
1909
|
|
|
1817
1910
|
/** 只有最后一条回复默认显示底部的操作栏(复制 / 分叉 / 用时…),前面的 hover 才出来 */
|
|
@@ -1829,13 +1922,12 @@ function markLatestAi() {
|
|
|
1829
1922
|
|
|
1830
1923
|
function addItem(item) {
|
|
1831
1924
|
ui.msgInner.querySelector(".empty")?.remove();
|
|
1832
|
-
const stick = nearBottom();
|
|
1833
1925
|
item.el = renderItem(item);
|
|
1834
1926
|
ui.msgInner.append(item.el);
|
|
1835
1927
|
S.items.push(item);
|
|
1836
1928
|
markLatestAi();
|
|
1837
1929
|
updateCtxVisibility();
|
|
1838
|
-
|
|
1930
|
+
afterLayout();
|
|
1839
1931
|
return item;
|
|
1840
1932
|
}
|
|
1841
1933
|
|
|
@@ -1847,7 +1939,6 @@ function updateItem(item) {
|
|
|
1847
1939
|
flushScheduled = true;
|
|
1848
1940
|
requestAnimationFrame(() => {
|
|
1849
1941
|
flushScheduled = false;
|
|
1850
|
-
const stick = nearBottom();
|
|
1851
1942
|
for (const it of dirty) {
|
|
1852
1943
|
if (!it.el?.isConnected) continue;
|
|
1853
1944
|
const next = renderItem(it);
|
|
@@ -1856,7 +1947,7 @@ function updateItem(item) {
|
|
|
1856
1947
|
}
|
|
1857
1948
|
dirty.clear();
|
|
1858
1949
|
markLatestAi();
|
|
1859
|
-
|
|
1950
|
+
afterLayout();
|
|
1860
1951
|
});
|
|
1861
1952
|
}
|
|
1862
1953
|
|
|
@@ -2054,7 +2145,12 @@ function applyEvent(env) {
|
|
|
2054
2145
|
}
|
|
2055
2146
|
// 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
|
|
2056
2147
|
if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
|
|
2057
|
-
addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
2148
|
+
const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
2149
|
+
// 这个页面刚发的:顶到最上面
|
|
2150
|
+
if (S.pinNext && ev.origin?.channel === "web") {
|
|
2151
|
+
S.pinNext = false;
|
|
2152
|
+
pinToTop(it.el);
|
|
2153
|
+
}
|
|
2058
2154
|
currentAi(env.ts);
|
|
2059
2155
|
return;
|
|
2060
2156
|
}
|
|
@@ -2299,9 +2395,9 @@ function renderComposer() {
|
|
|
2299
2395
|
: null;
|
|
2300
2396
|
ui.ctxBar = h("div", { class: "ctx-bar" });
|
|
2301
2397
|
ui.modelSlot = h("span", { class: "model-slot" });
|
|
2302
|
-
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
2303
2398
|
ui.ctxRing = h("span", { class: "ctx-ring-slot" });
|
|
2304
2399
|
ui.chips = h("div", { class: "chips" });
|
|
2400
|
+
ui.schedBar = h("div", { class: "sched-bar", hidden: true });
|
|
2305
2401
|
ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
|
|
2306
2402
|
ui.composer = h(
|
|
2307
2403
|
"div",
|
|
@@ -2328,18 +2424,132 @@ function renderComposer() {
|
|
|
2328
2424
|
ui.fileInput,
|
|
2329
2425
|
h("span", { class: "spacer" }),
|
|
2330
2426
|
ui.ctxRing,
|
|
2331
|
-
ui.modeSlot,
|
|
2332
2427
|
ui.modelSlot,
|
|
2333
2428
|
ui.micBtn,
|
|
2334
2429
|
ui.sendBtn,
|
|
2335
2430
|
),
|
|
2336
2431
|
);
|
|
2337
2432
|
fill(ui.composerWrap,
|
|
2338
|
-
|
|
2433
|
+
ui.toBottom,
|
|
2434
|
+
h("div", { class: "composer-box" }, ui.schedBar, ui.ctxBar, ui.composer),
|
|
2339
2435
|
);
|
|
2340
2436
|
ui.input.addEventListener("input", updateSendBtn);
|
|
2341
2437
|
updateSendBtn();
|
|
2342
2438
|
renderContext();
|
|
2439
|
+
renderScheduled();
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// ---------- 定时发送 ----------
|
|
2443
|
+
// 额度用完、要等刷新才能接着干活时:先把下一条排好,到点由服务端发出(页面关了也会发)。只支持文字。
|
|
2444
|
+
|
|
2445
|
+
function fmtWhen(ms) {
|
|
2446
|
+
const d = new Date(ms);
|
|
2447
|
+
const now = new Date();
|
|
2448
|
+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
2449
|
+
const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
2450
|
+
const diff = Math.round((day(d) - day(now)) / 86400000);
|
|
2451
|
+
if (diff === 0) return `今天 ${hm}`;
|
|
2452
|
+
if (diff === 1) return `明天 ${hm}`;
|
|
2453
|
+
if (diff === 2) return `后天 ${hm}`;
|
|
2454
|
+
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/** <input type="datetime-local"> 要的本地时间格式 */
|
|
2458
|
+
function toLocalInput(ms) {
|
|
2459
|
+
const d = new Date(ms);
|
|
2460
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2461
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/** 输入框上方:这条对话排着的定时消息,可以取消 */
|
|
2465
|
+
function renderScheduled() {
|
|
2466
|
+
if (!ui.schedBar) return;
|
|
2467
|
+
const list = (S.current && S.convs.get(S.current)?.scheduled) || [];
|
|
2468
|
+
ui.schedBar.hidden = list.length === 0;
|
|
2469
|
+
fill(ui.schedBar,
|
|
2470
|
+
list.map((m) =>
|
|
2471
|
+
h(
|
|
2472
|
+
"div",
|
|
2473
|
+
{ class: "sched-item", title: m.text },
|
|
2474
|
+
icon("clock"),
|
|
2475
|
+
h("span", { class: "sched-when" }, fmtWhen(m.at)),
|
|
2476
|
+
h("span", { class: "sched-text" }, oneLine(m.text, 200)),
|
|
2477
|
+
h("button", { class: "sched-act", title: "改时间或内容", onclick: () => openSchedule(m) }, "编辑"),
|
|
2478
|
+
h("button", { class: "sched-act", title: "取消这条定时消息", onclick: () => cancelScheduled(m.id) }, "取消"),
|
|
2479
|
+
),
|
|
2480
|
+
),
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
async function cancelScheduled(id, quiet) {
|
|
2485
|
+
const key = S.current;
|
|
2486
|
+
if (!key) return false;
|
|
2487
|
+
try {
|
|
2488
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
2489
|
+
S.convs.set(info.key, info);
|
|
2490
|
+
renderScheduled();
|
|
2491
|
+
return true;
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
if (!quiet) toast(err.message);
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/** 选时间的弹窗。edit:改已经排着的那条(先删旧的再排新的) */
|
|
2499
|
+
function openSchedule(edit) {
|
|
2500
|
+
if (!edit && S.uploads.length) return toast("定时发送暂不支持附件,先移除附件");
|
|
2501
|
+
const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
|
|
2502
|
+
text.value = edit ? edit.text : ui.input.value;
|
|
2503
|
+
const when = h("input", { type: "datetime-local", class: "sched-time" });
|
|
2504
|
+
// 当前 agent 的额度窗口(5 小时 / 7 天)什么时候重置:小字提示,默认排在 5 小时窗口重置后 5 分钟
|
|
2505
|
+
const agent = currentAgentId();
|
|
2506
|
+
const windows = (quotaState.data ?? []).find((q) => q.agent === agent)?.windows ?? [];
|
|
2507
|
+
const resets = windows.filter((w) => /^(5h|7d)$/.test(w.short) && w.resetsAt > Date.now());
|
|
2508
|
+
const session = resets.find((w) => w.short === "5h");
|
|
2509
|
+
when.value = toLocalInput(edit ? edit.at : session ? session.resetsAt + 5 * 60_000 : Date.now() + 5 * 3600_000);
|
|
2510
|
+
const body = h(
|
|
2511
|
+
"div",
|
|
2512
|
+
{ class: "modal-body sched-body" },
|
|
2513
|
+
text,
|
|
2514
|
+
h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
|
|
2515
|
+
h(
|
|
2516
|
+
"div",
|
|
2517
|
+
{ class: "muted sched-hint" },
|
|
2518
|
+
resets.length
|
|
2519
|
+
? `额度重置:${resets.map((w) => `${w.short === "5h" ? "5 小时" : "7 天"}额度 ${fmtWhen(w.resetsAt)}`).join(",")}。`
|
|
2520
|
+
: "",
|
|
2521
|
+
"到点由服务端发出,关掉页面也会发。",
|
|
2522
|
+
),
|
|
2523
|
+
);
|
|
2524
|
+
const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
|
|
2525
|
+
const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
2526
|
+
text.focus();
|
|
2527
|
+
go.onclick = async () => {
|
|
2528
|
+
const at = new Date(when.value).getTime();
|
|
2529
|
+
if (!text.value.trim()) return toast("内容不能为空");
|
|
2530
|
+
if (!Number.isFinite(at)) return toast("选一下发送时间");
|
|
2531
|
+
if (at <= Date.now()) return toast("发送时间要在现在之后");
|
|
2532
|
+
go.disabled = true;
|
|
2533
|
+
try {
|
|
2534
|
+
const key = await ensureConversation();
|
|
2535
|
+
// 先排新的再删旧的:中途失败也不会把原来那条弄丢
|
|
2536
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled`, { method: "POST", body: { text: text.value, at } });
|
|
2537
|
+
S.convs.set(info.key, info);
|
|
2538
|
+
if (edit) await cancelScheduled(edit.id, true);
|
|
2539
|
+
renderScheduled();
|
|
2540
|
+
// 用的是输入框里的草稿:排好了就清掉
|
|
2541
|
+
if (!edit && text.value === ui.input.value) {
|
|
2542
|
+
ui.input.value = "";
|
|
2543
|
+
autosize();
|
|
2544
|
+
updateSendBtn();
|
|
2545
|
+
}
|
|
2546
|
+
close();
|
|
2547
|
+
toast(`已排好,${fmtWhen(at)} 发送`);
|
|
2548
|
+
} catch (err) {
|
|
2549
|
+
go.disabled = false;
|
|
2550
|
+
toast(err.message);
|
|
2551
|
+
}
|
|
2552
|
+
};
|
|
2343
2553
|
}
|
|
2344
2554
|
|
|
2345
2555
|
// ---------- 语音输入(浏览器自带 Web Speech API) ----------
|
|
@@ -2682,12 +2892,15 @@ async function sendMessage() {
|
|
|
2682
2892
|
let key;
|
|
2683
2893
|
let res;
|
|
2684
2894
|
setSending(true);
|
|
2895
|
+
// 发出去之后恢复自动滚动;回显的那条用户消息到了就顶到最上面(见 applyEvent)
|
|
2896
|
+
S.pinNext = true;
|
|
2685
2897
|
try {
|
|
2686
2898
|
key = await ensureConversation();
|
|
2687
2899
|
if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
|
|
2688
2900
|
// 命令(比如 /status 要现读额度)可能比普通发送慢,多给点时间
|
|
2689
2901
|
res = await WS.request({ t: "send", key, text, uploads: S.uploads.map((u) => u.id) }, 45000);
|
|
2690
2902
|
} catch (err) {
|
|
2903
|
+
S.pinNext = false;
|
|
2691
2904
|
toast(err.message);
|
|
2692
2905
|
return;
|
|
2693
2906
|
} finally {
|
|
@@ -2703,9 +2916,12 @@ async function sendMessage() {
|
|
|
2703
2916
|
S.uploads = [];
|
|
2704
2917
|
renderChips();
|
|
2705
2918
|
updateSendBtn();
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2919
|
+
if (res?.reply) {
|
|
2920
|
+
// 命令不进会话记录,没有回显:直接贴到底看结果
|
|
2921
|
+
S.pinNext = false;
|
|
2922
|
+
await showCommandReply(text.trim(), res.reply, res.info);
|
|
2923
|
+
scrollToBottom();
|
|
2924
|
+
} else if (!ui.pinEl) ui.follow = true;
|
|
2709
2925
|
}
|
|
2710
2926
|
|
|
2711
2927
|
/** 会改变当前会话(换会话、换目录、换 agent)的命令,执行完要重拉一遍 */
|
|
@@ -5308,10 +5524,6 @@ async function systemTab(cfg, panel) {
|
|
|
5308
5524
|
|
|
5309
5525
|
// ---------- 启动 ----------
|
|
5310
5526
|
|
|
5311
|
-
boot()
|
|
5312
|
-
.then(() => {
|
|
5313
|
-
ui.messages?.addEventListener("scroll", () => (ui.stick = nearBottom()), { passive: true });
|
|
5314
|
-
})
|
|
5315
|
-
.catch((err) => {
|
|
5527
|
+
boot().catch((err) => {
|
|
5316
5528
|
fill($app, h("div", { class: "auth" }, h("div", { class: "auth-card" }, h("p", {}, `加载失败:${err.message}`))));
|
|
5317
5529
|
});
|
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
|
@@ -984,6 +984,46 @@ button.conv-group:hover {
|
|
|
984
984
|
overflow-y: auto;
|
|
985
985
|
overscroll-behavior: contain;
|
|
986
986
|
scroll-behavior: auto;
|
|
987
|
+
/* 留白(.messages-spacer)按消息的 offsetTop 计算,要以滚动容器为准 */
|
|
988
|
+
position: relative;
|
|
989
|
+
outline: none;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/* 发出消息后把它顶到最上面用的留白,高度由 app.js 算 */
|
|
993
|
+
.messages-spacer {
|
|
994
|
+
height: 0;
|
|
995
|
+
flex-shrink: 0;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/* 自己往上翻了之后出现:点一下回到底部、恢复自动滚动 */
|
|
999
|
+
.to-bottom {
|
|
1000
|
+
position: absolute;
|
|
1001
|
+
left: 50%;
|
|
1002
|
+
bottom: calc(100% + 10px);
|
|
1003
|
+
transform: translateX(-50%);
|
|
1004
|
+
z-index: 5;
|
|
1005
|
+
width: 34px;
|
|
1006
|
+
height: 34px;
|
|
1007
|
+
border-radius: 50%;
|
|
1008
|
+
display: grid;
|
|
1009
|
+
place-items: center;
|
|
1010
|
+
background: var(--bg);
|
|
1011
|
+
color: var(--fg-soft);
|
|
1012
|
+
border: 1px solid var(--border);
|
|
1013
|
+
box-shadow: var(--shadow);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.to-bottom:hover {
|
|
1017
|
+
color: var(--fg);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.to-bottom[hidden] {
|
|
1021
|
+
display: none;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.to-bottom svg {
|
|
1025
|
+
width: 18px;
|
|
1026
|
+
height: 18px;
|
|
987
1027
|
}
|
|
988
1028
|
|
|
989
1029
|
.messages-inner {
|
|
@@ -1998,6 +2038,16 @@ button.ctx-item:hover {
|
|
|
1998
2038
|
color: var(--accent);
|
|
1999
2039
|
}
|
|
2000
2040
|
|
|
2041
|
+
.topbar .mode-slot {
|
|
2042
|
+
display: inline-flex;
|
|
2043
|
+
flex-shrink: 0;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
.topbar .top-more svg {
|
|
2047
|
+
width: 20px;
|
|
2048
|
+
height: 20px;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2001
2051
|
.composer.drag {
|
|
2002
2052
|
border-color: var(--accent);
|
|
2003
2053
|
}
|
|
@@ -3319,3 +3369,103 @@ button.list-item:hover {
|
|
|
3319
3369
|
.in-desktop .side-top :is(button, a, input, select, textarea, label) {
|
|
3320
3370
|
-webkit-app-region: no-drag;
|
|
3321
3371
|
}
|
|
3372
|
+
|
|
3373
|
+
/* ---------- 定时发送 ---------- */
|
|
3374
|
+
|
|
3375
|
+
.sched-bar {
|
|
3376
|
+
display: flex;
|
|
3377
|
+
flex-direction: column;
|
|
3378
|
+
gap: 4px;
|
|
3379
|
+
margin: 0 14px 6px;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
.sched-bar[hidden] {
|
|
3383
|
+
display: none;
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
.sched-item {
|
|
3387
|
+
display: flex;
|
|
3388
|
+
align-items: center;
|
|
3389
|
+
gap: 8px;
|
|
3390
|
+
min-width: 0;
|
|
3391
|
+
padding: 6px 6px 6px 10px;
|
|
3392
|
+
border: 1px solid var(--border);
|
|
3393
|
+
border-radius: 12px;
|
|
3394
|
+
background: var(--bg-soft);
|
|
3395
|
+
font-size: 13px;
|
|
3396
|
+
color: var(--fg-soft);
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
.sched-item svg {
|
|
3400
|
+
width: 15px;
|
|
3401
|
+
height: 15px;
|
|
3402
|
+
flex-shrink: 0;
|
|
3403
|
+
color: var(--accent);
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
.sched-when {
|
|
3407
|
+
flex-shrink: 0;
|
|
3408
|
+
color: var(--fg);
|
|
3409
|
+
font-weight: 500;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
.sched-text {
|
|
3413
|
+
flex: 1;
|
|
3414
|
+
min-width: 0;
|
|
3415
|
+
overflow: hidden;
|
|
3416
|
+
text-overflow: ellipsis;
|
|
3417
|
+
white-space: nowrap;
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
.sched-act {
|
|
3421
|
+
flex-shrink: 0;
|
|
3422
|
+
padding: 3px 8px;
|
|
3423
|
+
border-radius: 8px;
|
|
3424
|
+
font-size: 12px;
|
|
3425
|
+
color: var(--fg-soft);
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
.sched-act:hover {
|
|
3429
|
+
background: var(--bg-hover);
|
|
3430
|
+
color: var(--fg);
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
.sched-input {
|
|
3434
|
+
width: 100%;
|
|
3435
|
+
box-sizing: border-box;
|
|
3436
|
+
resize: vertical;
|
|
3437
|
+
padding: 10px 12px;
|
|
3438
|
+
border: 1px solid var(--border);
|
|
3439
|
+
border-radius: 10px;
|
|
3440
|
+
background: var(--bg-input);
|
|
3441
|
+
color: var(--fg);
|
|
3442
|
+
font: inherit;
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
.sched-hint {
|
|
3446
|
+
font-size: 12px;
|
|
3447
|
+
line-height: 1.6;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
.sched-row {
|
|
3451
|
+
display: flex;
|
|
3452
|
+
align-items: center;
|
|
3453
|
+
gap: 10px;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
.sched-time {
|
|
3457
|
+
flex: 1;
|
|
3458
|
+
padding: 7px 10px;
|
|
3459
|
+
border: 1px solid var(--border);
|
|
3460
|
+
border-radius: 10px;
|
|
3461
|
+
background: var(--bg-input);
|
|
3462
|
+
color: var(--fg);
|
|
3463
|
+
font: inherit;
|
|
3464
|
+
color-scheme: light dark;
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
/* iOS 上双击会放大页面:关掉双击缩放(双指缩放照常) */
|
|
3468
|
+
html,
|
|
3469
|
+
body {
|
|
3470
|
+
touch-action: manipulation;
|
|
3471
|
+
}
|