agent-loft 0.2.28 → 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 -5555
- 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,394 @@
|
|
|
1
|
+
import { agentIcon, fill, h, icon, ICON, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { S, ui } from "../state.js";
|
|
5
|
+
import { agentLabel, convTitle, openSide } from "./sidebar.js";
|
|
6
|
+
import { closeMenu, confirmDialog, modal, placePopover, showMenu, showPopover } from "../lib/overlay.js";
|
|
7
|
+
import { newDraft, openConversation } from "./messages.js";
|
|
8
|
+
import { openSchedule } from "./schedule.js";
|
|
9
|
+
import { changeCwd, deleteConv, doAction, forkConversation, openModelPicker, renameConv, switchAgent } from "./conv-actions.js";
|
|
10
|
+
|
|
11
|
+
// ---------- 顶栏 ----------
|
|
12
|
+
|
|
13
|
+
export function renderTopbar() {
|
|
14
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
15
|
+
fill(ui.topbar,
|
|
16
|
+
h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
|
|
17
|
+
h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
|
|
18
|
+
ui.modeSlot,
|
|
19
|
+
h("button", { class: "icon-btn top-more", title: "更多", onclick: (e) => openTopMore(e.currentTarget) }, icon("more")),
|
|
20
|
+
);
|
|
21
|
+
renderContext();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 顶栏右上角「更多」 */
|
|
25
|
+
function openTopMore(anchor) {
|
|
26
|
+
if (ui.menu?.anchor === anchor) return closeMenu();
|
|
27
|
+
showMenu(anchor, [{ label: "定时发送…", onclick: () => openSchedule() }]);
|
|
28
|
+
ui.menu.anchor = anchor;
|
|
29
|
+
// 贴着按钮右边对齐,别超出屏幕
|
|
30
|
+
const r = anchor.getBoundingClientRect();
|
|
31
|
+
ui.menu.style.left = `${Math.max(8, r.right - ui.menu.offsetWidth)}px`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function baseName(p) {
|
|
35
|
+
return (p ?? "").split(/[\\/]/).filter(Boolean).pop() || p || "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function currentCwd() {
|
|
39
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
40
|
+
return c ? c.cwd : (S.draftCwd ?? S.workspaceRoot ?? "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 新对话能选的 agent:启用了、并且服务端查过是装好登录了的(available 为 false 的不出现)。
|
|
45
|
+
* 一个能用的都没有时退回到全部启用的,至少能看到报错,而不是什么都选不了。
|
|
46
|
+
*/
|
|
47
|
+
export function usableAgents() {
|
|
48
|
+
const enabled = S.agents.filter((a) => a.enabled);
|
|
49
|
+
const usable = enabled.filter((a) => a.available !== false);
|
|
50
|
+
return usable.length ? usable : enabled;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pickAgentMenu(anchor) {
|
|
54
|
+
const allowed = usableAgents().map((a) => a.id);
|
|
55
|
+
if (allowed.length < 2) return;
|
|
56
|
+
if (ui.menu?.dataset.for === "agent") return closeMenu();
|
|
57
|
+
const pop = showPopover(anchor, { align: "left" });
|
|
58
|
+
pop.dataset.for = "agent";
|
|
59
|
+
fill(
|
|
60
|
+
pop,
|
|
61
|
+
allowed.map((id) =>
|
|
62
|
+
h(
|
|
63
|
+
"button",
|
|
64
|
+
{ class: `pop-item${id === S.draftAgent ? " on" : ""}`, onclick: () => (closeMenu(), switchAgent(id)) },
|
|
65
|
+
agentIcon(id),
|
|
66
|
+
h("span", { class: "grow" }, agentLabel(id)),
|
|
67
|
+
id === S.draftAgent ? h("span", { class: "check" }, "✓") : null,
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
placePopover(pop, anchor);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 新对话里选的模型 / 思考程度,按 agent 分开记,创建会话时带过去 */
|
|
75
|
+
S.draftPick = JSON.parse(localStorage.getItem("aim.draftPick") || "{}");
|
|
76
|
+
export function draftPick(agent = S.draftAgent) {
|
|
77
|
+
return (S.draftPick[agent] ??= {});
|
|
78
|
+
}
|
|
79
|
+
export function saveDraftPick() {
|
|
80
|
+
localStorage.setItem("aim.draftPick", JSON.stringify(S.draftPick));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 各 agent 的模型列表(新对话用 + 把模型 id 翻成显示名),按 agent 缓存 */
|
|
84
|
+
export const catalogCache = new Map();
|
|
85
|
+
/** 已经拿到的:agent → 模型列表 / 没显式选时实际用的 { model, effort } */
|
|
86
|
+
export const catalogLoaded = new Map();
|
|
87
|
+
export const catalogDefaults = new Map();
|
|
88
|
+
export function agentCatalog(agent) {
|
|
89
|
+
if (!catalogCache.has(agent)) {
|
|
90
|
+
const p = api(`/api/agents/${agent}/models`).then((d) => {
|
|
91
|
+
catalogLoaded.set(agent, d.models ?? []);
|
|
92
|
+
catalogDefaults.set(agent, d.defaults ?? {});
|
|
93
|
+
return d.models ?? [];
|
|
94
|
+
});
|
|
95
|
+
p.catch(() => catalogCache.delete(agent));
|
|
96
|
+
catalogCache.set(agent, p);
|
|
97
|
+
}
|
|
98
|
+
return catalogCache.get(agent);
|
|
99
|
+
}
|
|
100
|
+
/** 列表还没拿到就去拿,拿到后重画输入框的模型按钮 */
|
|
101
|
+
function ensureCatalog(agent) {
|
|
102
|
+
if (!agent || catalogLoaded.has(agent) || catalogCache.has(agent)) return;
|
|
103
|
+
void agentCatalog(agent).then(() => renderContext()).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 设置里勾选的、输入框模型列表要显示的模型;没设置过(空)就全部显示 */
|
|
107
|
+
export function visibleModels(agent, models) {
|
|
108
|
+
const allow = S.agents.find((a) => a.id === agent)?.models ?? [];
|
|
109
|
+
if (!allow.length) return models;
|
|
110
|
+
const shown = models.filter((m) => allow.includes(m.id));
|
|
111
|
+
return shown.length ? shown : models;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Claude 的 id 有几种写法:别名(opus)、完整名(claude-opus-5-5、带日期的、带 [1m] 的)
|
|
115
|
+
const bareModelId = (id) => String(id).replace(/\[[^\]]*\]$/, "").replace(/-\d{8}$/, "");
|
|
116
|
+
/** 「Default (recommended)」那一项实际是哪个模型:Claude 写在说明的开头(「Opus 5.5 · …」) */
|
|
117
|
+
export function defaultEntryName(m) {
|
|
118
|
+
const head = m.description?.split(" · ")[0]?.trim();
|
|
119
|
+
return head && head.length <= 24 && head !== m.description ? head : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 模型 id → 给人看的名字(Opus 5.5、GPT-6.1-Sol),和选择列表里的写法一致。
|
|
123
|
+
* id 为空 = 没显式选,按 agent 的默认模型算。都对不上时尽量从 id 拼出来,实在不行才显示 id。
|
|
124
|
+
*/
|
|
125
|
+
export function modelName(agent, id) {
|
|
126
|
+
const models = catalogLoaded.get(agent) ?? [];
|
|
127
|
+
if (!id) id = catalogDefaults.get(agent)?.model;
|
|
128
|
+
if (!id || id === "default") {
|
|
129
|
+
const def = models.find((m) => m.id === "default") ?? models.find((m) => m.isDefault);
|
|
130
|
+
if (!def) return null;
|
|
131
|
+
return def.id === "default" ? (defaultEntryName(def) ?? def.label) : def.label || def.id;
|
|
132
|
+
}
|
|
133
|
+
const exact = models.find((m) => m.id === id) ?? models.find((m) => bareModelId(m.id) === bareModelId(id));
|
|
134
|
+
if (exact) return exact.id === "default" ? (defaultEntryName(exact) ?? exact.label) : exact.label || exact.id;
|
|
135
|
+
const claude = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?$/.exec(bareModelId(id));
|
|
136
|
+
if (claude) return `${claude[1][0].toUpperCase()}${claude[1].slice(1)} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
|
|
137
|
+
return id;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 输入框上方的上下文条(目录 / agent)和输入框里的模型按钮 */
|
|
141
|
+
export function renderContext() {
|
|
142
|
+
if (!ui.ctxBar) return;
|
|
143
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
144
|
+
const cwd = currentCwd();
|
|
145
|
+
const multi = usableAgents().length > 1;
|
|
146
|
+
// 会话里不能换 agent(两个 agent 的会话互不相通),只显示;新对话里可以选
|
|
147
|
+
const agentId = c ? c.agent : S.draftAgent;
|
|
148
|
+
const agentEl = c || !multi
|
|
149
|
+
? h("span", { class: "ctx-item static" }, agentIcon(agentId), h("span", { class: "label" }, agentLabel(agentId ?? "") || "无可用 agent"))
|
|
150
|
+
: h(
|
|
151
|
+
"button",
|
|
152
|
+
{ class: "ctx-item", title: "选择 agent", onclick: (e) => pickAgentMenu(e.currentTarget) },
|
|
153
|
+
agentIcon(agentId),
|
|
154
|
+
h("span", { class: "label" }, agentLabel(agentId ?? "")),
|
|
155
|
+
h("span", { class: "chev", html: ICON.down }),
|
|
156
|
+
);
|
|
157
|
+
fill(
|
|
158
|
+
ui.ctxBar,
|
|
159
|
+
h("button", { class: "ctx-item", title: `工作目录:${cwd}\n点击切换`, onclick: () => changeCwd() }, icon("folder"), h("span", { class: "label" }, baseName(cwd) || "选择目录")),
|
|
160
|
+
agentEl,
|
|
161
|
+
);
|
|
162
|
+
updateCtxVisibility();
|
|
163
|
+
|
|
164
|
+
// 直接显示实际的模型名和思考程度(和选择列表里一样的写法),不显示「默认模型」
|
|
165
|
+
let model;
|
|
166
|
+
let effort;
|
|
167
|
+
const modelAgent = c ? c.agent : S.draftAgent;
|
|
168
|
+
if (modelAgent) {
|
|
169
|
+
ensureCatalog(modelAgent);
|
|
170
|
+
const defaults = catalogDefaults.get(modelAgent) ?? {};
|
|
171
|
+
const pick = c ? null : draftPick();
|
|
172
|
+
const id = c ? (c.runtimeModel ?? c.model) : pick.model;
|
|
173
|
+
model = modelName(modelAgent, id) ?? (catalogLoaded.has(modelAgent) ? "选择模型" : "…");
|
|
174
|
+
effort = (c ? (c.runtimeEffort ?? c.effort) : pick.effort) ?? defaults.effort;
|
|
175
|
+
}
|
|
176
|
+
renderCtxRing();
|
|
177
|
+
renderModePill(c);
|
|
178
|
+
fill(
|
|
179
|
+
ui.modelSlot,
|
|
180
|
+
model
|
|
181
|
+
? h(
|
|
182
|
+
"button",
|
|
183
|
+
{ class: "model-pill", title: "切换模型和思考程度", onclick: () => void openModelPicker() },
|
|
184
|
+
h("span", { class: "model-name" }, model),
|
|
185
|
+
effort ? h("span", { class: "sub" }, effort) : null,
|
|
186
|
+
icon("down"),
|
|
187
|
+
)
|
|
188
|
+
: null,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 输入框里的「聊天 / 工作」切换。会话里切换走服务端(同一条对话接着聊),新对话里只是记下选择 */
|
|
193
|
+
function renderModePill(c) {
|
|
194
|
+
if (!ui.modeSlot) return;
|
|
195
|
+
const mode = c ? (c.mode ?? "work") : S.draftMode;
|
|
196
|
+
const chat = mode === "chat";
|
|
197
|
+
fill(
|
|
198
|
+
ui.modeSlot,
|
|
199
|
+
h(
|
|
200
|
+
"button",
|
|
201
|
+
{
|
|
202
|
+
class: `mode-pill${chat ? " chat" : ""}`,
|
|
203
|
+
title: chat
|
|
204
|
+
? "聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮开销小很多。点击切到工作模式"
|
|
205
|
+
: "工作模式:agent 原装的全部能力。点击切到聊天模式(更省、更快,适合日常聊天和查资料)",
|
|
206
|
+
onclick: () => void toggleMode(),
|
|
207
|
+
},
|
|
208
|
+
chat ? "💬 聊天" : "🛠 工作",
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function toggleMode() {
|
|
214
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
215
|
+
const next = (c ? (c.mode ?? "work") : S.draftMode) === "chat" ? "work" : "chat";
|
|
216
|
+
if (!c) {
|
|
217
|
+
S.draftMode = next;
|
|
218
|
+
localStorage.setItem("aim.mode", next);
|
|
219
|
+
renderContext();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// 聊过的聊天对话切到工作模式:工作模式会在对话里留下一大段 Claude Code / Codex 的背景,
|
|
223
|
+
// 之后再切回聊天模式每轮还得带着它,就省不了了。先问一句,建议另开一条工作对话
|
|
224
|
+
if (next === "work" && S.items.length > 0) {
|
|
225
|
+
const choice = await confirmDialog({
|
|
226
|
+
title: "在这条对话里切到工作模式?",
|
|
227
|
+
message:
|
|
228
|
+
"切过去以后,这条对话里会留下工作模式的一大段背景(约 1.7 万 token),之后再切回聊天模式每轮也得带着,就省不了了。切换后的第一轮也会稍贵一些。\n\n要干活的话,建议新开一条工作对话(目录和 agent 跟这条一样),这条留着继续聊天。",
|
|
229
|
+
confirmText: "新开工作对话",
|
|
230
|
+
altText: "就在这里切",
|
|
231
|
+
});
|
|
232
|
+
if (choice === "alt") await doAction("mode", "work");
|
|
233
|
+
else if (choice === true) {
|
|
234
|
+
S.draftAgent = c.agent;
|
|
235
|
+
S.draftCwd = c.cwd;
|
|
236
|
+
S.draftMode = "work";
|
|
237
|
+
localStorage.setItem("aim.mode", "work");
|
|
238
|
+
newDraft();
|
|
239
|
+
toast("已新开工作对话,目录和 agent 跟原来那条一样");
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await doAction("mode", next);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 84321 → 84k,1234567 → 1.2M */
|
|
247
|
+
function fmtTokens(n) {
|
|
248
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
|
|
249
|
+
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
|
250
|
+
return String(n);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 输入框里的上下文窗口小圆环,hover 显示详情 */
|
|
254
|
+
function renderCtxRing() {
|
|
255
|
+
if (!ui.ctxRing) return;
|
|
256
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
257
|
+
const ctx = c?.context;
|
|
258
|
+
if (!ctx) return fill(ui.ctxRing);
|
|
259
|
+
const pct = Math.max(0, Math.min(100, ctx.percent));
|
|
260
|
+
const level = pct >= 90 ? "danger" : pct >= 75 ? "warn" : "";
|
|
261
|
+
const r = 7;
|
|
262
|
+
const len = 2 * Math.PI * r;
|
|
263
|
+
fill(
|
|
264
|
+
ui.ctxRing,
|
|
265
|
+
h(
|
|
266
|
+
"span",
|
|
267
|
+
{ class: `ctx-ring ${level}`, tabindex: "0", "aria-label": `上下文已用 ${Math.round(ctx.percent)}%` },
|
|
268
|
+
h("span", {
|
|
269
|
+
class: "ring",
|
|
270
|
+
html: `<svg viewBox="0 0 18 18"><circle cx="9" cy="9" r="${r}" class="track"/><circle cx="9" cy="9" r="${r}" class="bar" stroke-dasharray="${((pct / 100) * len).toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 9 9)"/></svg>`,
|
|
271
|
+
}),
|
|
272
|
+
h(
|
|
273
|
+
"span",
|
|
274
|
+
{ class: "tip" },
|
|
275
|
+
h("span", { class: "tip-title" }, "上下文窗口:"),
|
|
276
|
+
h("span", {}, `${Math.round(ctx.percent)}% 已用(剩余 ${Math.max(0, 100 - Math.round(ctx.percent))}%)`),
|
|
277
|
+
h("span", {}, `已用 ${fmtTokens(ctx.used)} tokens,共 ${fmtTokens(ctx.window)}`),
|
|
278
|
+
),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 发出第一条消息、或会话里已经有内容后,就不再显示上下文条 */
|
|
284
|
+
export const sentKeys = new Set();
|
|
285
|
+
export function updateCtxVisibility() {
|
|
286
|
+
if (ui.ctxBar) ui.ctxBar.hidden = !!S.current && (S.items.length > 0 || sentKeys.has(S.current));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 侧栏会话的右键菜单:分叉 / 重命名 / 删除 */
|
|
290
|
+
export function openConvContextMenu(key, at) {
|
|
291
|
+
const c = S.convs.get(key);
|
|
292
|
+
if (!c) return;
|
|
293
|
+
showMenu(at, [
|
|
294
|
+
{
|
|
295
|
+
label: "分叉",
|
|
296
|
+
onclick: async () => {
|
|
297
|
+
if (c.canFork) return forkConversation(undefined, key);
|
|
298
|
+
// 不支持另开一条的:只能在原对话里分叉,先切过去
|
|
299
|
+
if (key !== S.current) await openConversation(key);
|
|
300
|
+
await doAction("fork");
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{ label: "重命名", onclick: () => renameConv(key) },
|
|
304
|
+
...(c.channel === "web" && S.bots.length > 0 ? [{ label: "在飞书继续…", onclick: () => continueInFeishu(key) }] : []),
|
|
305
|
+
...(c.channel === "web" ? [{ label: "删除", danger: true, onclick: () => deleteConv(key) }] : []),
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 在飞书继续:选一个机器人聊过的飞书聊天,把它接到这条 Web 会话上。
|
|
311
|
+
* 之后在那个聊天里说话就进这条会话,回复、授权、提问也发过去;飞书里发 /detach 退回来。
|
|
312
|
+
* 只有一个聊天可选时直接接上,不弹窗。
|
|
313
|
+
*/
|
|
314
|
+
async function continueInFeishu(key) {
|
|
315
|
+
let data;
|
|
316
|
+
try {
|
|
317
|
+
data = await api("/api/feishu/targets");
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return toast(err.message);
|
|
320
|
+
}
|
|
321
|
+
const targets = data.bots.flatMap((b) => b.chats.map((c) => ({ bot: b, chat: c })));
|
|
322
|
+
if (data.bots.length === 0) return toast("没有运行中的飞书机器人");
|
|
323
|
+
if (targets.length === 0) return toast("机器人还没有聊天:先在飞书里给它发一条消息,再回来推过去");
|
|
324
|
+
const go = async (t, close) => {
|
|
325
|
+
try {
|
|
326
|
+
const r = await api(`/api/conversations/${encodeURIComponent(key)}/feishu`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: { botId: t.bot.id, chatId: t.chat.chatId },
|
|
329
|
+
});
|
|
330
|
+
close?.();
|
|
331
|
+
toast(r.text);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
toast(err.message);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
if (targets.length === 1) return go(targets[0]);
|
|
337
|
+
const multiBot = data.bots.filter((b) => b.chats.length > 0).length > 1;
|
|
338
|
+
const body = h("div", { class: "modal-body" });
|
|
339
|
+
const close = modal({ title: "在飞书继续", body });
|
|
340
|
+
fill(
|
|
341
|
+
body,
|
|
342
|
+
h("div", { class: "muted" }, "选一个飞书聊天,之后在那里说话就会发进这条会话,回复和授权也会发过去。在飞书里发 /detach 回到那个聊天自己的会话。"),
|
|
343
|
+
...targets.map((t) => {
|
|
344
|
+
const here = t.chat.attached === key;
|
|
345
|
+
const other = t.chat.attached && !here ? S.convs.get(t.chat.attached) : null;
|
|
346
|
+
const note = here ? "已接着这条" : other ? `正接着「${convTitle(other)}」,选它会换过来` : "";
|
|
347
|
+
return h(
|
|
348
|
+
"button",
|
|
349
|
+
{ class: `list-item${here ? " on" : ""}`, onclick: () => go(t, close) },
|
|
350
|
+
h("span", { class: "brand-icon", html: ICON.feishu }),
|
|
351
|
+
h(
|
|
352
|
+
"div",
|
|
353
|
+
{ class: "grow" },
|
|
354
|
+
h("div", { class: "t" }, multiBot ? `${t.bot.name} · ${t.chat.label}` : t.chat.label),
|
|
355
|
+
h("div", { class: "s" }, [`${relTime(t.chat.activeAt)}活跃`, t.bot.state !== "connected" ? "机器人未连上" : "", note].filter(Boolean).join(" · ")),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
|
|
363
|
+
export function openBotConversations(botId) {
|
|
364
|
+
const bot = S.bots.find((b) => b.id === botId);
|
|
365
|
+
const convs = [...S.convs.values()]
|
|
366
|
+
.filter((c) => c.channel === "feishu" && c.channelId === botId && !c.hidden)
|
|
367
|
+
.sort((a, b) => b.activeAt - a.activeAt);
|
|
368
|
+
const body = h("div", { class: "modal-body" });
|
|
369
|
+
const close = modal({ title: `历史会话${bot ? ` · ${bot.name}` : ""}`, body });
|
|
370
|
+
fill(
|
|
371
|
+
body,
|
|
372
|
+
...(convs.length === 0
|
|
373
|
+
? [h("div", { class: "muted" }, "还没有会话")]
|
|
374
|
+
: convs.map((c) =>
|
|
375
|
+
h(
|
|
376
|
+
"button",
|
|
377
|
+
{
|
|
378
|
+
class: `list-item${c.key === S.current ? " on" : ""}`,
|
|
379
|
+
onclick: () => {
|
|
380
|
+
close();
|
|
381
|
+
if (c.key !== S.current) void openConversation(c.key);
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
h(
|
|
385
|
+
"div",
|
|
386
|
+
{ class: "grow" },
|
|
387
|
+
h("div", { class: "t" }, convTitle(c)),
|
|
388
|
+
h("div", { class: "s" }, `${relTime(c.activeAt)} · ${agentLabel(c.agent)} · ${c.cwd}`),
|
|
389
|
+
),
|
|
390
|
+
c.key === S.current ? "当前" : c.busy ? h("span", { class: "conv-spin", title: "进行中" }) : null,
|
|
391
|
+
),
|
|
392
|
+
)),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { toast } from "../lib/dom.js";
|
|
2
|
+
import { ui } from "../state.js";
|
|
3
|
+
import { autosize, updateSendBtn } from "./composer.js";
|
|
4
|
+
|
|
5
|
+
// ---------- 语音输入(浏览器自带 Web Speech API) ----------
|
|
6
|
+
// Chrome / Edge 走各自厂商的在线识别服务,Safari 走系统识别;Firefox 没有这个 API,按钮直接不显示。
|
|
7
|
+
// 必须是安全上下文(HTTPS 或 localhost)才能拿到麦克风。
|
|
8
|
+
|
|
9
|
+
export const SpeechRec = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
|
10
|
+
// Android Chrome 的 continuous 模式会把已确认的结果重复回吐,改成单句识别、说完自动续上
|
|
11
|
+
const voiceContinuous = !/Android/i.test(navigator.userAgent);
|
|
12
|
+
|
|
13
|
+
const VOICE_ERR = {
|
|
14
|
+
"not-allowed": "没有麦克风权限(需要 HTTPS,并在浏览器里允许麦克风)",
|
|
15
|
+
"service-not-allowed": "浏览器不允许使用语音识别服务",
|
|
16
|
+
"audio-capture": "没找到可用的麦克风",
|
|
17
|
+
network: "语音识别需要联网,连不上识别服务",
|
|
18
|
+
"language-not-supported": "不支持当前语言的语音识别",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** rec:当前识别实例;before/after:开始时光标前后的文字;committed:已确认的识别结果 */
|
|
22
|
+
const voice = { rec: null, want: false, before: "", after: "", committed: "", target: null, stopTimer: null };
|
|
23
|
+
|
|
24
|
+
export function toggleVoice() {
|
|
25
|
+
// 点过停止、正在等收尾(按钮已经不亮了):再点就是要重新开始,先丢掉旧的
|
|
26
|
+
if (voice.rec && !voice.want) cancelVoice();
|
|
27
|
+
if (voice.rec) stopVoice();
|
|
28
|
+
else startVoice();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function startVoice() {
|
|
32
|
+
if (!SpeechRec) return toast("当前浏览器不支持语音输入");
|
|
33
|
+
const ta = ui.input;
|
|
34
|
+
voice.target = ta;
|
|
35
|
+
voice.before = ta.value.slice(0, ta.selectionStart);
|
|
36
|
+
voice.after = ta.value.slice(ta.selectionEnd);
|
|
37
|
+
voice.committed = "";
|
|
38
|
+
voice.want = true;
|
|
39
|
+
try {
|
|
40
|
+
listenOnce();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
voice.rec = null;
|
|
43
|
+
voice.want = false;
|
|
44
|
+
toast(`无法启动语音识别:${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
updateMicBtn();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 正常结束的识别实例留着下次复用:iOS Safari 每次 new 一个新实例,
|
|
51
|
+
* 第二次开始往往一个结果都不回。被 abort / 卡住的实例不复用,下次换新的。
|
|
52
|
+
*/
|
|
53
|
+
let spareRec = null;
|
|
54
|
+
|
|
55
|
+
/** voice.rec 是一轮识别(run):{ rec, heard, shown, done },事件都按 run 对号入座 */
|
|
56
|
+
function listenOnce() {
|
|
57
|
+
const run = { rec: null, heard: "", shown: "", done: false };
|
|
58
|
+
const wire = (rec) => {
|
|
59
|
+
rec.lang = navigator.language || "zh-CN";
|
|
60
|
+
rec.continuous = voiceContinuous;
|
|
61
|
+
rec.interimResults = true;
|
|
62
|
+
rec.onresult = (e) => {
|
|
63
|
+
if (voice.rec !== run) return;
|
|
64
|
+
let fin = "";
|
|
65
|
+
let interim = "";
|
|
66
|
+
for (const r of e.results) {
|
|
67
|
+
if (r.isFinal) fin += r[0].transcript;
|
|
68
|
+
else interim += r[0].transcript;
|
|
69
|
+
}
|
|
70
|
+
run.heard = fin;
|
|
71
|
+
run.shown = fin + interim;
|
|
72
|
+
showVoiceText(joinVoice(voice.committed, run.shown));
|
|
73
|
+
};
|
|
74
|
+
rec.onerror = (e) => {
|
|
75
|
+
if (voice.rec !== run) return;
|
|
76
|
+
if (e.error === "no-speech" || e.error === "aborted") return;
|
|
77
|
+
voice.want = false;
|
|
78
|
+
toast(VOICE_ERR[e.error] ?? `语音识别出错:${e.error}`);
|
|
79
|
+
};
|
|
80
|
+
rec.onend = () => {
|
|
81
|
+
if (voice.rec !== run) return; // 已被取消 / 已被强制结束
|
|
82
|
+
finishRun(run, true);
|
|
83
|
+
};
|
|
84
|
+
return rec;
|
|
85
|
+
};
|
|
86
|
+
let rec = wire(spareRec ?? new SpeechRec());
|
|
87
|
+
spareRec = null;
|
|
88
|
+
run.rec = rec;
|
|
89
|
+
voice.rec = run;
|
|
90
|
+
try {
|
|
91
|
+
rec.start();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// 复用的实例还没完全停下(InvalidStateError):换个新的再来
|
|
94
|
+
if (err?.name !== "InvalidStateError") throw err;
|
|
95
|
+
rec = wire(new SpeechRec());
|
|
96
|
+
run.rec = rec;
|
|
97
|
+
rec.start();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 一轮识别结束:把这轮听到的定下来,需要的话接着听下一轮 */
|
|
102
|
+
function finishRun(run, clean) {
|
|
103
|
+
if (run.done) return;
|
|
104
|
+
run.done = true;
|
|
105
|
+
clearTimeout(voice.stopTimer);
|
|
106
|
+
// Safari 有时整轮都不给 isFinal:没有定稿就以屏幕上显示的为准,不能把已经显示的字吞掉
|
|
107
|
+
const heard = run.heard || run.shown;
|
|
108
|
+
voice.committed = joinVoice(voice.committed, heard);
|
|
109
|
+
showVoiceText(voice.committed);
|
|
110
|
+
if (clean) spareRec = run.rec;
|
|
111
|
+
// 这一轮什么都没听到就当说完了,避免麦克风一直开着
|
|
112
|
+
if (clean && voice.want && heard) {
|
|
113
|
+
try {
|
|
114
|
+
listenOnce();
|
|
115
|
+
return;
|
|
116
|
+
} catch {
|
|
117
|
+
// 续不上就结束
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
voice.rec = null;
|
|
121
|
+
voice.want = false;
|
|
122
|
+
updateMicBtn();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 用户点停止:让浏览器把最后一句定稿后再结束 */
|
|
126
|
+
function stopVoice() {
|
|
127
|
+
const run = voice.rec;
|
|
128
|
+
if (!run) return;
|
|
129
|
+
voice.want = false;
|
|
130
|
+
// 先把按钮恢复,用户能立刻看到已经停了
|
|
131
|
+
if (ui.micBtn) ui.micBtn.classList.remove("recording");
|
|
132
|
+
try {
|
|
133
|
+
run.rec.stop();
|
|
134
|
+
} catch {
|
|
135
|
+
// 已经停了
|
|
136
|
+
}
|
|
137
|
+
// iOS 上 stop() 之后有时永远等不来 onend,按钮就一直卡在「录音中」,再点只会又 stop 一次。
|
|
138
|
+
// 等一会儿还没结束就强制收尾,这个实例不再复用
|
|
139
|
+
clearTimeout(voice.stopTimer);
|
|
140
|
+
voice.stopTimer = setTimeout(() => {
|
|
141
|
+
if (voice.rec !== run) return;
|
|
142
|
+
try {
|
|
143
|
+
run.rec.abort();
|
|
144
|
+
} catch {
|
|
145
|
+
// 忽略
|
|
146
|
+
}
|
|
147
|
+
finishRun(run, false);
|
|
148
|
+
}, 1500);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 发送、切会话、用户手动改输入框时:立刻丢掉识别,不再往输入框写 */
|
|
152
|
+
export function cancelVoice() {
|
|
153
|
+
const run = voice.rec;
|
|
154
|
+
if (!run) return;
|
|
155
|
+
clearTimeout(voice.stopTimer);
|
|
156
|
+
run.done = true;
|
|
157
|
+
voice.rec = null;
|
|
158
|
+
voice.want = false;
|
|
159
|
+
try {
|
|
160
|
+
run.rec.abort();
|
|
161
|
+
} catch {
|
|
162
|
+
// 忽略
|
|
163
|
+
}
|
|
164
|
+
updateMicBtn();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 中日韩直接拼接,其它语言之间补空格 */
|
|
168
|
+
function joinVoice(a, b) {
|
|
169
|
+
if (!a || !b) return a + b;
|
|
170
|
+
if (/\s$/.test(a) || /^\s/.test(b) || /^(zh|ja|ko)/i.test(navigator.language)) return a + b;
|
|
171
|
+
return `${a} ${b}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function showVoiceText(text) {
|
|
175
|
+
const ta = voice.target;
|
|
176
|
+
if (!ta || ta !== ui.input) return;
|
|
177
|
+
ta.value = voice.before + text + voice.after;
|
|
178
|
+
const caret = voice.before.length + text.length;
|
|
179
|
+
ta.setSelectionRange(caret, caret);
|
|
180
|
+
autosize();
|
|
181
|
+
updateSendBtn();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function updateMicBtn() {
|
|
185
|
+
if (!ui.micBtn) return;
|
|
186
|
+
const on = !!voice.rec;
|
|
187
|
+
ui.micBtn.classList.toggle("recording", on);
|
|
188
|
+
ui.micBtn.title = on ? "停止语音输入" : "语音输入";
|
|
189
|
+
}
|