agent-loft 0.2.28 → 0.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/channels/web/server.ts +1 -1
- package/web/app.js +8 -5555
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +11 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +232 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +44 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +404 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +186 -0
- package/web/views/messages.js +510 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +455 -0
- package/web/views/slash.js +185 -0
- package/web/views/topbar.js +394 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { agentIcon, fill, h } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { desktop, desktopQuota, S, ui } from "../state.js";
|
|
5
|
+
|
|
6
|
+
// ---------- 侧栏底部的账号额度 ----------
|
|
7
|
+
// 服务端不主动查:只有前端请求 /api/quota 时才去问各 agent,结果缓存 60 秒
|
|
8
|
+
// (60 秒内再请求直接返回缓存;Claude 要临时拉一个 CLI 进程才问得到,不能太勤)。
|
|
9
|
+
// 前端什么时候请求:
|
|
10
|
+
// - 打开页面拉一次;
|
|
11
|
+
// - 空闲时每隔 S.quotaRefreshMin 分钟(设置 → 通用,默认 10,0 = 不自动刷新),只在页面可见时;
|
|
12
|
+
// - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
|
|
13
|
+
// - 切回前台:距上次超过一个刷新间隔就拉;
|
|
14
|
+
// - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
|
|
15
|
+
// 设置 → 通用里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
|
|
16
|
+
// 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
|
|
17
|
+
// 查好推过来;点一下额度区域是请桌面端去刷新。
|
|
18
|
+
|
|
19
|
+
export const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
|
|
20
|
+
const QUOTA_SERVER_CACHE = 60_000;
|
|
21
|
+
const quotaInterval = () => (S.quotaEnabled ? (S.quotaRefreshMin ?? 10) * 60_000 : 0);
|
|
22
|
+
|
|
23
|
+
export async function loadQuota() {
|
|
24
|
+
if (!S.quotaEnabled) {
|
|
25
|
+
quotaState.data = null;
|
|
26
|
+
renderQuota();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (desktopQuota) {
|
|
30
|
+
ui.quota?.classList.add("loading");
|
|
31
|
+
desktop.refreshQuota();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (quotaState.loading) return;
|
|
35
|
+
quotaState.loading = true;
|
|
36
|
+
ui.quota?.classList.add("loading");
|
|
37
|
+
try {
|
|
38
|
+
const data = await api("/api/quota");
|
|
39
|
+
quotaState.data = data.quotas;
|
|
40
|
+
quotaState.at = Date.now();
|
|
41
|
+
desktop?.setQuota(data.quotas);
|
|
42
|
+
} catch {
|
|
43
|
+
// 保留旧数据
|
|
44
|
+
} finally {
|
|
45
|
+
quotaState.loading = false;
|
|
46
|
+
ui.quota?.classList.remove("loading");
|
|
47
|
+
renderQuota();
|
|
48
|
+
scheduleIdleQuota();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 桌面端推来的额度。查失败(error)时保留旧数据 */
|
|
53
|
+
export function applyDesktopQuota(payload) {
|
|
54
|
+
if (Array.isArray(payload?.quotas) && !payload.error) {
|
|
55
|
+
quotaState.data = payload.quotas;
|
|
56
|
+
quotaState.at = payload.at || Date.now();
|
|
57
|
+
}
|
|
58
|
+
ui.quota?.classList.remove("loading");
|
|
59
|
+
renderQuota();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 空闲定时:每次拉完重新计时,所以刚因为跑完一轮拉过,就不会紧接着又拉 */
|
|
63
|
+
export function scheduleIdleQuota() {
|
|
64
|
+
clearTimeout(quotaState.idle);
|
|
65
|
+
if (desktopQuota || !quotaInterval()) return;
|
|
66
|
+
quotaState.idle = setTimeout(() => {
|
|
67
|
+
// 页面不可见就先不拉,等切回来再说
|
|
68
|
+
if (document.visibilityState === "visible") void loadQuota();
|
|
69
|
+
}, quotaInterval());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
|
|
73
|
+
export function scheduleQuota() {
|
|
74
|
+
// 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
|
|
75
|
+
if (desktopQuota || !S.quotaEnabled) return;
|
|
76
|
+
const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
|
|
77
|
+
clearTimeout(quotaState.timer);
|
|
78
|
+
quotaState.timer = setTimeout(() => void loadQuota(), wait);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
document.addEventListener("visibilitychange", () => {
|
|
82
|
+
if (desktopQuota || document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
|
|
83
|
+
if (Date.now() - quotaState.at >= quotaInterval()) void loadQuota();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function untilText(ms) {
|
|
87
|
+
const sec = Math.floor((ms - Date.now()) / 1000);
|
|
88
|
+
if (sec <= 0) return "即将重置";
|
|
89
|
+
const d = Math.floor(sec / 86400);
|
|
90
|
+
const hh = Math.floor((sec % 86400) / 3600);
|
|
91
|
+
const mm = Math.floor((sec % 3600) / 60);
|
|
92
|
+
if (d > 0) return `${d} 天 ${hh} 小时后重置`;
|
|
93
|
+
if (hh > 0) return `${hh} 小时 ${mm} 分后重置`;
|
|
94
|
+
return `${Math.max(1, mm)} 分钟后重置`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function quotaLevel(p) {
|
|
98
|
+
return p >= 90 ? "danger" : p >= 75 ? "warn" : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderQuota() {
|
|
102
|
+
if (!ui.quota) return;
|
|
103
|
+
const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
|
|
104
|
+
if (list.length === 0) return fill(ui.quota);
|
|
105
|
+
const rows = list.map((q) =>
|
|
106
|
+
h(
|
|
107
|
+
"div",
|
|
108
|
+
{ class: `quota-row${q.reached ? " reached" : ""}` },
|
|
109
|
+
agentIcon(q.agent, "agent-icon quota-icon"),
|
|
110
|
+
q.windows.slice(0, 2).map((w) =>
|
|
111
|
+
h(
|
|
112
|
+
"span",
|
|
113
|
+
{ class: `quota-cell ${quotaLevel(w.percent)}` },
|
|
114
|
+
h("span", { class: "q-label" }, w.short),
|
|
115
|
+
h("span", { class: "q-bar" }, h("i", { style: { width: `${Math.max(2, Math.min(100, w.percent))}%` } })),
|
|
116
|
+
h("span", { class: "q-pct" }, `${Math.round(w.percent)}%`),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
const tip = h(
|
|
122
|
+
"div",
|
|
123
|
+
{ class: "quota-tip" },
|
|
124
|
+
list.map((q) =>
|
|
125
|
+
h(
|
|
126
|
+
"div",
|
|
127
|
+
{ class: "qt-agent" },
|
|
128
|
+
h("div", { class: "qt-head" }, agentIcon(q.agent), h("b", {}, q.label), q.plan ? h("span", { class: "qt-plan" }, q.plan) : null),
|
|
129
|
+
q.windows.map((w) =>
|
|
130
|
+
h(
|
|
131
|
+
"div",
|
|
132
|
+
{ class: "qt-row" },
|
|
133
|
+
h("span", { class: "qt-name" }, w.label),
|
|
134
|
+
h("span", { class: `qt-pct ${quotaLevel(w.percent)}` }, `已用 ${Math.round(w.percent)}%`),
|
|
135
|
+
w.resetsAt ? h("span", { class: "qt-reset" }, untilText(w.resetsAt)) : null,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
q.extra ? h("div", { class: "qt-extra" }, q.extra) : null,
|
|
139
|
+
q.reached ? h("div", { class: "qt-extra danger" }, "已达上限,等窗口重置后才能继续") : null,
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
h("div", { class: "qt-foot" }, `${relTime(quotaState.at)}更新 · 点击刷新`),
|
|
143
|
+
);
|
|
144
|
+
fill(ui.quota, h("button", { class: "quota-btn", onclick: () => void loadQuota() }, rows), tip);
|
|
145
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { fill, h, icon, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { S, ui } from "../state.js";
|
|
4
|
+
import { quotaState } from "./quota.js";
|
|
5
|
+
import { modal } from "../lib/overlay.js";
|
|
6
|
+
import { autosize, ensureConversation, updateSendBtn } from "./composer.js";
|
|
7
|
+
import { currentAgentId, oneLine } from "./slash.js";
|
|
8
|
+
|
|
9
|
+
// ---------- 定时发送 ----------
|
|
10
|
+
// 额度用完、要等刷新才能接着干活时:先把下一条排好,到点由服务端发出(页面关了也会发)。只支持文字。
|
|
11
|
+
|
|
12
|
+
function fmtWhen(ms) {
|
|
13
|
+
const d = new Date(ms);
|
|
14
|
+
const now = new Date();
|
|
15
|
+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
16
|
+
const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
17
|
+
const diff = Math.round((day(d) - day(now)) / 86400000);
|
|
18
|
+
if (diff === 0) return `今天 ${hm}`;
|
|
19
|
+
if (diff === 1) return `明天 ${hm}`;
|
|
20
|
+
if (diff === 2) return `后天 ${hm}`;
|
|
21
|
+
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** <input type="datetime-local"> 要的本地时间格式 */
|
|
25
|
+
function toLocalInput(ms) {
|
|
26
|
+
const d = new Date(ms);
|
|
27
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
28
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 输入框上方:这条对话排着的定时消息,可以取消 */
|
|
32
|
+
export function renderScheduled() {
|
|
33
|
+
if (!ui.schedBar) return;
|
|
34
|
+
const list = (S.current && S.convs.get(S.current)?.scheduled) || [];
|
|
35
|
+
ui.schedBar.hidden = list.length === 0;
|
|
36
|
+
fill(ui.schedBar,
|
|
37
|
+
list.map((m) =>
|
|
38
|
+
h(
|
|
39
|
+
"div",
|
|
40
|
+
{ class: "sched-item", title: m.text },
|
|
41
|
+
icon("clock"),
|
|
42
|
+
h("span", { class: "sched-when" }, fmtWhen(m.at)),
|
|
43
|
+
h("span", { class: "sched-text" }, oneLine(m.text, 200)),
|
|
44
|
+
h("button", { class: "sched-act", title: "改时间或内容", onclick: () => openSchedule(m) }, "编辑"),
|
|
45
|
+
h("button", { class: "sched-act", title: "取消这条定时消息", onclick: () => cancelScheduled(m.id) }, "取消"),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function cancelScheduled(id, quiet) {
|
|
52
|
+
const key = S.current;
|
|
53
|
+
if (!key) return false;
|
|
54
|
+
try {
|
|
55
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
56
|
+
S.convs.set(info.key, info);
|
|
57
|
+
renderScheduled();
|
|
58
|
+
return true;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (!quiet) toast(err.message);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 选时间的弹窗。edit:改已经排着的那条(先删旧的再排新的) */
|
|
66
|
+
export function openSchedule(edit) {
|
|
67
|
+
if (!edit && S.uploads.length) return toast("定时发送暂不支持附件,先移除附件");
|
|
68
|
+
const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
|
|
69
|
+
text.value = edit ? edit.text : ui.input.value;
|
|
70
|
+
const when = h("input", { type: "datetime-local", class: "sched-time" });
|
|
71
|
+
// 当前 agent 的额度窗口(5 小时 / 7 天)什么时候重置:小字提示,默认排在 5 小时窗口重置后 5 分钟
|
|
72
|
+
const agent = currentAgentId();
|
|
73
|
+
const windows = (quotaState.data ?? []).find((q) => q.agent === agent)?.windows ?? [];
|
|
74
|
+
const resets = windows.filter((w) => /^(5h|7d)$/.test(w.short) && w.resetsAt > Date.now());
|
|
75
|
+
const session = resets.find((w) => w.short === "5h");
|
|
76
|
+
when.value = toLocalInput(edit ? edit.at : session ? session.resetsAt + 5 * 60_000 : Date.now() + 5 * 3600_000);
|
|
77
|
+
const body = h(
|
|
78
|
+
"div",
|
|
79
|
+
{ class: "modal-body sched-body" },
|
|
80
|
+
text,
|
|
81
|
+
h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
|
|
82
|
+
h(
|
|
83
|
+
"div",
|
|
84
|
+
{ class: "muted sched-hint" },
|
|
85
|
+
resets.length
|
|
86
|
+
? `额度重置:${resets.map((w) => `${w.short === "5h" ? "5 小时" : "7 天"}额度 ${fmtWhen(w.resetsAt)}`).join(",")}。`
|
|
87
|
+
: "",
|
|
88
|
+
"到点由服务端发出,关掉页面也会发。",
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
|
|
92
|
+
const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
93
|
+
text.focus();
|
|
94
|
+
go.onclick = async () => {
|
|
95
|
+
const at = new Date(when.value).getTime();
|
|
96
|
+
if (!text.value.trim()) return toast("内容不能为空");
|
|
97
|
+
if (!Number.isFinite(at)) return toast("选一下发送时间");
|
|
98
|
+
if (at <= Date.now()) return toast("发送时间要在现在之后");
|
|
99
|
+
go.disabled = true;
|
|
100
|
+
try {
|
|
101
|
+
const key = await ensureConversation();
|
|
102
|
+
// 先排新的再删旧的:中途失败也不会把原来那条弄丢
|
|
103
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled`, { method: "POST", body: { text: text.value, at } });
|
|
104
|
+
S.convs.set(info.key, info);
|
|
105
|
+
if (edit) await cancelScheduled(edit.id, true);
|
|
106
|
+
renderScheduled();
|
|
107
|
+
// 用的是输入框里的草稿:排好了就清掉
|
|
108
|
+
if (!edit && text.value === ui.input.value) {
|
|
109
|
+
ui.input.value = "";
|
|
110
|
+
autosize();
|
|
111
|
+
updateSendBtn();
|
|
112
|
+
}
|
|
113
|
+
close();
|
|
114
|
+
toast(`已排好,${fmtWhen(at)} 发送`);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
go.disabled = false;
|
|
117
|
+
toast(err.message);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|