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.
- package/README.md +9 -3
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +80 -2
- package/src/agents/claude/session.ts +51 -7
- package/src/agents/codex/session.ts +43 -7
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +84 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/server.ts +13 -4
- package/src/core/hub.ts +126 -19
- package/src/core/model-prices.json +103 -0
- package/src/core/pricing.ts +93 -0
- package/src/core/skill-runtime.ts +1 -1
- package/src/core/skills.ts +2 -1
- package/src/core/types.ts +24 -2
- package/src/core/usage.ts +144 -0
- package/web/css/composer.css +0 -25
- package/web/css/messages.css +169 -0
- package/web/css/settings.css +194 -0
- package/web/css/sidebar.css +5 -5
- package/web/css/skills.css +18 -1
- package/web/css/topbar.css +131 -0
- package/web/lib/dom.js +2 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +46 -6
- package/web/settings/index.js +11 -4
- package/web/{views → settings}/skills.js +86 -172
- package/web/settings/usage.js +272 -0
- package/web/state.js +5 -4
- package/web/views/composer.js +2 -1
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -25
- package/web/views/mention.js +2 -2
- package/web/views/messages.js +239 -81
- package/web/views/msg-nav.js +120 -0
- package/web/views/quota.js +1 -0
- package/web/views/sidebar.js +114 -17
- package/web/views/topbar.js +169 -41
|
@@ -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"
|
|
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
|
-
|
|
46
|
+
/** 更新的桌面端:页面右上角的「更多」整个交给桌面端顶栏的「更多」(排在最上面),页面上不显示这个按钮 */
|
|
47
|
+
export const desktopMenu = typeof desktop?.setMenu === "function";
|
package/web/views/composer.js
CHANGED
|
@@ -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: () =>
|
|
74
|
+
onEmpty: () => openSettings("技能"),
|
|
74
75
|
});
|
|
75
76
|
ui.composer = h(
|
|
76
77
|
"div",
|
package/web/views/interaction.js
CHANGED
|
@@ -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" },
|
|
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" },
|
|
139
|
+
? h("div", { class: "ix-meta" }, settledText(settled))
|
|
132
140
|
: h(
|
|
133
141
|
"div",
|
|
134
142
|
{ class: "ix-buttons" },
|
package/web/views/layout.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/web/views/mention.js
CHANGED
|
@@ -40,7 +40,7 @@ function match(list, q) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* 给一个输入框挂上 @ 补全。menu 是放菜单的元素(调用方决定放哪、怎么定位,样式用 .slash-menu)。
|
|
43
|
-
* onEmpty
|
|
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;
|