agent-loft 0.2.29 → 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 -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/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,205 @@
|
|
|
1
|
+
.main {
|
|
2
|
+
flex: 1;
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
position: relative;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.topbar {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: 6px;
|
|
13
|
+
padding: calc(8px + env(safe-area-inset-top)) 12px 8px;
|
|
14
|
+
min-height: 52px;
|
|
15
|
+
/*
|
|
16
|
+
* iOS 26+ 的主屏 PWA 会在页面顶端叠一层系统的模糊渐变(Liquid Glass 边缘效果),CSS 关不掉。
|
|
17
|
+
* WebKit 只在顶端有「fixed / sticky、铺满宽度、高于 10px、纯色不透明」的元素时跳过它,
|
|
18
|
+
* 并改用这个颜色延伸到状态栏。所以顶栏要 sticky + 不透明底色,不能是普通的流式元素。
|
|
19
|
+
*/
|
|
20
|
+
background: var(--bg);
|
|
21
|
+
position: sticky;
|
|
22
|
+
top: 0;
|
|
23
|
+
z-index: 5;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* 保险:主屏 PWA 里再在最顶端放一条 fixed 的纯色细条(同样是为了让 WebKit 跳过边缘模糊)。
|
|
27
|
+
层级低于顶栏和侧栏,和顶栏同色,看不出来 */
|
|
28
|
+
.edge-strip {
|
|
29
|
+
display: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@media (display-mode: standalone) and (max-width: 768px) {
|
|
33
|
+
.edge-strip {
|
|
34
|
+
display: block;
|
|
35
|
+
position: fixed;
|
|
36
|
+
top: 0;
|
|
37
|
+
left: 0;
|
|
38
|
+
right: 0;
|
|
39
|
+
height: max(12px, env(safe-area-inset-top));
|
|
40
|
+
background: var(--bg);
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
z-index: 4;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* 顶栏下沿:渐变 + 模糊,聊天内容滚上去时柔和地消失在顶栏后面 */
|
|
47
|
+
/*
|
|
48
|
+
* 顶栏浮在聊天内容上面:内容一直滚到窗口顶端,经过顶栏时先变模糊、再淡出到底色,
|
|
49
|
+
* 没有分界线(参考 Claude 桌面端)。顶栏自身比按钮行高出一截,多出来的部分就是过渡带。
|
|
50
|
+
* iOS 主屏 PWA 例外,见下面的媒体查询。
|
|
51
|
+
*/
|
|
52
|
+
.topbar {
|
|
53
|
+
position: absolute;
|
|
54
|
+
left: 0;
|
|
55
|
+
right: 0;
|
|
56
|
+
padding-bottom: 20px;
|
|
57
|
+
background: linear-gradient(
|
|
58
|
+
to bottom,
|
|
59
|
+
var(--bg) 0%,
|
|
60
|
+
var(--bg) calc(100% - 22px),
|
|
61
|
+
color-mix(in srgb, var(--bg) 75%, transparent) calc(100% - 13px),
|
|
62
|
+
color-mix(in srgb, var(--bg) 30%, transparent) calc(100% - 5px),
|
|
63
|
+
color-mix(in srgb, var(--bg) 0%, transparent) 100%
|
|
64
|
+
);
|
|
65
|
+
pointer-events: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.topbar > * {
|
|
69
|
+
pointer-events: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* 模糊层:上实下虚,越往下越清楚,和渐变叠在一起 */
|
|
73
|
+
.topbar::before {
|
|
74
|
+
content: "";
|
|
75
|
+
position: absolute;
|
|
76
|
+
inset: 0;
|
|
77
|
+
z-index: -1;
|
|
78
|
+
-webkit-backdrop-filter: blur(10px);
|
|
79
|
+
backdrop-filter: blur(10px);
|
|
80
|
+
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent);
|
|
81
|
+
mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent);
|
|
82
|
+
pointer-events: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* 内容让出顶栏按钮行的高度(.main 前缀是为了压过手机端的 padding 简写) */
|
|
86
|
+
.main .messages-inner {
|
|
87
|
+
padding-top: calc(72px + env(safe-area-inset-top));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.messages {
|
|
91
|
+
scroll-padding-top: calc(72px + env(safe-area-inset-top));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* iOS 主屏 PWA:顶端必须是不透明的 sticky 元素才能避开系统的边缘模糊,回到普通顶栏 + 下方淡出 */
|
|
95
|
+
@media (display-mode: standalone) and (max-width: 768px) {
|
|
96
|
+
.topbar {
|
|
97
|
+
position: sticky;
|
|
98
|
+
padding-bottom: 8px;
|
|
99
|
+
background: var(--bg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.topbar::before {
|
|
103
|
+
display: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.topbar::after {
|
|
107
|
+
content: "";
|
|
108
|
+
position: absolute;
|
|
109
|
+
left: 0;
|
|
110
|
+
right: 0;
|
|
111
|
+
top: 100%;
|
|
112
|
+
height: 24px;
|
|
113
|
+
pointer-events: none;
|
|
114
|
+
background: linear-gradient(
|
|
115
|
+
to bottom,
|
|
116
|
+
var(--bg),
|
|
117
|
+
color-mix(in srgb, var(--bg) 60%, transparent) 40%,
|
|
118
|
+
color-mix(in srgb, var(--bg) 0%, transparent)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.main .messages-inner {
|
|
123
|
+
padding-top: 16px;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.topbar .menu-btn {
|
|
128
|
+
display: none;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.icon-btn {
|
|
132
|
+
width: 36px;
|
|
133
|
+
height: 36px;
|
|
134
|
+
display: inline-grid;
|
|
135
|
+
place-items: center;
|
|
136
|
+
border-radius: 8px;
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
color: var(--fg-soft);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.icon-btn:hover {
|
|
142
|
+
background: var(--bg-hover);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.icon-btn svg {
|
|
146
|
+
width: 20px;
|
|
147
|
+
height: 20px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.top-title {
|
|
151
|
+
flex: 1;
|
|
152
|
+
min-width: 0;
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 6px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.top-title .conv-name {
|
|
159
|
+
font-size: 15px;
|
|
160
|
+
font-weight: 500;
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
text-overflow: ellipsis;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
padding: 0 4px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.pill {
|
|
168
|
+
display: inline-flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
gap: 4px;
|
|
171
|
+
padding: 5px 10px;
|
|
172
|
+
border-radius: 999px;
|
|
173
|
+
font-size: 14px;
|
|
174
|
+
font-weight: 500;
|
|
175
|
+
max-width: 100%;
|
|
176
|
+
white-space: nowrap;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.pill:hover {
|
|
180
|
+
background: var(--bg-hover);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
.pill .sub {
|
|
185
|
+
color: var(--fg-faint);
|
|
186
|
+
font-weight: 400;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
text-overflow: ellipsis;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.pill svg {
|
|
192
|
+
width: 14px;
|
|
193
|
+
height: 14px;
|
|
194
|
+
flex-shrink: 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* 桌面端(Agent Loft for macOS)里:顶栏和侧栏顶部按住能拖动窗口,里面的按钮照常能点 */
|
|
198
|
+
.in-desktop .topbar,
|
|
199
|
+
.in-desktop .side-top {
|
|
200
|
+
-webkit-app-region: drag;
|
|
201
|
+
}
|
|
202
|
+
.in-desktop .topbar :is(button, a, input, select, textarea, label, .conv-name),
|
|
203
|
+
.in-desktop .side-top :is(button, a, input, select, textarea, label) {
|
|
204
|
+
-webkit-app-region: no-drag;
|
|
205
|
+
}
|
package/web/index.html
CHANGED
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
15
15
|
<link rel="stylesheet" href="/vendor/hljs-light.css" media="(prefers-color-scheme: light)" />
|
|
16
16
|
<link rel="stylesheet" href="/vendor/hljs-dark.css" media="(prefers-color-scheme: dark)" />
|
|
17
|
-
|
|
17
|
+
<!-- 顺序就是层叠顺序,不要随意调换 -->
|
|
18
|
+
<link rel="stylesheet" href="/css/base.css" />
|
|
19
|
+
<link rel="stylesheet" href="/css/sidebar.css" />
|
|
20
|
+
<link rel="stylesheet" href="/css/topbar.css" />
|
|
21
|
+
<link rel="stylesheet" href="/css/messages.css" />
|
|
22
|
+
<link rel="stylesheet" href="/css/loader.css" />
|
|
23
|
+
<link rel="stylesheet" href="/css/composer.css" />
|
|
24
|
+
<link rel="stylesheet" href="/css/overlay.css" />
|
|
25
|
+
<link rel="stylesheet" href="/css/settings.css" />
|
|
26
|
+
<link rel="stylesheet" href="/css/schedule.css" />
|
|
27
|
+
<link rel="stylesheet" href="/css/mobile.css" />
|
|
18
28
|
</head>
|
|
19
29
|
<body>
|
|
20
30
|
<div id="app"><div class="boot"><span class="loft-loader xl" role="img" aria-label="加载中"><svg viewBox="96 56 320 388" aria-hidden="true"><defs><linearGradient id="boot-g" gradientUnits="userSpaceOnUse" x1="104" y1="104" x2="408" y2="408"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs><path class="ll-shadow" d="M256 296L404 364L256 432L108 364Z" fill="url(#boot-g)"/><path class="ll-plate" d="M256 252L404 320L256 388L108 320Z" fill="url(#boot-g)"/><path class="ll-star ll-l" d="M152 186C158.46 217.54 158.46 217.54 190 224C158.46 230.46 158.46 230.46 152 262C145.54 230.46 145.54 230.46 114 224C145.54 217.54 145.54 217.54 152 186Z" fill="url(#boot-g)"/><path class="ll-star ll-c" 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(#boot-g)"/><path class="ll-star ll-r" d="M360 186C366.46 217.54 366.46 217.54 398 224C366.46 230.46 366.46 230.46 360 262C353.54 230.46 353.54 230.46 322 224C353.54 217.54 353.54 217.54 360 186Z" fill="url(#boot-g)"/></svg></span></div></div>
|
package/web/lib/api.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { showAuth } from "../views/auth.js";
|
|
2
|
+
|
|
3
|
+
export async function api(path, opts = {}) {
|
|
4
|
+
const res = await fetch(path, {
|
|
5
|
+
method: opts.method ?? "GET",
|
|
6
|
+
credentials: "same-origin",
|
|
7
|
+
headers: opts.body !== undefined && !(opts.body instanceof Blob) ? { "Content-Type": "application/json" } : opts.headers,
|
|
8
|
+
body: opts.body === undefined ? undefined : opts.body instanceof Blob ? opts.body : JSON.stringify(opts.body),
|
|
9
|
+
});
|
|
10
|
+
let data = null;
|
|
11
|
+
try {
|
|
12
|
+
data = await res.json();
|
|
13
|
+
} catch {
|
|
14
|
+
// 没有 body
|
|
15
|
+
}
|
|
16
|
+
if (res.status === 401 && !path.startsWith("/api/me") && !path.startsWith("/api/login")) {
|
|
17
|
+
showAuth();
|
|
18
|
+
throw new Error("请先登录");
|
|
19
|
+
}
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const err = new Error(data?.error ?? `请求失败(${res.status})`);
|
|
22
|
+
err.status = res.status;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
return data;
|
|
26
|
+
}
|
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
|
+
}
|