agent-loft 0.2.25 → 0.2.26
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 +2 -0
- 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 +228 -20
- package/web/style.css +139 -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
|
+
- 定时发送:输入框左下角的时钟按钮,选好时间(有「额度重置后」的快捷选项)由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
|
|
49
51
|
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
|
|
50
52
|
- 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
|
|
51
53
|
- 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
|
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,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
|
+
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
42
|
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
43
|
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
44
|
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 +462,7 @@ function onWsMessage(msg) {
|
|
|
461
462
|
if (msg.info.key === S.current) {
|
|
462
463
|
renderTopbar();
|
|
463
464
|
setBusy(msg.info.busy);
|
|
465
|
+
renderScheduled();
|
|
464
466
|
}
|
|
465
467
|
scheduleSidebar();
|
|
466
468
|
return;
|
|
@@ -682,7 +684,11 @@ function renderLayout() {
|
|
|
682
684
|
);
|
|
683
685
|
ui.topbar = h("div", { class: "topbar" });
|
|
684
686
|
ui.msgInner = h("div", { class: "messages-inner" });
|
|
685
|
-
ui.
|
|
687
|
+
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
688
|
+
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
689
|
+
ui.toBottom = h("button", { class: "to-bottom", title: "回到底部", hidden: true, onclick: () => scrollToBottom() }, icon("down"));
|
|
690
|
+
ui.follow = true;
|
|
691
|
+
bindScrollWatch();
|
|
686
692
|
ui.composerWrap = h("div", { class: "composer-wrap" });
|
|
687
693
|
ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
|
|
688
694
|
ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
|
|
@@ -1711,7 +1717,11 @@ function newDraft() {
|
|
|
1711
1717
|
S.turnAi = null;
|
|
1712
1718
|
turnStartAt = null;
|
|
1713
1719
|
S.uploads = [];
|
|
1720
|
+
S.pinNext = false;
|
|
1721
|
+
ui.pinEl = null;
|
|
1722
|
+
ui.follow = true;
|
|
1714
1723
|
setBusy(false);
|
|
1724
|
+
renderScheduled();
|
|
1715
1725
|
history.replaceState(null, "", location.pathname);
|
|
1716
1726
|
WS.presence();
|
|
1717
1727
|
renderTopbar();
|
|
@@ -1751,7 +1761,11 @@ async function openConversation(key) {
|
|
|
1751
1761
|
S.turnAi = null;
|
|
1752
1762
|
turnStartAt = null;
|
|
1753
1763
|
S.uploads = [];
|
|
1764
|
+
S.pinNext = false;
|
|
1765
|
+
ui.pinEl = null;
|
|
1766
|
+
ui.follow = true;
|
|
1754
1767
|
renderChips();
|
|
1768
|
+
renderScheduled();
|
|
1755
1769
|
history.replaceState(null, "", `#${encodeURIComponent(key)}`);
|
|
1756
1770
|
WS.presence();
|
|
1757
1771
|
renderTopbar();
|
|
@@ -1800,18 +1814,82 @@ async function openConversation(key) {
|
|
|
1800
1814
|
setBusy(data.info.busy);
|
|
1801
1815
|
renderTopbar();
|
|
1802
1816
|
WS.subscribe(key, S.seq.get(key));
|
|
1803
|
-
|
|
1817
|
+
renderScheduled();
|
|
1818
|
+
scrollToBottom();
|
|
1804
1819
|
}
|
|
1805
1820
|
|
|
1806
1821
|
// ---------- 消息渲染 ----------
|
|
1807
1822
|
|
|
1808
|
-
|
|
1823
|
+
// 滚动规则(和 ChatGPT 一样):
|
|
1824
|
+
// - 自己发出一条消息后,把它顶到视口最上面,下面留白;回复往下长,长到屏幕底部之后才跟着往下滚。
|
|
1825
|
+
// 留白靠消息列表后面的 ui.spacer 撑出来:高度 = 刚好够那条消息滚到顶,回复越长它越矮,到 0 为止。
|
|
1826
|
+
// - 回复过程中只要用户自己滚了(滚轮、触摸、拖滚动条、键盘翻页),就不再自动滚,直到下一次发消息。
|
|
1827
|
+
// ui.follow 记着现在要不要自动滚。
|
|
1828
|
+
|
|
1829
|
+
function nearBottom(gap = 120) {
|
|
1809
1830
|
const m = ui.messages;
|
|
1810
|
-
return m.scrollHeight - m.scrollTop - m.clientHeight <
|
|
1831
|
+
return m.scrollHeight - m.scrollTop - m.clientHeight < gap;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** 内容变了之后:重算留白,要跟随的话贴到底(有留白时「底」正好就是被顶上去的那条消息) */
|
|
1835
|
+
function afterLayout() {
|
|
1836
|
+
if (!ui.messages) return;
|
|
1837
|
+
fitSpacer();
|
|
1838
|
+
if (ui.follow && performance.now() > (ui.smoothUntil ?? 0)) ui.messages.scrollTop = ui.messages.scrollHeight;
|
|
1839
|
+
updateToBottomBtn();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function fitSpacer() {
|
|
1843
|
+
const pin = ui.pinEl;
|
|
1844
|
+
if (!pin?.isConnected) {
|
|
1845
|
+
ui.pinEl = null;
|
|
1846
|
+
ui.spacer.style.height = "0px";
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
// 顶栏是叠在消息区上面的:让出它的高度,再留一点空
|
|
1850
|
+
const covered = Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
|
|
1851
|
+
const top = Math.max(0, pin.offsetTop - covered - 12);
|
|
1852
|
+
const need = top + ui.messages.clientHeight - ui.msgInner.offsetHeight;
|
|
1853
|
+
ui.spacer.style.height = `${Math.max(0, Math.ceil(need))}px`;
|
|
1811
1854
|
}
|
|
1812
1855
|
|
|
1813
|
-
|
|
1814
|
-
|
|
1856
|
+
/** 回到跟随模式并贴到底:打开会话、点「回到底部」时用 */
|
|
1857
|
+
function scrollToBottom() {
|
|
1858
|
+
ui.follow = true;
|
|
1859
|
+
afterLayout();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** 把刚发出的这条消息顶到最上面 */
|
|
1863
|
+
function pinToTop(el) {
|
|
1864
|
+
ui.pinEl = el;
|
|
1865
|
+
ui.follow = true;
|
|
1866
|
+
fitSpacer();
|
|
1867
|
+
ui.smoothUntil = performance.now() + 450;
|
|
1868
|
+
ui.messages.scrollTo({ top: ui.messages.scrollHeight, behavior: "smooth" });
|
|
1869
|
+
updateToBottomBtn();
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/** 用户自己动了滚动:停止自动滚动 */
|
|
1873
|
+
function userScrolled() {
|
|
1874
|
+
ui.follow = false;
|
|
1875
|
+
ui.smoothUntil = 0;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function updateToBottomBtn() {
|
|
1879
|
+
if (ui.toBottom) ui.toBottom.hidden = nearBottom(200);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function bindScrollWatch() {
|
|
1883
|
+
const m = ui.messages;
|
|
1884
|
+
m.addEventListener("wheel", userScrolled, { passive: true });
|
|
1885
|
+
m.addEventListener("touchmove", userScrolled, { passive: true });
|
|
1886
|
+
// 点在滚动条上时事件目标就是滚动容器本身(点在内容上的是里面的元素)
|
|
1887
|
+
m.addEventListener("pointerdown", (e) => e.target === m && userScrolled());
|
|
1888
|
+
m.addEventListener("keydown", (e) => {
|
|
1889
|
+
if (["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "].includes(e.key)) userScrolled();
|
|
1890
|
+
});
|
|
1891
|
+
m.addEventListener("scroll", updateToBottomBtn, { passive: true });
|
|
1892
|
+
addEventListener("resize", () => afterLayout());
|
|
1815
1893
|
}
|
|
1816
1894
|
|
|
1817
1895
|
/** 只有最后一条回复默认显示底部的操作栏(复制 / 分叉 / 用时…),前面的 hover 才出来 */
|
|
@@ -1829,13 +1907,12 @@ function markLatestAi() {
|
|
|
1829
1907
|
|
|
1830
1908
|
function addItem(item) {
|
|
1831
1909
|
ui.msgInner.querySelector(".empty")?.remove();
|
|
1832
|
-
const stick = nearBottom();
|
|
1833
1910
|
item.el = renderItem(item);
|
|
1834
1911
|
ui.msgInner.append(item.el);
|
|
1835
1912
|
S.items.push(item);
|
|
1836
1913
|
markLatestAi();
|
|
1837
1914
|
updateCtxVisibility();
|
|
1838
|
-
|
|
1915
|
+
afterLayout();
|
|
1839
1916
|
return item;
|
|
1840
1917
|
}
|
|
1841
1918
|
|
|
@@ -1847,7 +1924,6 @@ function updateItem(item) {
|
|
|
1847
1924
|
flushScheduled = true;
|
|
1848
1925
|
requestAnimationFrame(() => {
|
|
1849
1926
|
flushScheduled = false;
|
|
1850
|
-
const stick = nearBottom();
|
|
1851
1927
|
for (const it of dirty) {
|
|
1852
1928
|
if (!it.el?.isConnected) continue;
|
|
1853
1929
|
const next = renderItem(it);
|
|
@@ -1856,7 +1932,7 @@ function updateItem(item) {
|
|
|
1856
1932
|
}
|
|
1857
1933
|
dirty.clear();
|
|
1858
1934
|
markLatestAi();
|
|
1859
|
-
|
|
1935
|
+
afterLayout();
|
|
1860
1936
|
});
|
|
1861
1937
|
}
|
|
1862
1938
|
|
|
@@ -2054,7 +2130,12 @@ function applyEvent(env) {
|
|
|
2054
2130
|
}
|
|
2055
2131
|
// 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
|
|
2056
2132
|
if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
|
|
2057
|
-
addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
2133
|
+
const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
2134
|
+
// 这个页面刚发的:顶到最上面
|
|
2135
|
+
if (S.pinNext && ev.origin?.channel === "web") {
|
|
2136
|
+
S.pinNext = false;
|
|
2137
|
+
pinToTop(it.el);
|
|
2138
|
+
}
|
|
2058
2139
|
currentAi(env.ts);
|
|
2059
2140
|
return;
|
|
2060
2141
|
}
|
|
@@ -2302,6 +2383,7 @@ function renderComposer() {
|
|
|
2302
2383
|
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
2303
2384
|
ui.ctxRing = h("span", { class: "ctx-ring-slot" });
|
|
2304
2385
|
ui.chips = h("div", { class: "chips" });
|
|
2386
|
+
ui.schedBar = h("div", { class: "sched-bar", hidden: true });
|
|
2305
2387
|
ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
|
|
2306
2388
|
ui.composer = h(
|
|
2307
2389
|
"div",
|
|
@@ -2325,6 +2407,7 @@ function renderComposer() {
|
|
|
2325
2407
|
"div",
|
|
2326
2408
|
{ class: "composer-row" },
|
|
2327
2409
|
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")),
|
|
2328
2411
|
ui.fileInput,
|
|
2329
2412
|
h("span", { class: "spacer" }),
|
|
2330
2413
|
ui.ctxRing,
|
|
@@ -2335,11 +2418,134 @@ function renderComposer() {
|
|
|
2335
2418
|
),
|
|
2336
2419
|
);
|
|
2337
2420
|
fill(ui.composerWrap,
|
|
2338
|
-
|
|
2421
|
+
ui.toBottom,
|
|
2422
|
+
h("div", { class: "composer-box" }, ui.schedBar, ui.ctxBar, ui.composer),
|
|
2339
2423
|
);
|
|
2340
2424
|
ui.input.addEventListener("input", updateSendBtn);
|
|
2341
2425
|
updateSendBtn();
|
|
2342
2426
|
renderContext();
|
|
2427
|
+
renderScheduled();
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// ---------- 定时发送 ----------
|
|
2431
|
+
// 额度用完、要等刷新才能接着干活时:先把下一条排好,到点由服务端发出(页面关了也会发)。只支持文字。
|
|
2432
|
+
|
|
2433
|
+
function fmtWhen(ms) {
|
|
2434
|
+
const d = new Date(ms);
|
|
2435
|
+
const now = new Date();
|
|
2436
|
+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
2437
|
+
const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
2438
|
+
const diff = Math.round((day(d) - day(now)) / 86400000);
|
|
2439
|
+
if (diff === 0) return `今天 ${hm}`;
|
|
2440
|
+
if (diff === 1) return `明天 ${hm}`;
|
|
2441
|
+
if (diff === 2) return `后天 ${hm}`;
|
|
2442
|
+
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/** <input type="datetime-local"> 要的本地时间格式 */
|
|
2446
|
+
function toLocalInput(ms) {
|
|
2447
|
+
const d = new Date(ms);
|
|
2448
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2449
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** 输入框上方:这条对话排着的定时消息,可以取消 */
|
|
2453
|
+
function renderScheduled() {
|
|
2454
|
+
if (!ui.schedBar) return;
|
|
2455
|
+
const list = (S.current && S.convs.get(S.current)?.scheduled) || [];
|
|
2456
|
+
ui.schedBar.hidden = list.length === 0;
|
|
2457
|
+
fill(ui.schedBar,
|
|
2458
|
+
list.map((m) =>
|
|
2459
|
+
h(
|
|
2460
|
+
"div",
|
|
2461
|
+
{ class: "sched-item", title: m.text },
|
|
2462
|
+
icon("clock"),
|
|
2463
|
+
h("span", { class: "sched-when" }, fmtWhen(m.at)),
|
|
2464
|
+
h("span", { class: "sched-text" }, oneLine(m.text, 200)),
|
|
2465
|
+
h("button", { class: "sched-act", title: "改时间或内容", onclick: () => openSchedule(m) }, "编辑"),
|
|
2466
|
+
h("button", { class: "sched-act", title: "取消这条定时消息", onclick: () => cancelScheduled(m.id) }, "取消"),
|
|
2467
|
+
),
|
|
2468
|
+
),
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
async function cancelScheduled(id, quiet) {
|
|
2473
|
+
const key = S.current;
|
|
2474
|
+
if (!key) return false;
|
|
2475
|
+
try {
|
|
2476
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
2477
|
+
S.convs.set(info.key, info);
|
|
2478
|
+
renderScheduled();
|
|
2479
|
+
return true;
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
if (!quiet) toast(err.message);
|
|
2482
|
+
return false;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/** 选时间的弹窗。edit:改已经排着的那条(先删旧的再排新的) */
|
|
2487
|
+
function openSchedule(edit) {
|
|
2488
|
+
if (!edit && S.uploads.length) return toast("定时发送暂不支持附件,先移除附件");
|
|
2489
|
+
const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
|
|
2490
|
+
text.value = edit ? edit.text : ui.input.value;
|
|
2491
|
+
const when = h("input", { type: "datetime-local", class: "sched-time" });
|
|
2492
|
+
const setAt = (ms) => (when.value = toLocalInput(ms));
|
|
2493
|
+
setAt(edit ? edit.at : Date.now() + 5 * 3600_000);
|
|
2494
|
+
// 快捷时间:当前 agent 的额度窗口重置后一分钟,再加几个常用的
|
|
2495
|
+
const agent = currentAgentId();
|
|
2496
|
+
const presets = [];
|
|
2497
|
+
for (const q of quotaState.data ?? []) {
|
|
2498
|
+
if (q.agent !== agent) continue;
|
|
2499
|
+
for (const w of q.windows) {
|
|
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
|
+
);
|
|
2512
|
+
const body = h(
|
|
2513
|
+
"div",
|
|
2514
|
+
{ class: "modal-body sched-body" },
|
|
2515
|
+
text,
|
|
2516
|
+
h("div", { class: "sched-presets" }, presets.map((p) => h("button", { class: "btn", onclick: () => setAt(p.at) }, p.label))),
|
|
2517
|
+
h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
|
|
2518
|
+
h("div", { class: "muted" }, "到点由服务端发出,关掉页面也会发。到点时 agent 还在忙的话,会排在当前这轮之后。"),
|
|
2519
|
+
);
|
|
2520
|
+
const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
|
|
2521
|
+
const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
2522
|
+
text.focus();
|
|
2523
|
+
go.onclick = async () => {
|
|
2524
|
+
const at = new Date(when.value).getTime();
|
|
2525
|
+
if (!text.value.trim()) return toast("内容不能为空");
|
|
2526
|
+
if (!Number.isFinite(at)) return toast("选一下发送时间");
|
|
2527
|
+
if (at <= Date.now()) return toast("发送时间要在现在之后");
|
|
2528
|
+
go.disabled = true;
|
|
2529
|
+
try {
|
|
2530
|
+
const key = await ensureConversation();
|
|
2531
|
+
// 先排新的再删旧的:中途失败也不会把原来那条弄丢
|
|
2532
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled`, { method: "POST", body: { text: text.value, at } });
|
|
2533
|
+
S.convs.set(info.key, info);
|
|
2534
|
+
if (edit) await cancelScheduled(edit.id, true);
|
|
2535
|
+
renderScheduled();
|
|
2536
|
+
// 用的是输入框里的草稿:排好了就清掉
|
|
2537
|
+
if (!edit && text.value === ui.input.value) {
|
|
2538
|
+
ui.input.value = "";
|
|
2539
|
+
autosize();
|
|
2540
|
+
updateSendBtn();
|
|
2541
|
+
}
|
|
2542
|
+
close();
|
|
2543
|
+
toast(`已排好,${fmtWhen(at)} 发送`);
|
|
2544
|
+
} catch (err) {
|
|
2545
|
+
go.disabled = false;
|
|
2546
|
+
toast(err.message);
|
|
2547
|
+
}
|
|
2548
|
+
};
|
|
2343
2549
|
}
|
|
2344
2550
|
|
|
2345
2551
|
// ---------- 语音输入(浏览器自带 Web Speech API) ----------
|
|
@@ -2682,12 +2888,15 @@ async function sendMessage() {
|
|
|
2682
2888
|
let key;
|
|
2683
2889
|
let res;
|
|
2684
2890
|
setSending(true);
|
|
2891
|
+
// 发出去之后恢复自动滚动;回显的那条用户消息到了就顶到最上面(见 applyEvent)
|
|
2892
|
+
S.pinNext = true;
|
|
2685
2893
|
try {
|
|
2686
2894
|
key = await ensureConversation();
|
|
2687
2895
|
if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
|
|
2688
2896
|
// 命令(比如 /status 要现读额度)可能比普通发送慢,多给点时间
|
|
2689
2897
|
res = await WS.request({ t: "send", key, text, uploads: S.uploads.map((u) => u.id) }, 45000);
|
|
2690
2898
|
} catch (err) {
|
|
2899
|
+
S.pinNext = false;
|
|
2691
2900
|
toast(err.message);
|
|
2692
2901
|
return;
|
|
2693
2902
|
} finally {
|
|
@@ -2703,9 +2912,12 @@ async function sendMessage() {
|
|
|
2703
2912
|
S.uploads = [];
|
|
2704
2913
|
renderChips();
|
|
2705
2914
|
updateSendBtn();
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2915
|
+
if (res?.reply) {
|
|
2916
|
+
// 命令不进会话记录,没有回显:直接贴到底看结果
|
|
2917
|
+
S.pinNext = false;
|
|
2918
|
+
await showCommandReply(text.trim(), res.reply, res.info);
|
|
2919
|
+
scrollToBottom();
|
|
2920
|
+
} else if (!ui.pinEl) ui.follow = true;
|
|
2709
2921
|
}
|
|
2710
2922
|
|
|
2711
2923
|
/** 会改变当前会话(换会话、换目录、换 agent)的命令,执行完要重拉一遍 */
|
|
@@ -5308,10 +5520,6 @@ async function systemTab(cfg, panel) {
|
|
|
5308
5520
|
|
|
5309
5521
|
// ---------- 启动 ----------
|
|
5310
5522
|
|
|
5311
|
-
boot()
|
|
5312
|
-
.then(() => {
|
|
5313
|
-
ui.messages?.addEventListener("scroll", () => (ui.stick = nearBottom()), { passive: true });
|
|
5314
|
-
})
|
|
5315
|
-
.catch((err) => {
|
|
5523
|
+
boot().catch((err) => {
|
|
5316
5524
|
fill($app, h("div", { class: "auth" }, h("div", { class: "auth-card" }, h("p", {}, `加载失败:${err.message}`))));
|
|
5317
5525
|
});
|
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 {
|
|
@@ -3319,3 +3359,102 @@ button.list-item:hover {
|
|
|
3319
3359
|
.in-desktop .side-top :is(button, a, input, select, textarea, label) {
|
|
3320
3360
|
-webkit-app-region: no-drag;
|
|
3321
3361
|
}
|
|
3362
|
+
|
|
3363
|
+
/* ---------- 定时发送 ---------- */
|
|
3364
|
+
|
|
3365
|
+
.sched-bar {
|
|
3366
|
+
display: flex;
|
|
3367
|
+
flex-direction: column;
|
|
3368
|
+
gap: 4px;
|
|
3369
|
+
margin: 0 14px 6px;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
.sched-bar[hidden] {
|
|
3373
|
+
display: none;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
.sched-item {
|
|
3377
|
+
display: flex;
|
|
3378
|
+
align-items: center;
|
|
3379
|
+
gap: 8px;
|
|
3380
|
+
min-width: 0;
|
|
3381
|
+
padding: 6px 6px 6px 10px;
|
|
3382
|
+
border: 1px solid var(--border);
|
|
3383
|
+
border-radius: 12px;
|
|
3384
|
+
background: var(--bg-soft);
|
|
3385
|
+
font-size: 13px;
|
|
3386
|
+
color: var(--fg-soft);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
.sched-item svg {
|
|
3390
|
+
width: 15px;
|
|
3391
|
+
height: 15px;
|
|
3392
|
+
flex-shrink: 0;
|
|
3393
|
+
color: var(--accent);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
.sched-when {
|
|
3397
|
+
flex-shrink: 0;
|
|
3398
|
+
color: var(--fg);
|
|
3399
|
+
font-weight: 500;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
.sched-text {
|
|
3403
|
+
flex: 1;
|
|
3404
|
+
min-width: 0;
|
|
3405
|
+
overflow: hidden;
|
|
3406
|
+
text-overflow: ellipsis;
|
|
3407
|
+
white-space: nowrap;
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
.sched-act {
|
|
3411
|
+
flex-shrink: 0;
|
|
3412
|
+
padding: 3px 8px;
|
|
3413
|
+
border-radius: 8px;
|
|
3414
|
+
font-size: 12px;
|
|
3415
|
+
color: var(--fg-soft);
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
.sched-act:hover {
|
|
3419
|
+
background: var(--bg-hover);
|
|
3420
|
+
color: var(--fg);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
.sched-input {
|
|
3424
|
+
width: 100%;
|
|
3425
|
+
box-sizing: border-box;
|
|
3426
|
+
resize: vertical;
|
|
3427
|
+
padding: 10px 12px;
|
|
3428
|
+
border: 1px solid var(--border);
|
|
3429
|
+
border-radius: 10px;
|
|
3430
|
+
background: var(--bg-input);
|
|
3431
|
+
color: var(--fg);
|
|
3432
|
+
font: inherit;
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
.sched-presets {
|
|
3436
|
+
display: flex;
|
|
3437
|
+
flex-wrap: wrap;
|
|
3438
|
+
gap: 6px;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
.sched-presets .btn {
|
|
3442
|
+
font-size: 13px;
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
.sched-row {
|
|
3446
|
+
display: flex;
|
|
3447
|
+
align-items: center;
|
|
3448
|
+
gap: 10px;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
.sched-time {
|
|
3452
|
+
flex: 1;
|
|
3453
|
+
padding: 7px 10px;
|
|
3454
|
+
border: 1px solid var(--border);
|
|
3455
|
+
border-radius: 10px;
|
|
3456
|
+
background: var(--bg-input);
|
|
3457
|
+
color: var(--fg);
|
|
3458
|
+
font: inherit;
|
|
3459
|
+
color-scheme: light dark;
|
|
3460
|
+
}
|