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.
@@ -0,0 +1,272 @@
1
+ import { fill, h } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { S } from "../state.js";
4
+
5
+ // ---------- 用量:按日 / 月 / 年统计 token 和费用(数据见 src/core/usage.ts 按天的账) ----------
6
+
7
+ /** 各项的顺序和颜色跟「更多」菜单里本会话的用量一致 */
8
+ const PARTS = [
9
+ { key: "input", label: "输入", cls: "u-input" },
10
+ { key: "cacheWrite", label: "缓存写入", cls: "u-cwrite" },
11
+ { key: "cacheRead", label: "缓存读取", cls: "u-cread" },
12
+ { key: "output", label: "输出", cls: "u-output" },
13
+ ];
14
+
15
+ const UNITS = [
16
+ ["day", "按日"],
17
+ ["month", "按月"],
18
+ ["year", "按年"],
19
+ ];
20
+
21
+ const pad = (n) => String(n).padStart(2, "0");
22
+ const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
23
+
24
+ /** 1234 → 1.2k,1234567 → 1.23M */
25
+ function fmtCount(n) {
26
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
27
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e8 ? 0 : n >= 1e7 ? 1 : 2)}M`;
28
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k`;
29
+ return String(n);
30
+ }
31
+ const exact = (n) => n.toLocaleString("en-US");
32
+ const fmtCost = (n) => `$${n >= 100 ? n.toFixed(0) : n.toFixed(2)}`;
33
+ const tokensOf = (r) => r.input + r.cacheRead + r.cacheWrite + r.output;
34
+
35
+ /** 某个周期的起止(含两头)、标题、趋势图的格子 */
36
+ function periodOf(unit, anchor) {
37
+ const y = anchor.getFullYear();
38
+ const m = anchor.getMonth();
39
+ if (unit === "day") {
40
+ const d = ymd(anchor);
41
+ return { from: d, to: d, title: `${y}年${m + 1}月${anchor.getDate()}日`, buckets: null };
42
+ }
43
+ if (unit === "year") {
44
+ const buckets = Array.from({ length: 12 }, (_, i) => ({ key: `${y}-${pad(i + 1)}`, label: `${i + 1}月` }));
45
+ return { from: `${y}-01-01`, to: `${y}-12-31`, title: `${y}年`, buckets, slice: 7 };
46
+ }
47
+ const days = new Date(y, m + 1, 0).getDate();
48
+ const buckets = Array.from({ length: days }, (_, i) => ({ key: `${y}-${pad(m + 1)}-${pad(i + 1)}`, label: `${m + 1}月${i + 1}日` }));
49
+ return { from: buckets[0].key, to: buckets[days - 1].key, title: `${y}年${m + 1}月`, buckets, slice: 10 };
50
+ }
51
+
52
+ function shift(unit, anchor, step) {
53
+ const d = new Date(anchor);
54
+ if (unit === "day") d.setDate(d.getDate() + step);
55
+ else if (unit === "month") d.setMonth(d.getMonth() + step, 1);
56
+ else d.setFullYear(d.getFullYear() + step, 0, 1);
57
+ return d;
58
+ }
59
+
60
+ function sum(rows) {
61
+ const t = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, costUsd: 0 };
62
+ for (const r of rows) {
63
+ for (const k of Object.keys(t)) t[k] += r[k] ?? 0;
64
+ }
65
+ return t;
66
+ }
67
+
68
+ /** 按 keyOf 分组合计,Token 多的在前 */
69
+ function groupBy(rows, keyOf) {
70
+ const m = new Map();
71
+ for (const r of rows) {
72
+ const k = keyOf(r);
73
+ if (!m.has(k)) m.set(k, []);
74
+ m.get(k).push(r);
75
+ }
76
+ return [...m].map(([k, list]) => ({ key: k, ...sum(list) })).sort((a, b) => tokensOf(b) - tokensOf(a));
77
+ }
78
+
79
+ export async function usageTab(panel) {
80
+ let unit = "month";
81
+ let anchor = new Date();
82
+ const box = h("div", { class: "section usage-stats" });
83
+ fill(panel, box);
84
+
85
+ const render = async () => {
86
+ const period = periodOf(unit, anchor);
87
+ const isCurrent = period.to >= ymd(new Date());
88
+ const seg = h(
89
+ "div",
90
+ { class: "seg" },
91
+ UNITS.map(([u, label]) =>
92
+ h(
93
+ "button",
94
+ {
95
+ class: u === unit ? "on" : "",
96
+ onclick: () => {
97
+ if (u === unit) return;
98
+ unit = u;
99
+ anchor = new Date();
100
+ void render();
101
+ },
102
+ },
103
+ label,
104
+ ),
105
+ ),
106
+ );
107
+ const nav = h(
108
+ "div",
109
+ { class: "us-nav" },
110
+ h("button", { class: "btn small", "aria-label": "上一个", onclick: () => ((anchor = shift(unit, anchor, -1)), void render()) }, "‹"),
111
+ h("span", { class: "us-title" }, period.title),
112
+ h(
113
+ "button",
114
+ { class: "btn small", "aria-label": "下一个", disabled: isCurrent, onclick: () => ((anchor = shift(unit, anchor, 1)), void render()) },
115
+ "›",
116
+ ),
117
+ isCurrent ? null : h("button", { class: "btn small", onclick: () => ((anchor = new Date()), void render()) }, unit === "day" ? "今天" : unit === "month" ? "本月" : "今年"),
118
+ );
119
+ const head = h("div", { class: "us-head" }, h("h4", {}, "Token 与费用"), seg, nav);
120
+ fill(box, head, h("span", { class: "spinner" }));
121
+
122
+ let data;
123
+ try {
124
+ data = await api(`/api/usage?from=${period.from}&to=${period.to}`);
125
+ } catch (err) {
126
+ fill(box, head, h("div", { class: "error-text" }, err.message));
127
+ return;
128
+ }
129
+ const rows = data.rows;
130
+ const total = sum(rows);
131
+ const tokens = tokensOf(total);
132
+ // 估算的金额、没能算金额的 Token(认不出模型,或者是升级前没记模型的 Codex 用量)
133
+ const estimated = rows.reduce((n, r) => n + (r.estimated ? r.costUsd : 0), 0);
134
+ const unpriced = rows.reduce((n, r) => n + (r.costUsd ? 0 : tokensOf(r)), 0);
135
+ const note = h(
136
+ "div",
137
+ { class: "muted" },
138
+ "金额都是按 API 价格算的参考值,订阅用户实际不按这个扣费:Claude 用它自己报的费用;Codex 不报费用,按模型单价(LiteLLM 公开价格表,每天更新)× Token 估算,不含长上下文加价。从这个版本开始记录,之前的用量没有日期,不计入。",
139
+ unpriced ? ` 有 ${fmtCount(unpriced)} Token 认不出模型单价,没算进金额。` : "",
140
+ );
141
+ if (!tokens) {
142
+ fill(box, head, h("div", { class: "muted us-empty" }, `${period.title}没有用量记录`), note);
143
+ return;
144
+ }
145
+
146
+ const days = unit === "day" ? 1 : new Set(rows.map((r) => r.day)).size;
147
+ const tiles = h(
148
+ "div",
149
+ { class: "us-tiles" },
150
+ tile("Token 合计", fmtCount(tokens), `${exact(tokens)} tokens`),
151
+ tile(
152
+ "费用(约)",
153
+ total.costUsd ? fmtCost(total.costUsd) : "—",
154
+ total.costUsd
155
+ ? `$${total.costUsd.toFixed(4)}${estimated ? `,其中按单价估算 $${estimated.toFixed(4)}` : ""}`
156
+ : "这段时间没有能算金额的用量",
157
+ ),
158
+ unit === "day"
159
+ ? tile("输出", fmtCount(total.output), `${exact(total.output)} tokens`)
160
+ : tile("日均 Token", fmtCount(Math.round(tokens / days)), `有用量的 ${days} 天平均`),
161
+ );
162
+
163
+ const parts = PARTS.map((p) => ({ ...p, n: total[p.key] })).filter((p) => p.n > 0);
164
+ const composition = h(
165
+ "div",
166
+ { class: "usage" },
167
+ h("div", { class: "usage-bar", "aria-hidden": "true" }, parts.map((p) => h("i", { class: p.cls, style: `flex-grow:${p.n}` }))),
168
+ parts.map((p) =>
169
+ h(
170
+ "div",
171
+ { class: "usage-row", title: `${exact(p.n)} tokens` },
172
+ h("span", { class: `usage-dot ${p.cls}` }),
173
+ h("span", { class: "usage-name" }, p.label, p.key === "output" && total.reasoning ? h("small", {}, `含推理 ${fmtCount(total.reasoning)}`) : null),
174
+ h("span", { class: "usage-num" }, fmtCount(p.n)),
175
+ h("span", { class: "usage-pct" }, pct(p.n, tokens)),
176
+ ),
177
+ ),
178
+ );
179
+
180
+ const agentName = (id) => S.agents.find((a) => a.id === id)?.label ?? id;
181
+ const byAgent = groupBy(rows, (r) => r.agent).map((g) => ({ ...g, label: agentName(g.key) }));
182
+ const byModel = groupBy(rows, (r) => r.model ?? "").map((g) => ({ ...g, label: g.key || "未记录模型" }));
183
+ const byUser = data.admin
184
+ ? groupBy(rows, (r) => r.owner ?? "").map((g) => ({ ...g, label: g.key ? data.users[g.key] : "未归属(飞书等)" }))
185
+ : [];
186
+
187
+ fill(
188
+ box,
189
+ head,
190
+ tiles,
191
+ period.buckets ? trend(period, rows) : null,
192
+ h("h5", {}, "构成"),
193
+ composition,
194
+ h("h5", {}, "按 Agent"),
195
+ breakdown(byAgent, tokens),
196
+ h("h5", {}, "按模型"),
197
+ breakdown(byModel, tokens),
198
+ byUser.length > 1 || (byUser.length === 1 && byUser[0].key) ? [h("h5", {}, "按用户"), breakdown(byUser, tokens)] : null,
199
+ note,
200
+ );
201
+ };
202
+ await render();
203
+ }
204
+
205
+ function tile(label, value, title) {
206
+ return h("div", { class: "us-tile", title }, h("span", {}, label), h("b", {}, value));
207
+ }
208
+
209
+ function pct(n, total) {
210
+ const p = (n / total) * 100;
211
+ return p > 0 && p < 1 ? "<1%" : `${Math.round(p)}%`;
212
+ }
213
+
214
+ /** 趋势:月视图每天一根,年视图每月一根。悬停看具体数字 */
215
+ function trend(period, rows) {
216
+ const byKey = new Map();
217
+ for (const r of rows) {
218
+ const k = r.day.slice(0, period.slice);
219
+ const t = byKey.get(k) ?? { tokens: 0, cost: 0 };
220
+ t.tokens += tokensOf(r);
221
+ t.cost += r.costUsd ?? 0;
222
+ byKey.set(k, t);
223
+ }
224
+ const max = Math.max(...[...byKey.values()].map((t) => t.tokens), 1);
225
+ const today = ymd(new Date()).slice(0, period.slice);
226
+ const n = period.buckets.length;
227
+ return h(
228
+ "div",
229
+ { class: "us-trend", role: "img", "aria-label": `${period.title}每${period.slice === 7 ? "月" : "天"}的 Token 用量` },
230
+ h(
231
+ "div",
232
+ { class: "us-bars", style: `grid-template-columns:repeat(${n},1fr)` },
233
+ period.buckets.map((b) => {
234
+ const t = byKey.get(b.key);
235
+ const tip = t ? `${b.label} ${exact(t.tokens)} tokens${t.cost ? ` 约 ${fmtCost(t.cost)}` : ""}` : `${b.label} 没有用量`;
236
+ return h(
237
+ "div",
238
+ { class: `us-col${b.key > today ? " future" : ""}`, title: tip },
239
+ t ? h("i", { style: `height:${Math.max(2, (t.tokens / max) * 100)}%` }) : null,
240
+ );
241
+ }),
242
+ ),
243
+ h(
244
+ "div",
245
+ { class: "us-axis", style: `grid-template-columns:repeat(${n},1fr)` },
246
+ period.buckets.map((b, i) => {
247
+ // 月视图只标 1、5、10…号,免得挤
248
+ const show = period.slice === 7 || i === 0 || (i + 1) % 5 === 0;
249
+ return h("span", {}, show ? String(i + 1) : "");
250
+ }),
251
+ ),
252
+ h("div", { class: "us-max" }, `最高 ${fmtCount(max)}`),
253
+ );
254
+ }
255
+
256
+ function breakdown(groups, tokens) {
257
+ return h(
258
+ "div",
259
+ { class: "us-list" },
260
+ groups.map((g) => {
261
+ const n = tokensOf(g);
262
+ return h(
263
+ "div",
264
+ { class: "us-item", title: `${exact(n)} tokens` },
265
+ h("span", { class: "us-name" }, g.label),
266
+ h("span", { class: "us-meter", "aria-hidden": "true" }, h("i", { style: `width:${(n / tokens) * 100}%` })),
267
+ h("span", { class: "us-num" }, fmtCount(n)),
268
+ h("span", { class: "us-cost" }, g.costUsd ? fmtCost(g.costUsd) : "—"),
269
+ );
270
+ }),
271
+ );
272
+ }
package/web/state.js CHANGED
@@ -21,7 +21,7 @@ export const S = {
21
21
  loadToken: 0,
22
22
  /** 登录的用户 { id, name, role } */
23
23
  user: null,
24
- /** 主区域在显示什么:null 是对话(或新对话),"tasks" 是定时任务页,"skills" 是技能页 */
24
+ /** 主区域在显示什么:null 是对话(或新对话),"tasks" 是定时任务页 */
25
25
  view: null,
26
26
  };
27
27
 
@@ -39,8 +39,9 @@ export const desktopQuota = typeof desktop?.onQuota === "function";
39
39
  // 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 css/topbar.css 的 .in-desktop)
40
40
  if (desktop) document.documentElement.classList.add("in-desktop");
41
41
  /**
42
- * 新版桌面端顶栏的「更多」里能打开每台服务器的设置:侧栏底部的设置按钮就不显示了(额度照常显示,数据由桌面端推)。
43
- * 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
42
+ * 新版桌面端顶栏的「更多」里能打开每台服务器的设置:页面「更多」里的设置就不放了。
43
+ * 老版本桌面端没有这个入口,照旧放,免得设置打不开。
44
44
  */
45
45
  export const desktopSettings = typeof desktop?.onOpenSettings === "function";
46
- if (desktopSettings) document.documentElement.classList.add("desktop-settings");
46
+ /** 更新的桌面端:页面右上角的「更多」整个交给桌面端顶栏的「更多」(排在最上面),页面上不显示这个按钮 */
47
+ export const desktopMenu = typeof desktop?.setMenu === "function";
@@ -10,6 +10,7 @@ import { cancelVoice, SpeechRec, toggleVoice } from "./voice.js";
10
10
  import { closeSlashMenu, commandUsage, oneLine, slashKeydown, updateSlashMenu } from "./slash.js";
11
11
  import { attachMention } from "./mention.js";
12
12
  import { doAction, openModelPicker, openSessions } from "./conv-actions.js";
13
+ import { openSettings } from "./layout.js";
13
14
 
14
15
  // ---------- 输入框 ----------
15
16
 
@@ -70,7 +71,7 @@ export function renderComposer() {
70
71
  ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
71
72
  ui.mentionMenu = h("div", { class: "slash-menu", hidden: true });
72
73
  ui.mention = attachMention(ui.input, ui.mentionMenu, {
73
- onEmpty: () => import("./skills.js").then((m) => m.openSkills()),
74
+ onEmpty: () => openSettings("技能"),
74
75
  });
75
76
  ui.composer = h(
76
77
  "div",
@@ -28,7 +28,13 @@ export function upsertInteraction(ix) {
28
28
  return;
29
29
  }
30
30
  if (ix.settled) return;
31
- // 卡片插在这一轮中间:之前的输出先封口,之后的输出接在卡片下面,顺序才对
31
+ insertCard(ix);
32
+ notifyIfHidden(ix);
33
+ }
34
+
35
+ /** 把卡片放进对话:插在这一轮中间的话,之前的输出先封口,之后的输出接在卡片下面,顺序才对 */
36
+ export function insertCard(ix) {
37
+ if (S.items.some((i) => i.type === "ix" && i.id === ix.id)) return;
32
38
  if (S.turnAi && !S.turnAi.done) {
33
39
  S.turnAi.done = true;
34
40
  S.turnAi.segment = true;
@@ -36,9 +42,11 @@ export function upsertInteraction(ix) {
36
42
  S.turnAi = null;
37
43
  }
38
44
  addItem({ type: "ix", id: ix.id, ix });
39
- notifyIfHidden(ix);
40
45
  }
41
46
 
47
+ /** 已处理:「已回答 · 已在 Web 处理」。从会话记录里还原的不知道是谁处理的,只写结果 */
48
+ const settledText = (s) => [SETTLE_LABEL[s.settlement] ?? s.settlement, s.by].filter(Boolean).join(" · ");
49
+
42
50
  function notifyIfHidden(ix) {
43
51
  // 桌面端由服务端的 notice 统一弹,这里再弹就重复了
44
52
  if (desktop || document.visibilityState === "visible" || !("Notification" in window)) return;
@@ -82,7 +90,7 @@ export function renderInteraction(item) {
82
90
  h("div", { class: "ix-title" }, settled ? "" : "⚠️", spec.title),
83
91
  h("div", { class: "ix-detail" }, renderMarkdown(spec.detail)),
84
92
  settled
85
- ? h("div", { class: "ix-meta" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
93
+ ? h("div", { class: "ix-meta" }, settledText(settled))
86
94
  : h(
87
95
  "div",
88
96
  { class: "ix-buttons" },
@@ -128,7 +136,7 @@ export function renderInteraction(item) {
128
136
  );
129
137
  }),
130
138
  settled
131
- ? h("div", { class: "ix-meta" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
139
+ ? h("div", { class: "ix-meta" }, settledText(settled))
132
140
  : h(
133
141
  "div",
134
142
  { class: "ix-buttons" },
@@ -1,9 +1,11 @@
1
1
  import { $app, fill, h, icon, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
- import { desktop, desktopQuota, desktopSettings, S, ui } from "../state.js";
3
+ import { desktop, desktopMenu, desktopQuota, desktopSettings, S, ui } from "../state.js";
4
4
  import { clearAppBadge, WS } from "../lib/ws.js";
5
5
  import { showAuth } from "./auth.js";
6
6
  import {
7
+ applyBootstrap,
8
+ bootstrapQuery,
7
9
  closeSide,
8
10
  marquee,
9
11
  moveProjectDir,
@@ -18,10 +20,11 @@ import {
18
20
  stopMarquee,
19
21
  } from "./sidebar.js";
20
22
  import { applyDesktopQuota, loadQuota, renderQuota } from "./quota.js";
21
- import { openBotConversations, openConvContextMenu, usableAgents } from "./topbar.js";
23
+ import { openBotConversations, openConvContextMenu, runDesktopMenu, usableAgents } from "./topbar.js";
22
24
  import { showMenu } from "../lib/overlay.js";
23
25
  import { bindScrollWatch, newDraft, openConversation, scrollToBottom } from "./messages.js";
24
26
  import { renderComposer } from "./composer.js";
27
+ import { createNav } from "./msg-nav.js";
25
28
  import { renameConv } from "./conv-actions.js";
26
29
  import { releaseForeignPushSub } from "../lib/push.js";
27
30
 
@@ -32,13 +35,14 @@ import { releaseForeignPushSub } from "../lib/push.js";
32
35
  * 加载期间弹窗还没出来,连点 / 桌面端重复触发都会再开一层,所以加载中的再调用直接忽略。
33
36
  */
34
37
  let settingsLoading = false;
35
- function openSettings() {
38
+ /** tab:直接打开哪一页,如「技能」 */
39
+ export function openSettings(tab) {
36
40
  if (settingsLoading) return;
37
41
  settingsLoading = true;
38
42
  import("../settings/index.js").then(
39
43
  (m) => {
40
44
  settingsLoading = false;
41
- return m.openSettings();
45
+ return m.openSettings(tab);
42
46
  },
43
47
  (err) => {
44
48
  settingsLoading = false;
@@ -47,14 +51,6 @@ function openSettings() {
47
51
  );
48
52
  }
49
53
 
50
- /** 技能页:第一次打开时才加载 */
51
- export function openSkillsView() {
52
- import("./skills.js").then(
53
- (m) => m.openSkills(),
54
- (err) => toast(`技能页加载失败,刷新页面再试:${err.message}`),
55
- );
56
- }
57
-
58
54
  /** 定时任务页:第一次打开时才加载 */
59
55
  export function openTasksView() {
60
56
  import("./tasks.js").then(
@@ -69,14 +65,14 @@ export async function boot() {
69
65
  WS.stopped = false;
70
66
  WS.epoch = null;
71
67
  WS.hellos = 0;
72
- const data = await api("/api/bootstrap");
68
+ const data = await api(`/api/bootstrap${bootstrapQuery()}`);
73
69
  S.user = data.user ?? me.user ?? null;
74
70
  S.agents = data.agents;
75
71
  S.bots = data.bots;
76
72
  S.workspaceRoot = data.workspaceRoot;
77
73
  S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
78
74
  S.quotaEnabled = data.quotaEnabled !== false;
79
- S.convs = new Map(data.conversations.map((c) => [c.key, c]));
75
+ applyBootstrap(data);
80
76
  S.pending = new Map(data.pending.map((i) => [i.id, i]));
81
77
  const enabled = usableAgents().map((a) => a.id);
82
78
  if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
@@ -89,14 +85,18 @@ export async function boot() {
89
85
  // 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
90
86
  const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
91
87
  if (fromHash === "tasks") openTasksView();
92
- else if (fromHash === "skills") openSkillsView();
93
88
  else if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
94
89
  else if (latest) await openConversation(latest.key);
95
90
  else newDraft();
91
+ // 以前的技能页地址:技能现在在设置里
92
+ if (fromHash === "skills") openSettings("技能");
96
93
  // 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
97
94
  desktop?.onOpen((key) => {
98
- if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
95
+ // 不要求在 S.convs 里:侧栏分页,那条可能还没拉到;openConversation 会去服务端拿
96
+ if (key && key !== S.current) void openConversation(key);
99
97
  });
98
+ // 页面的「更多」菜单在桌面端顶栏的「更多」里:点了哪项
99
+ if (desktopMenu) desktop.onMenu(runDesktopMenu);
100
100
  // 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
101
101
  if (desktopSettings) {
102
102
  desktop.onOpenSettings(() => {
@@ -111,7 +111,7 @@ export async function boot() {
111
111
  navigator.serviceWorker.addEventListener("message", (e) => {
112
112
  if (e.data?.type !== "open") return;
113
113
  const key = decodeURIComponent(new URL(e.data.url, location.origin).hash.slice(1));
114
- if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
114
+ if (key && key !== S.current) void openConversation(key);
115
115
  });
116
116
  }
117
117
  }
@@ -179,19 +179,16 @@ function renderLayout() {
179
179
  ),
180
180
  // 「最近」上面的入口,像 ChatGPT 的 Tasks。当前在任务页时高亮(renderSidebar 里切换)
181
181
  (ui.tasksNav = h("button", { class: "btn new-chat side-nav", onclick: () => openTasksView() }, icon("clock"), "定时任务")),
182
- (ui.skillsNav = h("button", { class: "btn new-chat side-nav", onclick: () => openSkillsView() }, icon("spark"), "技能")),
183
182
  ui.convList,
184
- h(
183
+ // 底部只有额度:没有额度可显示(关了查询、还没查到)时整块藏起来,连分隔线一起
184
+ (ui.sideBottom = h(
185
185
  "div",
186
- { class: "side-bottom" },
186
+ { class: "side-bottom", hidden: true },
187
187
  (ui.quota = h("div", { class: "quota", onpointerenter: () => renderQuota() })),
188
- h("button", { class: "icon-btn side-gear", title: "设置", onclick: () => (openSettings(), closeSide()) }, icon("gear")),
189
- ),
188
+ )),
190
189
  h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
191
190
  );
192
191
  ui.topbar = h("div", { class: "topbar" });
193
- // 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
194
- ui.modeSlot = h("span", { class: "mode-slot" });
195
192
  ui.msgInner = h("div", { class: "messages-inner" });
196
193
  ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
197
194
  ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
@@ -199,7 +196,7 @@ function renderLayout() {
199
196
  ui.follow = true;
200
197
  bindScrollWatch();
201
198
  ui.composerWrap = h("div", { class: "composer-wrap" });
202
- ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
199
+ ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, createNav(), ui.composerWrap);
203
200
  ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
204
201
  fill($app, ui.layout, h("div", { class: "edge-strip", "aria-hidden": "true" }));
205
202
  renderComposer();
@@ -40,7 +40,7 @@ function match(list, q) {
40
40
 
41
41
  /**
42
42
  * 给一个输入框挂上 @ 补全。menu 是放菜单的元素(调用方决定放哪、怎么定位,样式用 .slash-menu)。
43
- * onEmpty:一个技能都没有时,菜单里给个入口(去技能页)。返回 { update, keydown, close }:
43
+ * onEmpty:一个技能都没有时,菜单里给个入口(去设置里的技能页)。返回 { update, keydown, close }:
44
44
  * 输入框的 input / keydown / blur 里分别调用。
45
45
  */
46
46
  export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
@@ -146,7 +146,7 @@ export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
146
146
  },
147
147
  },
148
148
  h("div", { class: "slash-head" }, h("span", { class: "slash-name" }, "还没有技能")),
149
- h("div", { class: "slash-desc" }, "去「技能」页新建、上传,或者从技能广场安装"),
149
+ h("div", { class: "slash-desc" }, "去「设置 → 技能」新建、上传"),
150
150
  ),
151
151
  );
152
152
  return;