agent-loft 0.2.29 → 0.2.31
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 +5 -0
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +3 -2
- package/src/agents/claude/session.ts +11 -4
- package/src/agents/codex/session.ts +9 -3
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +4 -0
- package/src/channels/web/server.ts +14 -2
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +14 -14
- package/src/core/hub.ts +95 -16
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +3 -1
- package/src/core/store.ts +2 -0
- package/src/core/task-prompt.ts +38 -0
- package/src/core/task-runner.ts +232 -0
- package/src/core/task-schedule.ts +250 -0
- package/src/core/tasks-bridge.ts +252 -0
- package/src/core/tasks-mcp.ts +55 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +2 -0
- package/src/main.ts +8 -0
- package/src/mcp/tasks-server.ts +206 -0
- package/web/app.js +8 -5560
- 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/tasks.css +455 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +12 -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 +236 -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 +46 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +412 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +197 -0
- package/web/views/messages.js +531 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +458 -0
- package/web/views/slash.js +185 -0
- package/web/views/tasks.js +460 -0
- package/web/views/topbar.js +400 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
package/web/lib/dom.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ---------- 小工具 ----------
|
|
2
|
+
|
|
3
|
+
export const $app = document.getElementById("app");
|
|
4
|
+
export const isTouch = matchMedia("(pointer: coarse)").matches;
|
|
5
|
+
|
|
6
|
+
export function h(tag, attrs, ...children) {
|
|
7
|
+
const el = document.createElement(tag);
|
|
8
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
9
|
+
if (v === undefined || v === null || v === false) continue;
|
|
10
|
+
if (k === "class") el.className = v;
|
|
11
|
+
else if (k === "html") el.innerHTML = v;
|
|
12
|
+
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
|
13
|
+
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
|
14
|
+
else el.setAttribute(k, v === true ? "" : v);
|
|
15
|
+
}
|
|
16
|
+
for (const c of children.flat(Infinity)) {
|
|
17
|
+
if (c === null || c === undefined || c === false) continue;
|
|
18
|
+
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
19
|
+
}
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** replaceChildren 会把 null 变成字面量 "null",统一过滤掉 */
|
|
24
|
+
export function fill(el, ...kids) {
|
|
25
|
+
el.replaceChildren(...kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ICON = {
|
|
29
|
+
menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>',
|
|
30
|
+
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
|
|
31
|
+
compose: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a2 2 0 0 1 2.9 2.9L12.5 14.3 9 15l.7-3.5Z"/></svg>',
|
|
32
|
+
send: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
|
|
33
|
+
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
|
|
34
|
+
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>',
|
|
35
|
+
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>',
|
|
36
|
+
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>',
|
|
37
|
+
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>',
|
|
38
|
+
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>',
|
|
39
|
+
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>',
|
|
40
|
+
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>',
|
|
41
|
+
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
|
42
|
+
x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
|
|
43
|
+
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
44
|
+
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>',
|
|
45
|
+
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h7.4A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z"/><path d="M3 10h18"/></svg>',
|
|
46
|
+
folderOpen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17.5V7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h6.4A2.5 2.5 0 0 1 20 9.7V11"/><path d="M3 17.5 5.6 12a2 2 0 0 1 1.8-1.1h13a1 1 0 0 1 .9 1.4l-2.4 6.2a2.2 2.2 0 0 1-2 1.5H5.5A2.5 2.5 0 0 1 3 17.5Z"/></svg>',
|
|
47
|
+
laptop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/></svg>',
|
|
48
|
+
logo: '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 8.5a8 8 0 0 1 13.2 0 8 8 0 0 1 9 9.4 8 8 0 0 1 0 12.2 8 8 0 0 1-9 9.4 8 8 0 0 1-13.2 0 8 8 0 0 1-9-9.4 8 8 0 0 1 0-12.2 8 8 0 0 1 9-9.4Z"/><path d="m17 18 5 5-5 5M25 30h7"/></svg>',
|
|
49
|
+
feishu:
|
|
50
|
+
'<svg viewBox="2 4 44 40"><path fill="#00D6B9" d="M11.6 7H27.6C30.9 9.9 32.8 14.7 33.6 17C30.9 18.3 29 20.6 27 24C22.6 16.4 15.6 11 11.6 7Z"/><path fill="#133C9A" d="M17 29C21 29 25 26.9 28 23.4C36 14 41.4 16.8 44 18C38.5 21 40.5 29.6 33 36C27.5 34.8 21.8 32.2 17 29Z"/><path fill="#3370FF" d="M4 16.2C7.2 20.8 12.4 26 17.4 29.4C23 33.2 28.6 35.7 34 35.2C28.9 39.8 23.8 41 19 41C12.5 41 6.9 37.8 4 35.4Z"/></svg>',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Claude 的放射状标志:长短不一的圆头射线 */
|
|
54
|
+
ICON.claude = (() => {
|
|
55
|
+
const lens = [10, 8.2, 9.4, 7.6, 10.4, 8.4, 9.2, 7.8, 10, 8.6, 9.6, 7.4];
|
|
56
|
+
const rays = lens
|
|
57
|
+
.map((len, i) => {
|
|
58
|
+
const a = (i / lens.length) * Math.PI * 2 + 0.12;
|
|
59
|
+
const x = (r) => (12 + Math.cos(a) * r).toFixed(2);
|
|
60
|
+
const y = (r) => (12 + Math.sin(a) * r).toFixed(2);
|
|
61
|
+
return `M${x(1.6)} ${y(1.6)}L${x(len)} ${y(len)}`;
|
|
62
|
+
})
|
|
63
|
+
.join("");
|
|
64
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="#D97757" stroke-width="2.3" stroke-linecap="round"><path d="${rays}"/></svg>`;
|
|
65
|
+
})();
|
|
66
|
+
ICON.codex = ICON.logo;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 星星加载动画(回复末尾、打开会话时用)。开屏那个带台面的完整 logo 动画直接写在 index.html 里。
|
|
70
|
+
* 每个实例的渐变 id 唯一,避免页面里有隐藏的同 id 渐变时画不出来。
|
|
71
|
+
* 回复流式输出时整条消息会反复重建,动画按全局时钟设负的起始延迟,重建后接着播、不会一抖一抖地重来。
|
|
72
|
+
*/
|
|
73
|
+
let loaderSeq = 0;
|
|
74
|
+
|
|
75
|
+
/** 只有 logo 中间那颗大星,一边旋转一边放大缩小 */
|
|
76
|
+
const STARS_MS = 1800;
|
|
77
|
+
export function starsLoader(cls = "") {
|
|
78
|
+
const id = `loft-g${++loaderSeq}`;
|
|
79
|
+
const el = h("span", {
|
|
80
|
+
class: `loft-stars${cls ? ` ${cls}` : ""}`,
|
|
81
|
+
role: "img",
|
|
82
|
+
"aria-label": "回复中",
|
|
83
|
+
html: `<svg viewBox="180 82 152 152" aria-hidden="true"><defs><linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="180" y1="82" x2="332" y2="234"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs><path d="M256 82C268.92 145.08 268.92 145.08 332 158C268.92 170.92 268.92 170.92 256 234C243.08 170.92 243.08 170.92 180 158C243.08 145.08 243.08 145.08 256 82Z" fill="url(#${id})"/></svg>`,
|
|
84
|
+
});
|
|
85
|
+
el.style.setProperty("--t", `${-(performance.now() % STARS_MS).toFixed(0)}ms`);
|
|
86
|
+
return el;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function agentIcon(id, cls = "agent-icon") {
|
|
90
|
+
return h("span", { class: cls, html: ICON[id] ?? ICON.laptop });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function icon(name) {
|
|
94
|
+
return h("span", { html: ICON[name], style: { display: "inline-grid" } });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toast(text, ms = 2600) {
|
|
98
|
+
const el = h("div", { class: "toast" }, text);
|
|
99
|
+
document.body.append(el);
|
|
100
|
+
setTimeout(() => el.remove(), ms);
|
|
101
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import DOMPurify from "/vendor/purify.js";
|
|
2
|
+
import hljs from "/vendor/hljs.js";
|
|
3
|
+
import { marked } from "/vendor/marked.js";
|
|
4
|
+
import { h, toast } from "./dom.js";
|
|
5
|
+
|
|
6
|
+
// ---------- markdown ----------
|
|
7
|
+
|
|
8
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
9
|
+
|
|
10
|
+
export function renderMarkdown(text) {
|
|
11
|
+
const html = DOMPurify.sanitize(marked.parse(text ?? ""), { ADD_ATTR: ["target"] });
|
|
12
|
+
const div = h("div", { class: "md", html });
|
|
13
|
+
for (const a of div.querySelectorAll("a")) {
|
|
14
|
+
a.target = "_blank";
|
|
15
|
+
a.rel = "noopener noreferrer";
|
|
16
|
+
}
|
|
17
|
+
for (const pre of div.querySelectorAll("pre")) {
|
|
18
|
+
const code = pre.querySelector("code");
|
|
19
|
+
if (!code) continue;
|
|
20
|
+
const lang = /language-([\w+-]+)/.exec(code.className)?.[1] ?? "";
|
|
21
|
+
try {
|
|
22
|
+
if (lang && hljs.getLanguage(lang)) hljs.highlightElement(code);
|
|
23
|
+
else if (code.textContent.length < 20000) {
|
|
24
|
+
code.innerHTML = hljs.highlightAuto(code.textContent).value;
|
|
25
|
+
code.classList.add("hljs");
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// 高亮失败就原样显示
|
|
29
|
+
}
|
|
30
|
+
const copy = h("button", { class: "copy-btn", onclick: () => copyText(code.textContent, copy) }, "复制");
|
|
31
|
+
pre.prepend(h("div", { class: "code-head" }, h("span", {}, lang || "code"), copy));
|
|
32
|
+
}
|
|
33
|
+
return div;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function copyText(text, btn) {
|
|
37
|
+
try {
|
|
38
|
+
await navigator.clipboard.writeText(text);
|
|
39
|
+
} catch {
|
|
40
|
+
const ta = h("textarea", { style: { position: "fixed", opacity: "0" } });
|
|
41
|
+
ta.value = text;
|
|
42
|
+
document.body.append(ta);
|
|
43
|
+
ta.select();
|
|
44
|
+
document.execCommand("copy");
|
|
45
|
+
ta.remove();
|
|
46
|
+
}
|
|
47
|
+
if (btn) {
|
|
48
|
+
const old = btn.textContent;
|
|
49
|
+
btn.textContent = "已复制";
|
|
50
|
+
setTimeout(() => (btn.textContent = old), 1200);
|
|
51
|
+
} else toast("已复制");
|
|
52
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { h, icon } from "./dom.js";
|
|
2
|
+
import { ui } from "../state.js";
|
|
3
|
+
|
|
4
|
+
export function showMenu(anchor, items) {
|
|
5
|
+
closeMenu();
|
|
6
|
+
const menu = h(
|
|
7
|
+
"div",
|
|
8
|
+
{ class: "menu" },
|
|
9
|
+
items.map((it) =>
|
|
10
|
+
it === "sep"
|
|
11
|
+
? h("div", { class: "sep" })
|
|
12
|
+
: h(
|
|
13
|
+
"button",
|
|
14
|
+
{
|
|
15
|
+
class: it.danger ? "danger" : "",
|
|
16
|
+
onclick: () => {
|
|
17
|
+
closeMenu();
|
|
18
|
+
it.onclick();
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
it.label,
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
document.body.append(menu);
|
|
26
|
+
// anchor 可以是元素(贴着它下沿弹出),也可以是 {x, y}(右键菜单,贴着鼠标弹出)
|
|
27
|
+
const r = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y };
|
|
28
|
+
const w = menu.offsetWidth;
|
|
29
|
+
menu.style.top = `${Math.max(8, Math.min(r.bottom + (anchor instanceof Element ? 4 : 0), innerHeight - menu.offsetHeight - 8))}px`;
|
|
30
|
+
menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - w - 8))}px`;
|
|
31
|
+
ui.menu = menu;
|
|
32
|
+
setTimeout(() => {
|
|
33
|
+
document.addEventListener("pointerdown", onOutsideMenu);
|
|
34
|
+
document.addEventListener("keydown", onMenuKey);
|
|
35
|
+
addEventListener("blur", closeMenu);
|
|
36
|
+
}, 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function onMenuKey(e) {
|
|
40
|
+
if (e.key === "Escape") closeMenu();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function onOutsideMenu(e) {
|
|
44
|
+
// 点的是打开它的那个按钮:交给按钮自己的 onclick 去收起,免得先关再开
|
|
45
|
+
if (ui.menu && !ui.menu.contains(e.target) && !ui.menu.anchor?.contains(e.target)) closeMenu();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function closeMenu() {
|
|
49
|
+
ui.menu?.remove();
|
|
50
|
+
ui.menu = null;
|
|
51
|
+
document.removeEventListener("pointerdown", onOutsideMenu);
|
|
52
|
+
document.removeEventListener("keydown", onMenuKey);
|
|
53
|
+
removeEventListener("blur", closeMenu);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function modal({ title, body, foot, wide, tall }) {
|
|
57
|
+
const close = () => overlay.remove();
|
|
58
|
+
const overlay = h(
|
|
59
|
+
"div",
|
|
60
|
+
{ class: "overlay", onclick: (e) => e.target === overlay && close() },
|
|
61
|
+
h(
|
|
62
|
+
"div",
|
|
63
|
+
{ class: `modal${wide ? " wide" : ""}${tall ? " tall" : ""}` },
|
|
64
|
+
h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: close }, icon("x"))),
|
|
65
|
+
body,
|
|
66
|
+
foot ?? null,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
document.body.append(overlay);
|
|
70
|
+
return close;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 贴着按钮弹出的小面板(默认在按钮上方、右对齐),点外面关闭。复用 .menu 的关闭逻辑 */
|
|
74
|
+
export function showPopover(anchor, { place = "above", align = "right" } = {}) {
|
|
75
|
+
closeMenu();
|
|
76
|
+
const pop = h("div", { class: "menu popover" });
|
|
77
|
+
pop.anchor = anchor;
|
|
78
|
+
pop.dataset.place = place;
|
|
79
|
+
pop.dataset.align = align;
|
|
80
|
+
document.body.append(pop);
|
|
81
|
+
ui.menu = pop;
|
|
82
|
+
setTimeout(() => document.addEventListener("pointerdown", onOutsideMenu), 0);
|
|
83
|
+
return pop;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function placePopover(pop, anchor) {
|
|
87
|
+
if (!pop.isConnected || !anchor.isConnected) return;
|
|
88
|
+
const r = anchor.getBoundingClientRect();
|
|
89
|
+
const w = pop.offsetWidth;
|
|
90
|
+
const left = pop.dataset.align === "left" ? r.left : r.right - w;
|
|
91
|
+
pop.style.left = `${Math.max(8, Math.min(left, innerWidth - w - 8))}px`;
|
|
92
|
+
if (pop.dataset.place === "below") {
|
|
93
|
+
pop.style.top = `${Math.min(r.bottom + 6, innerHeight - pop.offsetHeight - 8)}px`;
|
|
94
|
+
pop.style.bottom = "";
|
|
95
|
+
} else {
|
|
96
|
+
pop.style.bottom = `${innerHeight - r.top + 6}px`;
|
|
97
|
+
pop.style.top = "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 确认弹窗(替代浏览器自带的 confirm),样式和重命名弹窗一致。
|
|
103
|
+
* 回车确认、Esc / 点外面取消;resolve 成 true / false
|
|
104
|
+
*/
|
|
105
|
+
/** altText:给了就多一个按钮,点它返回 "alt"(确定返回 true,取消返回 false) */
|
|
106
|
+
export function confirmDialog({ title, message, sub, confirmText = "确定", danger = false, altText }) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const ok = h("button", { class: `btn ${danger ? "danger-fill" : "primary"}` }, confirmText);
|
|
109
|
+
const cancel = h("button", { class: "btn ghost" }, "取消");
|
|
110
|
+
const alt = altText ? h("button", { class: "btn", onclick: () => done("alt") }, altText) : null;
|
|
111
|
+
const done = (v) => {
|
|
112
|
+
overlay.classList.add("leaving");
|
|
113
|
+
setTimeout(() => overlay.remove(), 120);
|
|
114
|
+
document.removeEventListener("keydown", onKey, true);
|
|
115
|
+
resolve(v);
|
|
116
|
+
};
|
|
117
|
+
const onKey = (e) => {
|
|
118
|
+
if (e.key === "Escape") {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
done(false);
|
|
121
|
+
} else if (e.key === "Enter" && !e.isComposing) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
done(true);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
ok.onclick = () => done(true);
|
|
127
|
+
cancel.onclick = () => done(false);
|
|
128
|
+
const overlay = h(
|
|
129
|
+
"div",
|
|
130
|
+
{ class: "overlay dialog-overlay", onclick: (e) => e.target === overlay && done(false) },
|
|
131
|
+
h(
|
|
132
|
+
"div",
|
|
133
|
+
{ class: "dialog confirm-dialog" },
|
|
134
|
+
h("h3", {}, title),
|
|
135
|
+
sub ? h("p", { class: "dialog-sub" }, sub) : null,
|
|
136
|
+
message ? h("p", { class: "dialog-msg" }, message) : null,
|
|
137
|
+
h("div", { class: "dialog-foot" }, cancel, alt, ok),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
document.body.append(overlay);
|
|
141
|
+
document.addEventListener("keydown", onKey, true);
|
|
142
|
+
ok.focus();
|
|
143
|
+
});
|
|
144
|
+
}
|
package/web/lib/push.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { api } from "./api.js";
|
|
2
|
+
import { desktop } from "../state.js";
|
|
3
|
+
|
|
4
|
+
// ---------- 系统通知(Web Push) ----------
|
|
5
|
+
|
|
6
|
+
export function pushSupport() {
|
|
7
|
+
const ios = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
8
|
+
const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
|
|
9
|
+
if (desktop) return { ok: false, why: "桌面端会直接弹出 macOS 系统通知,不需要在这里开启。" };
|
|
10
|
+
if (!window.isSecureContext) return { ok: false, why: "通知需要 HTTPS 访问(localhost 除外)。请通过 HTTPS 的反向代理地址打开。" };
|
|
11
|
+
if (ios && !standalone) {
|
|
12
|
+
return { ok: false, why: "iPhone / iPad 上要先在 Safari 里点「分享 → 添加到主屏幕」,再从桌面图标打开,才能开启通知(需 iOS 16.4+)。" };
|
|
13
|
+
}
|
|
14
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
|
|
15
|
+
return { ok: false, why: "这个浏览器不支持网页推送通知。" };
|
|
16
|
+
}
|
|
17
|
+
return { ok: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function swReady(ms = 8000) {
|
|
21
|
+
return Promise.race([
|
|
22
|
+
navigator.serviceWorker.ready,
|
|
23
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Service Worker 没有就绪,刷新页面再试")), ms)),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function currentPushSub() {
|
|
28
|
+
if (!pushSupport().ok) return null;
|
|
29
|
+
try {
|
|
30
|
+
return await (await swReady()).pushManager.getSubscription();
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function enablePush() {
|
|
37
|
+
// 必须在点击里直接请求权限,iOS 不认异步之后再弹的
|
|
38
|
+
const permission = await Notification.requestPermission();
|
|
39
|
+
if (permission !== "granted") throw new Error(permission === "denied" ? "通知权限被拒绝了,请到系统设置里给这个应用打开通知" : "没有授予通知权限");
|
|
40
|
+
const { publicKey } = await api("/api/push");
|
|
41
|
+
const reg = await swReady();
|
|
42
|
+
let sub = await reg.pushManager.getSubscription();
|
|
43
|
+
if (!sub) {
|
|
44
|
+
const raw = atob(publicKey.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(publicKey.length / 4) * 4, "="));
|
|
45
|
+
sub = await reg.pushManager.subscribe({
|
|
46
|
+
userVisibleOnly: true,
|
|
47
|
+
applicationServerKey: Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
await api("/api/push/subscribe", { method: "POST", body: { subscription: sub.toJSON() } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 这台设备不再收通知:服务端删掉订阅,浏览器也退订 */
|
|
54
|
+
export async function dropPushSub() {
|
|
55
|
+
const sub = await currentPushSub();
|
|
56
|
+
if (!sub) return;
|
|
57
|
+
await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: sub.endpoint } }).catch(() => {});
|
|
58
|
+
await sub.unsubscribe().catch(() => {});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 登录后:这个浏览器上的通知订阅如果不是当前账号的(上一个人没退出就换了账号),
|
|
63
|
+
* 退订掉 —— 推送服务随后会让服务端那条订阅失效,上一个人的通知就不会再推到这里。
|
|
64
|
+
*/
|
|
65
|
+
export async function releaseForeignPushSub() {
|
|
66
|
+
const sub = await currentPushSub();
|
|
67
|
+
if (!sub) return;
|
|
68
|
+
const { subscriptions } = await api("/api/push");
|
|
69
|
+
if (!subscriptions.some((d) => d.endpoint === sub.endpoint)) await sub.unsubscribe().catch(() => {});
|
|
70
|
+
}
|
package/web/lib/time.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** 用时按大小换单位:12.3秒 / 3分12秒 / 1小时5分(和服务端 formatDuration 一致) */
|
|
2
|
+
function formatDuration(ms) {
|
|
3
|
+
const sec = ms / 1000;
|
|
4
|
+
if (sec < 60) return `${sec.toFixed(1)}秒`;
|
|
5
|
+
const total = Math.round(sec);
|
|
6
|
+
const hr = Math.floor(total / 3600);
|
|
7
|
+
const m = Math.floor((total % 3600) / 60);
|
|
8
|
+
const s = total % 60;
|
|
9
|
+
if (hr > 0) return `${hr}小时${m ? `${m}分` : ""}`;
|
|
10
|
+
return `${m}分${s ? `${s}秒` : ""}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 回复进行中的实时用时:整秒跳动,12秒 / 3分05秒 / 1小时05分 */
|
|
14
|
+
export function liveDuration(ms) {
|
|
15
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
16
|
+
const hr = Math.floor(total / 3600);
|
|
17
|
+
const m = Math.floor((total % 3600) / 60);
|
|
18
|
+
const s = total % 60;
|
|
19
|
+
if (hr > 0) return `${hr}小时${String(m).padStart(2, "0")}分`;
|
|
20
|
+
if (m > 0) return `${m}分${String(s).padStart(2, "0")}秒`;
|
|
21
|
+
return `${s}秒`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 服务端时间 - 本机时间。事件里的 ts 是服务端时钟,算用时要换算过来,免得两边时钟不准 */
|
|
25
|
+
let clockSkew = 0;
|
|
26
|
+
export const serverNow = () => Date.now() + clockSkew;
|
|
27
|
+
export const setServerTime = (serverTime) => (clockSkew = serverTime - Date.now());
|
|
28
|
+
|
|
29
|
+
/** 所有进行中的用时一秒刷一次,只改文字,不重建消息 */
|
|
30
|
+
setInterval(() => {
|
|
31
|
+
for (const el of document.querySelectorAll(".ai-elapsed[data-start]")) {
|
|
32
|
+
el.textContent = liveDuration(serverNow() - Number(el.dataset.start));
|
|
33
|
+
}
|
|
34
|
+
}, 1000);
|
|
35
|
+
|
|
36
|
+
/** 老版本记下的脚注是「用时 1234.5s」,显示时也换成时 / 分 / 秒 */
|
|
37
|
+
export function tidyFooter(footer) {
|
|
38
|
+
return footer?.replace(/用时 ([\d.]+)s/, (_, s) => `用时 ${formatDuration(Number(s) * 1000)}`) ?? "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 回复下面的时间:今天只显示时分,今年显示月日,再早带上年份 */
|
|
42
|
+
export function clockTime(ts) {
|
|
43
|
+
const d = new Date(ts);
|
|
44
|
+
const now = new Date();
|
|
45
|
+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
46
|
+
if (d.toDateString() === now.toDateString()) return hm;
|
|
47
|
+
const md = `${d.getMonth() + 1}月${d.getDate()}日`;
|
|
48
|
+
return d.getFullYear() === now.getFullYear() ? `${md} ${hm}` : `${d.getFullYear()}年${md} ${hm}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function relTime(ts) {
|
|
52
|
+
const m = Math.floor((Date.now() - ts) / 60000);
|
|
53
|
+
if (m < 1) return "刚刚";
|
|
54
|
+
if (m < 60) return `${m} 分钟前`;
|
|
55
|
+
const hr = Math.floor(m / 60);
|
|
56
|
+
if (hr < 24) return `${hr} 小时前`;
|
|
57
|
+
const d = Math.floor(hr / 24);
|
|
58
|
+
if (d < 30) return `${d} 天前`;
|
|
59
|
+
return new Date(ts).toLocaleDateString("zh-CN");
|
|
60
|
+
}
|
package/web/lib/ws.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { h } from "./dom.js";
|
|
2
|
+
import { api } from "./api.js";
|
|
3
|
+
import { desktop, S, ui } from "../state.js";
|
|
4
|
+
import { showAuth } from "../views/auth.js";
|
|
5
|
+
import { renderSidebar, scheduleSidebar } from "../views/sidebar.js";
|
|
6
|
+
import { scheduleQuota } from "../views/quota.js";
|
|
7
|
+
import { renderTopbar } from "../views/topbar.js";
|
|
8
|
+
import { applyEvent, openConversation } from "../views/messages.js";
|
|
9
|
+
import { upsertInteraction } from "../views/interaction.js";
|
|
10
|
+
import { setBusy } from "../views/composer.js";
|
|
11
|
+
import { renderScheduled } from "../views/schedule.js";
|
|
12
|
+
|
|
13
|
+
// ---------- WebSocket ----------
|
|
14
|
+
|
|
15
|
+
export const WS = {
|
|
16
|
+
sock: null,
|
|
17
|
+
rid: 0,
|
|
18
|
+
waiters: new Map(),
|
|
19
|
+
backoff: 500,
|
|
20
|
+
connected: false,
|
|
21
|
+
pingTimer: null,
|
|
22
|
+
|
|
23
|
+
stopped: false,
|
|
24
|
+
epoch: null,
|
|
25
|
+
hellos: 0,
|
|
26
|
+
|
|
27
|
+
connect() {
|
|
28
|
+
if (this.stopped) return;
|
|
29
|
+
if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return;
|
|
30
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
31
|
+
const sock = new WebSocket(`${proto}://${location.host}/ws`);
|
|
32
|
+
this.sock = sock;
|
|
33
|
+
sock.onopen = () => {
|
|
34
|
+
this.connected = true;
|
|
35
|
+
this.backoff = 500;
|
|
36
|
+
setConnBanner(null);
|
|
37
|
+
clearInterval(this.pingTimer);
|
|
38
|
+
this.pingTimer = setInterval(() => this.raw({ t: "ping" }), 25000);
|
|
39
|
+
};
|
|
40
|
+
sock.onmessage = (e) => onWsMessage(JSON.parse(e.data));
|
|
41
|
+
sock.onclose = async (e) => {
|
|
42
|
+
this.connected = false;
|
|
43
|
+
clearInterval(this.pingTimer);
|
|
44
|
+
for (const w of this.waiters.values()) w.reject(new Error("连接已断开"));
|
|
45
|
+
this.waiters.clear();
|
|
46
|
+
if (this.sock !== sock) return;
|
|
47
|
+
// 登录已失效(改了密码、被踢下线):回登录页,不再重连
|
|
48
|
+
if (e.code === 4401) {
|
|
49
|
+
this.stopped = true;
|
|
50
|
+
showAuth();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (this.stopped) return;
|
|
54
|
+
// 握手被拒(401)在浏览器里只表现为 1006:问一下是不是登录态没了
|
|
55
|
+
try {
|
|
56
|
+
const me = await (await fetch("/api/me", { credentials: "same-origin" })).json();
|
|
57
|
+
if (!me.authed) {
|
|
58
|
+
this.stopped = true;
|
|
59
|
+
showAuth();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// 网络不通,照常重连
|
|
64
|
+
}
|
|
65
|
+
if (document.visibilityState === "visible") setConnBanner("连接已断开,正在重连…");
|
|
66
|
+
setTimeout(() => this.connect(), this.backoff);
|
|
67
|
+
this.backoff = Math.min(this.backoff * 2, 10000);
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
raw(msg) {
|
|
72
|
+
if (this.sock?.readyState === 1) this.sock.send(JSON.stringify(msg));
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
request(msg, timeoutMs = 15000) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
if (this.sock?.readyState !== 1) {
|
|
78
|
+
reject(new Error("还没连上服务器,稍等再试"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const rid = ++this.rid;
|
|
82
|
+
this.waiters.set(rid, { resolve, reject });
|
|
83
|
+
this.sock.send(JSON.stringify({ ...msg, rid }));
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
if (this.waiters.delete(rid)) reject(new Error("服务器没有响应"));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
subscribe(key, since) {
|
|
91
|
+
this.raw({ t: "sub", key, since, epoch: this.epoch });
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** 告诉服务器我是否在前台、正看着哪条会话:正看着的会话不推系统通知 */
|
|
95
|
+
presence() {
|
|
96
|
+
const visible = document.visibilityState === "visible" && document.hasFocus();
|
|
97
|
+
this.raw({ t: "presence", visible, key: S.current ?? "" });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const ev of ["focus", "blur"]) window.addEventListener(ev, () => WS.presence());
|
|
102
|
+
|
|
103
|
+
/** 打开 / 切回页面:清掉桌面图标角标和计数(计数由 sw.js 在收到推送时累加) */
|
|
104
|
+
export function clearAppBadge() {
|
|
105
|
+
if (document.visibilityState !== "visible") return;
|
|
106
|
+
navigator.clearAppBadge?.().catch(() => {});
|
|
107
|
+
if ("caches" in window) caches.delete("aim-badge").catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
document.addEventListener("visibilitychange", () => {
|
|
111
|
+
WS.presence();
|
|
112
|
+
clearAppBadge();
|
|
113
|
+
// 手机切回前台:连接多半已经被系统掐了,立刻重连而不是等退避
|
|
114
|
+
if (document.visibilityState === "visible" && WS.sock?.readyState !== 1) {
|
|
115
|
+
WS.backoff = 300;
|
|
116
|
+
WS.connect();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** 重连后:以服务器上的会话列表和待处理授权为准,清掉本地已失效的 */
|
|
121
|
+
async function resync() {
|
|
122
|
+
try {
|
|
123
|
+
const data = await api("/api/bootstrap");
|
|
124
|
+
S.convs = new Map(data.conversations.map((c) => [c.key, c]));
|
|
125
|
+
const live = new Set(data.pending.map((i) => i.id));
|
|
126
|
+
for (const ix of S.pending.values()) {
|
|
127
|
+
// 快照生成之后才出现的授权,快照里当然没有,不能当成「已在别处处理」
|
|
128
|
+
if (!ix.settled && !live.has(ix.id) && ix.createdAt <= data.serverTime) markGone(ix);
|
|
129
|
+
}
|
|
130
|
+
for (const ix of data.pending) upsertInteraction(ix);
|
|
131
|
+
renderSidebar();
|
|
132
|
+
} catch {
|
|
133
|
+
// 下次重连再说
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 本地以为还挂着、服务器上已经没了的授权:断线期间在别处处理掉了,或者服务重启过 */
|
|
138
|
+
function markGone(ix) {
|
|
139
|
+
upsertInteraction({ ...ix, settled: { settlement: "gone", by: "已在别处处理或已失效", at: Date.now() } });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onWsMessage(msg) {
|
|
143
|
+
switch (msg.t) {
|
|
144
|
+
case "hello": {
|
|
145
|
+
if (checkServerVersion(msg.version)) return;
|
|
146
|
+
const restarted = WS.epoch && WS.epoch !== msg.epoch;
|
|
147
|
+
WS.epoch = msg.epoch;
|
|
148
|
+
WS.hellos += 1;
|
|
149
|
+
WS.presence();
|
|
150
|
+
if (restarted) {
|
|
151
|
+
// 服务重启过:seq 从头计数了,旧游标全部作废,当前会话整页重拉
|
|
152
|
+
S.seq.clear();
|
|
153
|
+
if (S.current) void openConversation(S.current);
|
|
154
|
+
} else if (S.current) {
|
|
155
|
+
WS.subscribe(S.current, S.seq.get(S.current) ?? 0);
|
|
156
|
+
}
|
|
157
|
+
if (WS.hellos > 1 || restarted) void resync();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "ack": {
|
|
161
|
+
const w = WS.waiters.get(msg.rid);
|
|
162
|
+
if (!w) return;
|
|
163
|
+
WS.waiters.delete(msg.rid);
|
|
164
|
+
if (msg.ok) w.resolve(msg);
|
|
165
|
+
else w.reject(new Error(msg.error ?? "操作失败"));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
case "ev":
|
|
169
|
+
applyEvent(msg);
|
|
170
|
+
return;
|
|
171
|
+
case "replay":
|
|
172
|
+
if (msg.key !== S.current) return;
|
|
173
|
+
if (msg.reset) {
|
|
174
|
+
void openConversation(msg.key);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const env of msg.events) applyEvent(env);
|
|
178
|
+
{
|
|
179
|
+
const live = new Set(msg.pending.map((i) => i.id));
|
|
180
|
+
for (const ix of S.pending.values()) {
|
|
181
|
+
if (ix.convKey === msg.key && !ix.settled && !live.has(ix.id)) markGone(ix);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const ix of msg.pending) upsertInteraction(ix);
|
|
185
|
+
setBusy(msg.busy);
|
|
186
|
+
return;
|
|
187
|
+
case "conv":
|
|
188
|
+
if (S.convs.get(msg.info.key)?.busy && !msg.info.busy) scheduleQuota();
|
|
189
|
+
S.convs.set(msg.info.key, msg.info);
|
|
190
|
+
if (msg.info.key === S.current) {
|
|
191
|
+
renderTopbar();
|
|
192
|
+
setBusy(msg.info.busy);
|
|
193
|
+
renderScheduled();
|
|
194
|
+
}
|
|
195
|
+
scheduleSidebar();
|
|
196
|
+
return;
|
|
197
|
+
case "ix":
|
|
198
|
+
upsertInteraction(msg.interaction);
|
|
199
|
+
return;
|
|
200
|
+
case "tasks":
|
|
201
|
+
// 自己的定时任务变了:定时任务页开着的话会去重新拉(views/tasks.js)
|
|
202
|
+
document.dispatchEvent(new Event("aim:tasks"));
|
|
203
|
+
return;
|
|
204
|
+
case "notice":
|
|
205
|
+
// 正看着这条会话就不打扰;普通网页靠 Web Push,这里只给桌面端
|
|
206
|
+
if (!desktop) return;
|
|
207
|
+
if (document.visibilityState === "visible" && document.hasFocus() && msg.key === S.current) return;
|
|
208
|
+
desktop.notify({ key: msg.key, title: msg.title, body: msg.body, tag: msg.tag });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function setConnBanner(text, onclick) {
|
|
214
|
+
ui.banner?.remove();
|
|
215
|
+
ui.banner = null;
|
|
216
|
+
if (!text || !ui.main) return;
|
|
217
|
+
ui.banner = onclick ? h("button", { class: "conn-banner clickable", onclick }, text) : h("div", { class: "conn-banner" }, text);
|
|
218
|
+
ui.main.append(ui.banner);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 页面加载时连上的服务端版本。服务端升级重启后版本变了,页面上跑的还是旧前端,得刷新 */
|
|
222
|
+
let loadedServerVersion = null;
|
|
223
|
+
|
|
224
|
+
/** 服务端换了版本:刷新页面换上新前端。返回 true 表示已经在刷新了,后面不用再处理 */
|
|
225
|
+
function checkServerVersion(version) {
|
|
226
|
+
if (!version) return false;
|
|
227
|
+
if (!loadedServerVersion) loadedServerVersion = version;
|
|
228
|
+
if (version === loadedServerVersion) return false;
|
|
229
|
+
// 输入框里有没发出去的字:别直接刷掉,提示一下让用户自己点
|
|
230
|
+
if (ui.input?.value.trim()) {
|
|
231
|
+
setConnBanner(`服务端已升级到 ${version},点这里刷新页面`, () => location.reload());
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
location.reload();
|
|
235
|
+
return true;
|
|
236
|
+
}
|