agent-loft 0.2.32 → 0.2.34

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.
@@ -155,6 +155,18 @@
155
155
  gap: 6px;
156
156
  }
157
157
 
158
+ .top-title .conv-mode-icon {
159
+ display: inline-grid;
160
+ flex-shrink: 0;
161
+ margin-left: 4px;
162
+ color: var(--fg-soft);
163
+ }
164
+
165
+ .top-title .conv-mode-icon svg {
166
+ width: 16px;
167
+ height: 16px;
168
+ }
169
+
158
170
  .top-title .conv-name {
159
171
  font-size: 15px;
160
172
  font-weight: 500;
@@ -203,3 +215,122 @@
203
215
  .in-desktop .side-top :is(button, a, input, select, textarea, label) {
204
216
  -webkit-app-region: no-drag;
205
217
  }
218
+
219
+ /* ---- 右上角「更多」:菜单项 + 当前会话的 token 用量 ---- */
220
+ .top-menu {
221
+ min-width: 248px;
222
+ }
223
+
224
+ .top-menu .pop-item svg {
225
+ width: 16px;
226
+ height: 16px;
227
+ color: var(--fg-soft);
228
+ }
229
+
230
+ /* 分布的四种颜色:固定顺序(输入、缓存写入、缓存读取、输出),深色模式用为深色底挑的那一档 */
231
+ .usage {
232
+ --usage-input: #2a78d6;
233
+ --usage-cwrite: #eb6834;
234
+ --usage-cread: #1baf7a;
235
+ --usage-output: #eda100;
236
+ padding: 6px 12px 8px;
237
+ font-size: 12px;
238
+ color: var(--fg-soft);
239
+ font-variant-numeric: tabular-nums;
240
+ }
241
+
242
+ @media (prefers-color-scheme: dark) {
243
+ .usage {
244
+ --usage-input: #3987e5;
245
+ --usage-cwrite: #d95926;
246
+ --usage-cread: #199e70;
247
+ --usage-output: #c98500;
248
+ }
249
+ }
250
+
251
+ .usage .u-input {
252
+ background: var(--usage-input);
253
+ }
254
+
255
+ .usage .u-cwrite {
256
+ background: var(--usage-cwrite);
257
+ }
258
+
259
+ .usage .u-cread {
260
+ background: var(--usage-cread);
261
+ }
262
+
263
+ .usage .u-output {
264
+ background: var(--usage-output);
265
+ }
266
+
267
+ .usage-empty {
268
+ color: var(--fg-faint);
269
+ }
270
+
271
+ .usage-head {
272
+ display: flex;
273
+ justify-content: space-between;
274
+ align-items: baseline;
275
+ margin-bottom: 6px;
276
+ }
277
+
278
+ .usage-head b {
279
+ font-size: 14px;
280
+ font-weight: 600;
281
+ color: var(--fg);
282
+ }
283
+
284
+ /* 段与段之间留 2px 底色的缝,两端圆角;很小的一段也至少看得见 */
285
+ .usage-bar {
286
+ display: flex;
287
+ gap: 2px;
288
+ height: 8px;
289
+ border-radius: 4px;
290
+ overflow: hidden;
291
+ margin-bottom: 8px;
292
+ }
293
+
294
+ .usage-bar i {
295
+ flex: 1 1 0;
296
+ min-width: 3px;
297
+ }
298
+
299
+ .usage-row {
300
+ display: grid;
301
+ grid-template-columns: 8px 1fr auto 3.2em;
302
+ align-items: center;
303
+ gap: 8px;
304
+ padding: 2px 0;
305
+ }
306
+
307
+ .usage-dot {
308
+ width: 8px;
309
+ height: 8px;
310
+ border-radius: 2px;
311
+ }
312
+
313
+ .usage-name {
314
+ color: var(--fg);
315
+ min-width: 0;
316
+ }
317
+
318
+ .usage-name small {
319
+ margin-left: 6px;
320
+ color: var(--fg-faint);
321
+ font-size: 11px;
322
+ }
323
+
324
+ .usage-num {
325
+ color: var(--fg);
326
+ }
327
+
328
+ .usage-pct {
329
+ text-align: right;
330
+ color: var(--fg-faint);
331
+ }
332
+
333
+ .usage-foot {
334
+ margin-top: 6px;
335
+ color: var(--fg-faint);
336
+ }
package/web/lib/dom.js CHANGED
@@ -39,6 +39,7 @@ export const ICON = {
39
39
  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>',
40
40
  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>',
41
41
  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>',
42
+ chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-12.3 7.5L3 20.5l1.6-5A8.4 8.4 0 1 1 21 11.5Z"/></svg>',
42
43
  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>',
43
44
  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>',
44
45
  plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
@@ -69,7 +70,7 @@ ICON.codex = ICON.logo;
69
70
  /**
70
71
  * 星星加载动画(回复末尾、打开会话时用)。开屏那个带台面的完整 logo 动画直接写在 index.html 里。
71
72
  * 每个实例的渐变 id 唯一,避免页面里有隐藏的同 id 渐变时画不出来。
72
- * 回复流式输出时整条消息会反复重建,动画按全局时钟设负的起始延迟,重建后接着播、不会一抖一抖地重来。
73
+ * 动画按全局时钟设负的起始延迟:回复被授权卡片分成几段时,新一段的动画接着播、不会一抖一抖地重来。
73
74
  */
74
75
  let loaderSeq = 0;
75
76
 
@@ -8,29 +8,99 @@ import { h, toast } from "./dom.js";
8
8
  marked.setOptions({ gfm: true, breaks: true });
9
9
 
10
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")) {
11
+ const div = h("div", { class: "md" });
12
+ patchMarkdown(div, text);
13
+ return div;
14
+ }
15
+
16
+ /**
17
+ * 把 div 里的 markdown 更新成 text,只重画变了的块(段落、列表、代码块…)。
18
+ * 流式输出时前面的块不再变,每帧只需重画最后一两块;选中的文字、代码块的横向滚动也不会被冲掉。
19
+ * live = 还在输出:最后一块随时会变,先不高亮,等它定下来再高亮。
20
+ * 按块拼起来和整段解析的结果一样(marked 的 parser 本来就是逐块拼接的)。
21
+ */
22
+ export function patchMarkdown(div, text, { live = false } = {}) {
23
+ const tokens = marked.lexer(text ?? "");
24
+ const links = JSON.stringify(tokens.links ?? {});
25
+ // 引用式链接的定义可能在后面才出现(流式输出时地址还会一点点变长),会影响前面的块:定义变了就整个重画
26
+ if (div._links !== links) {
27
+ div._links = links;
28
+ div._blocks = [];
29
+ div.replaceChildren();
30
+ }
31
+ // HTML 块(<details>、<div align> 之类)的开闭标签常常不在同一块里,分开解析会被浏览器提前补上结束标签:
32
+ // 从第一个 HTML 块起到结尾合成一块
33
+ const cut = tokens.findIndex((t) => t.type === "html");
34
+ const chunks = cut < 0 ? tokens.map((t) => [t]) : [...tokens.slice(0, cut).map((t) => [t]), tokens.slice(cut)];
35
+ const raws = chunks.map((c) => c.map((t) => t.raw).join(""));
36
+ const blocks = (div._blocks ??= []);
37
+ const last = chunks.length - 1;
38
+ let i = 0;
39
+ // 还在输出时画的块没高亮,只有它仍是正在输出的最后一块才能接着用
40
+ while (i < blocks.length && i <= last && blocks[i].raw === raws[i] && (blocks[i].final || (live && i === last))) i++;
41
+ for (const b of blocks.splice(i)) {
42
+ for (const n of b.nodes) {
43
+ if (n.nodeType === Node.ELEMENT_NODE) for (const code of n.querySelectorAll("pre code")) lazyHighlight.unobserve(code);
44
+ n.remove();
45
+ }
46
+ }
47
+ for (; i <= last; i++) {
48
+ const final = !(live && i === last);
49
+ const chunk = chunks[i];
50
+ chunk.links = tokens.links;
51
+ // 在本页的元素里解析(DOMPurify 直接返回的节点属于另一个 document,搬过来后 IntersectionObserver 就不灵了)
52
+ const box = document.createElement("div");
53
+ box.innerHTML = DOMPurify.sanitize(marked.parser(chunk), { ADD_ATTR: ["target"] });
54
+ decorate(box, final);
55
+ const nodes = [...box.childNodes];
56
+ div.append(...nodes);
57
+ blocks.push({ raw: raws[i], final, nodes });
58
+ }
59
+ }
60
+
61
+ /** 链接新窗口打开;代码块加上语言 + 复制按钮的头,滚到附近了再高亮 */
62
+ function decorate(root, highlight) {
63
+ for (const a of root.querySelectorAll("a")) {
14
64
  a.target = "_blank";
15
65
  a.rel = "noopener noreferrer";
16
66
  }
17
- for (const pre of div.querySelectorAll("pre")) {
67
+ for (const pre of root.querySelectorAll("pre")) {
18
68
  const code = pre.querySelector("code");
19
69
  if (!code) continue;
20
70
  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
71
  const copy = h("button", { class: "copy-btn", onclick: () => copyText(code.textContent, copy) }, "复制");
31
72
  pre.prepend(h("div", { class: "code-head" }, h("span", {}, lang || "code"), copy));
73
+ if (highlight) lazyHighlight.observe(code);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 高亮(尤其是自动识别语言)很慢:长会话一打开几十个代码块会卡住。
79
+ * 只在代码块快滚进视口时才高亮;高亮不改变文字和行高,不会让内容跳。
80
+ */
81
+ const lazyHighlight = new IntersectionObserver(
82
+ (entries) => {
83
+ for (const e of entries) {
84
+ if (!e.isIntersecting) continue;
85
+ lazyHighlight.unobserve(e.target);
86
+ highlight(e.target);
87
+ }
88
+ },
89
+ // 代码块在消息区这种滚动容器里:rootMargin 只放大视口,scrollMargin 才放大滚动容器的裁剪范围(不支持的浏览器忽略它)
90
+ { rootMargin: "800px 0px", scrollMargin: "800px 0px" },
91
+ );
92
+
93
+ function highlight(code) {
94
+ const lang = /language-([\w+-]+)/.exec(code.className)?.[1] ?? "";
95
+ try {
96
+ if (lang && hljs.getLanguage(lang)) hljs.highlightElement(code);
97
+ else if (code.textContent.length < 20000) {
98
+ code.innerHTML = hljs.highlightAuto(code.textContent).value;
99
+ code.classList.add("hljs");
100
+ }
101
+ } catch {
102
+ // 高亮失败就原样显示
32
103
  }
33
- return div;
34
104
  }
35
105
 
36
106
  export async function copyText(text, btn) {
package/web/lib/ws.js CHANGED
@@ -2,7 +2,7 @@ import { h } from "./dom.js";
2
2
  import { api } from "./api.js";
3
3
  import { desktop, S, ui } from "../state.js";
4
4
  import { showAuth } from "../views/auth.js";
5
- import { renderSidebar, scheduleSidebar } from "../views/sidebar.js";
5
+ import { applyBootstrap, bootstrapQuery, renderSidebar, scheduleSidebar } from "../views/sidebar.js";
6
6
  import { scheduleQuota } from "../views/quota.js";
7
7
  import { renderTopbar } from "../views/topbar.js";
8
8
  import { applyEvent, openConversation } from "../views/messages.js";
@@ -92,13 +92,52 @@ export const WS = {
92
92
  },
93
93
 
94
94
  /** 告诉服务器我是否在前台、正看着哪条会话:正看着的会话不推系统通知 */
95
+ /**
96
+ * 告诉服务端「我正看着这条会话」:看着的时候不推送。要页面在前台、有焦点,而且最近还有人在动它 ——
97
+ * 窗口开着人走开了(电脑上停在这条会话、人在用手机),照样推
98
+ */
95
99
  presence() {
96
- const visible = document.visibilityState === "visible" && document.hasFocus();
100
+ const visible = document.visibilityState === "visible" && document.hasFocus() && Date.now() - lastInput < IDLE_MS;
97
101
  this.raw({ t: "presence", visible, key: S.current ?? "" });
98
102
  },
99
103
  };
100
104
 
101
- for (const ev of ["focus", "blur"]) window.addEventListener(ev, () => WS.presence());
105
+ // 切回窗口本身就算动了一下
106
+ window.addEventListener("focus", () => (markActive(), WS.presence()));
107
+ window.addEventListener("blur", () => WS.presence());
108
+
109
+ // ---------- 有没有人在动页面 ----------
110
+ // 超过 IDLE_MS 没点、没按键、没滚动,就当人不在了:报一次 presence,服务端照常推送;一动又报回来
111
+
112
+ const IDLE_MS = 2 * 60_000;
113
+ let lastInput = Date.now();
114
+ let idle = false;
115
+ let idleTimer = 0;
116
+ function markActive() {
117
+ lastInput = Date.now();
118
+ idle = false;
119
+ armIdle();
120
+ }
121
+ function armIdle() {
122
+ clearTimeout(idleTimer);
123
+ idleTimer = setTimeout(() => {
124
+ idle = true;
125
+ WS.presence();
126
+ }, IDLE_MS);
127
+ }
128
+ function onInput() {
129
+ const now = Date.now();
130
+ // 滚动一秒几十次,重设计时器不用那么勤
131
+ if (!idle && now - lastInput < 5000) return;
132
+ lastInput = now;
133
+ if (idle) {
134
+ idle = false;
135
+ WS.presence();
136
+ }
137
+ armIdle();
138
+ }
139
+ for (const ev of ["pointerdown", "keydown", "wheel", "touchstart", "scroll"]) window.addEventListener(ev, onInput, { capture: true, passive: true });
140
+ armIdle();
102
141
 
103
142
  /** 打开 / 切回页面:清掉桌面图标角标和计数(计数由 sw.js 在收到推送时累加) */
104
143
  export function clearAppBadge() {
@@ -108,6 +147,7 @@ export function clearAppBadge() {
108
147
  }
109
148
 
110
149
  document.addEventListener("visibilitychange", () => {
150
+ if (document.visibilityState === "visible") markActive();
111
151
  WS.presence();
112
152
  clearAppBadge();
113
153
  // 手机切回前台:连接多半已经被系统掐了,立刻重连而不是等退避
@@ -120,8 +160,8 @@ document.addEventListener("visibilitychange", () => {
120
160
  /** 重连后:以服务器上的会话列表和待处理授权为准,清掉本地已失效的 */
121
161
  async function resync() {
122
162
  try {
123
- const data = await api("/api/bootstrap");
124
- S.convs = new Map(data.conversations.map((c) => [c.key, c]));
163
+ const data = await api(`/api/bootstrap${bootstrapQuery()}`);
164
+ applyBootstrap(data);
125
165
  const live = new Set(data.pending.map((i) => i.id));
126
166
  for (const ix of S.pending.values()) {
127
167
  // 快照生成之后才出现的授权,快照里当然没有,不能当成「已在别处处理」
@@ -202,7 +242,7 @@ function onWsMessage(msg) {
202
242
  document.dispatchEvent(new Event("aim:tasks"));
203
243
  return;
204
244
  case "skills":
205
- // 技能变了(自己的、装了的、广场上的):技能页重新拉,@ 补全的缓存作废
245
+ // 技能变了:设置里的技能页开着就重新拉,@ 补全的缓存作废
206
246
  document.dispatchEvent(new Event("aim:skills"));
207
247
  return;
208
248
  case "notice":
@@ -9,19 +9,24 @@ import { agentsTab } from "./agents.js";
9
9
  import { feishuTab } from "./feishu.js";
10
10
  import { authTab, securityTab } from "./security.js";
11
11
  import { memoryTab } from "./memory.js";
12
+ import { skillsTab } from "./skills.js";
13
+ import { usageTab } from "./usage.js";
12
14
  import { systemTab, usersTab } from "./users.js";
13
15
 
14
16
  // ---------- 设置 ----------
15
17
 
16
- export async function openSettings() {
18
+ /** tab:直接打开哪一页(标签名,如「技能」),不给就是第一页 */
19
+ export async function openSettings(tab) {
17
20
  const nav = h("div", { class: "settings-nav" });
18
21
  const panel = h("div", { class: "settings-body" });
19
22
  // 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
20
23
  modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
21
- // 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
24
+ // 普通成员只能管自己的记忆、技能、用量、通知、密码和登录设备;其余设置都是管理员的
22
25
  if (!isAdmin()) {
23
26
  const tabs = [
24
27
  ["记忆", () => memoryTab(panel)],
28
+ ["技能", () => skillsTab(panel)],
29
+ ["用量", () => usageTab(panel)],
25
30
  ["通知", () => notifyTab(panel)],
26
31
  ["账号", () => securityTab(panel)],
27
32
  ];
@@ -31,7 +36,7 @@ export async function openSettings() {
31
36
  tabs[i][1]();
32
37
  };
33
38
  nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
34
- select(0);
39
+ select(Math.max(0, tabs.findIndex(([name]) => name === tab)));
35
40
  return;
36
41
  }
37
42
  let cfg;
@@ -45,9 +50,11 @@ export async function openSettings() {
45
50
  ["通用", () => generalTab(cfg, panel)],
46
51
  ["Agent", () => agentsTab(cfg, panel)],
47
52
  ["命令", () => commandsTab(cfg, panel)],
53
+ ["技能", () => skillsTab(panel)],
48
54
  ["飞书机器人", () => feishuTab(cfg, panel)],
49
55
  ["授权", () => authTab(panel)],
50
56
  ["记忆", () => memoryTab(panel)],
57
+ ["用量", () => usageTab(panel)],
51
58
  ["通知", () => notifyTab(panel)],
52
59
  ["账号", () => securityTab(panel)],
53
60
  ["用户", () => usersTab(panel)],
@@ -59,5 +66,5 @@ export async function openSettings() {
59
66
  tabs[i][1]();
60
67
  };
61
68
  nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
62
- select(0);
69
+ select(Math.max(0, tabs.findIndex(([name]) => name === tab)));
63
70
  }