@oharness/server 0.9.3 → 0.10.0
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/dist/i18n.js +354 -62
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.js +430 -23
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +20 -1
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/composer.js +693 -54
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/computer.d.ts +27 -0
- package/dist/page/client/computer.js +358 -0
- package/dist/page/client/computer.js.map +1 -0
- package/dist/page/client/dialogs.js +93 -6
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +94 -7
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/files.js +86 -1
- package/dist/page/client/files.js.map +1 -1
- package/dist/page/client/index.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +10 -6
- package/dist/page/client/panels.js.map +1 -1
- package/dist/page/client/routines.js +296 -41
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +187 -51
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +273 -19
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/tasks.d.ts +8 -0
- package/dist/page/client/tasks.js +293 -0
- package/dist/page/client/tasks.js.map +1 -0
- package/dist/page/client/transcript.js +137 -5
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/client/usage.js +1 -11
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +568 -60
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +90 -11
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +14 -0
- package/dist/prefs.js +8 -1
- package/dist/prefs.js.map +1 -1
- package/dist/remote-crypto.d.ts +78 -0
- package/dist/remote-crypto.js +216 -0
- package/dist/remote-crypto.js.map +1 -0
- package/dist/remote.d.ts +103 -0
- package/dist/remote.js +498 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +70 -0
- package/dist/server.js +1291 -49
- package/dist/server.js.map +1 -1
- package/dist/starters.d.ts +92 -0
- package/dist/starters.js +213 -0
- package/dist/starters.js.map +1 -0
- package/dist/turn-shots.d.ts +27 -0
- package/dist/turn-shots.js +71 -0
- package/dist/turn-shots.js.map +1 -0
- package/package.json +2 -2
|
@@ -11,6 +11,7 @@ export const COMPOSER = String.raw `
|
|
|
11
11
|
// stays small and a half-finished upload cannot start a turn.
|
|
12
12
|
|
|
13
13
|
const attachEl = document.getElementById("attachments");
|
|
14
|
+
const trayEl = document.getElementById("tray");
|
|
14
15
|
const queuedEl = document.getElementById("queued");
|
|
15
16
|
const fileInput = document.getElementById("file");
|
|
16
17
|
const composerBox = document.getElementById("composer-box");
|
|
@@ -30,6 +31,160 @@ const sentPreviews = new Map();
|
|
|
30
31
|
*/
|
|
31
32
|
let refs = [];
|
|
32
33
|
const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
|
|
34
|
+
/**
|
|
35
|
+
* Whether the next message is sent as a goal.
|
|
36
|
+
*
|
|
37
|
+
* Switched on from the + menu or a bare /goal, and off again once a message
|
|
38
|
+
* goes: a goal is one objective, and every message after it staying a goal
|
|
39
|
+
* would replace the one being worked on.
|
|
40
|
+
*/
|
|
41
|
+
let goalMode = false;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the next message starts a new session in a new worktree beside the
|
|
44
|
+
* project, instead of going to this one. Off again once it is sent, like a
|
|
45
|
+
* goal: it describes one message, not the conversation.
|
|
46
|
+
*/
|
|
47
|
+
let worktreeMode = false;
|
|
48
|
+
const inputPlaceholder = () =>
|
|
49
|
+
t(goalMode ? "goal.placeholder" : worktreeMode ? "worktree.placeholder" : "composer.placeholder");
|
|
50
|
+
|
|
51
|
+
function setGoalMode(on) {
|
|
52
|
+
goalMode = on;
|
|
53
|
+
input.placeholder = inputPlaceholder();
|
|
54
|
+
renderAttachments();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setWorktreeMode(on) {
|
|
58
|
+
worktreeMode = on;
|
|
59
|
+
input.placeholder = inputPlaceholder();
|
|
60
|
+
renderAttachments();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Make the worktree, open the session in it, and send it the message.
|
|
65
|
+
*
|
|
66
|
+
* The server does all three in one call so the first turn cannot start in the
|
|
67
|
+
* wrong tree; the page then moves to the new tab the way any switch does.
|
|
68
|
+
*/
|
|
69
|
+
async function startInWorktree(body) {
|
|
70
|
+
const state = await api("/worktree", body, 5 * 60_000);
|
|
71
|
+
if (!state || state.error) return void addNotice((state && state.error) || t("worktree.failed"), "notice err");
|
|
72
|
+
await openState(state);
|
|
73
|
+
await loadProjects();
|
|
74
|
+
const wt = state.worktree || {};
|
|
75
|
+
addNotice(
|
|
76
|
+
wt.branch
|
|
77
|
+
? t("worktree.startedBranch", { name: projectName(wt.root || wt.path), branch: wt.branch })
|
|
78
|
+
: t("worktree.started", { name: projectName(wt.root || wt.path) }),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Computer use for the conversation on screen: off, or on with what the OS
|
|
84
|
+
* currently lets it do. Kept on the pane, from the server's state, because it
|
|
85
|
+
* belongs to the conversation rather than to the next message the way goal
|
|
86
|
+
* mode does.
|
|
87
|
+
*/
|
|
88
|
+
const computerHere = () => {
|
|
89
|
+
const here = panes.get(activeId);
|
|
90
|
+
return (here && here.computer) || null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function setComputerUse(on) {
|
|
94
|
+
track("computer_use", { on });
|
|
95
|
+
void api("/computer-use", { session: activeId, on }).then((s) => {
|
|
96
|
+
if (s && s.error) return void addNotice(s.error, "notice err");
|
|
97
|
+
applyState(s);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** While the permission card is up, look again every two seconds. */
|
|
102
|
+
let computerPoll = null;
|
|
103
|
+
function watchComputerGrants(missing) {
|
|
104
|
+
if (missing && !computerPoll) {
|
|
105
|
+
computerPoll = setInterval(() => void api("/computer-use/permissions", {}), 2000);
|
|
106
|
+
} else if (!missing && computerPoll) {
|
|
107
|
+
clearInterval(computerPoll);
|
|
108
|
+
computerPoll = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The macOS grants, asked for in place.
|
|
114
|
+
*
|
|
115
|
+
* Only on macOS and only for what is missing. Each row asks for one grant, so
|
|
116
|
+
* the system prompt that follows is about exactly the thing the row names.
|
|
117
|
+
*/
|
|
118
|
+
function computerPermissionCard(computer) {
|
|
119
|
+
const perms = computer.permissions;
|
|
120
|
+
const card = el("div", "att computer-perm");
|
|
121
|
+
const head = el("div", "cp-head");
|
|
122
|
+
head.appendChild(svgIcon("computer"));
|
|
123
|
+
// The app whose switch it is: OHarness, or Electron for a development run.
|
|
124
|
+
const grantee = computer.grantee || "OHarness";
|
|
125
|
+
head.appendChild(el("span", "cp-title", t("computer.permTitle", { app: grantee })));
|
|
126
|
+
card.appendChild(head);
|
|
127
|
+
card.appendChild(el("div", "cp-body", t(grantee === "OHarness" ? "computer.permBody" : "computer.permBodyDev", { app: grantee })));
|
|
128
|
+
for (const [grant, label] of [["screen", "computer.permScreen"], ["accessibility", "computer.permAx"]]) {
|
|
129
|
+
const row = el("div", "cp-row");
|
|
130
|
+
row.appendChild(el("span", "cp-label", t(label)));
|
|
131
|
+
if (perms[grant]) {
|
|
132
|
+
// A tick, not a word in green: done is the quiet state, and the row's
|
|
133
|
+
// own label already says what was allowed.
|
|
134
|
+
const ok = el("span", "cp-ok", "\u2713");
|
|
135
|
+
ok.title = t("computer.granted");
|
|
136
|
+
ok.setAttribute("aria-label", t("computer.granted"));
|
|
137
|
+
row.appendChild(ok);
|
|
138
|
+
} else {
|
|
139
|
+
const allow = el("button", "cp-btn", t("computer.allow"));
|
|
140
|
+
allow.type = "button";
|
|
141
|
+
allow.addEventListener("click", () => void api("/computer-use/permissions", { request: true, grant }));
|
|
142
|
+
row.appendChild(allow);
|
|
143
|
+
}
|
|
144
|
+
card.appendChild(row);
|
|
145
|
+
}
|
|
146
|
+
const foot = el("div", "cp-foot");
|
|
147
|
+
const again = el("button", "cp-link", t("computer.recheck"));
|
|
148
|
+
again.type = "button";
|
|
149
|
+
again.addEventListener("click", () => void api("/computer-use/permissions", {}));
|
|
150
|
+
foot.appendChild(again);
|
|
151
|
+
if (computer.relaunch) {
|
|
152
|
+
const relaunch = el("button", "cp-link", t("computer.relaunch"));
|
|
153
|
+
relaunch.type = "button";
|
|
154
|
+
relaunch.title = t("computer.relaunchHint");
|
|
155
|
+
relaunch.addEventListener("click", () => void api("/computer-use/relaunch", {}));
|
|
156
|
+
foot.appendChild(relaunch);
|
|
157
|
+
}
|
|
158
|
+
card.appendChild(foot);
|
|
159
|
+
return card;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The tab's permission mode, as its state last said. */
|
|
163
|
+
function currentMode() {
|
|
164
|
+
const here = panes.get(activeId);
|
|
165
|
+
return (here && here.mode) || "default";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Into plan mode, and back to whatever it was before — not to Manual, which
|
|
170
|
+
* would quietly undo an Accept edits or Auto somebody had chosen.
|
|
171
|
+
*/
|
|
172
|
+
function togglePlanMode() {
|
|
173
|
+
const here = panes.get(activeId);
|
|
174
|
+
if (!here) return;
|
|
175
|
+
const leaving = currentMode() === "plan";
|
|
176
|
+
const next = leaving ? here.beforePlan || "default" : "plan";
|
|
177
|
+
if (!leaving) here.beforePlan = currentMode();
|
|
178
|
+
track("plan_mode", { on: !leaving });
|
|
179
|
+
void api("/mode", { session: activeId, mode: next }).then(applyState);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Send text as the session's goal: the first message, and the finish line. */
|
|
183
|
+
function sendGoal(text) {
|
|
184
|
+
if (!text) return void addNotice(t("goal.needsText"), "notice err");
|
|
185
|
+
track("goal_set", {});
|
|
186
|
+
void api("/prompt", { session: activeId, prompt: text, attachments: [], references: [], goal: true }).then(pushNotice);
|
|
187
|
+
}
|
|
33
188
|
const refKey = (ref) => ref.kind + ":" + ref.value;
|
|
34
189
|
|
|
35
190
|
/**
|
|
@@ -46,6 +201,22 @@ function renderQueued() {
|
|
|
46
201
|
// tab is the one streaming.
|
|
47
202
|
const here = panes.get(activeId);
|
|
48
203
|
const waiting = (here && here.queued) || [];
|
|
204
|
+
// The goal being worked toward sits over the queue: it is the reason the
|
|
205
|
+
// turn below it keeps going, and the × is the way to let it stop.
|
|
206
|
+
if (here && here.goal) {
|
|
207
|
+
const row = el("div", "q goal");
|
|
208
|
+
row.appendChild(el("span", "tag", t("goal.tag")));
|
|
209
|
+
row.appendChild(el("span", "n", here.goal));
|
|
210
|
+
const stop = el("button", "x", "×");
|
|
211
|
+
stop.type = "button";
|
|
212
|
+
stop.title = t("goal.stop");
|
|
213
|
+
stop.addEventListener("click", () => void api("/goal", { session: activeId }).then(applyState));
|
|
214
|
+
row.appendChild(stop);
|
|
215
|
+
queuedEl.appendChild(row);
|
|
216
|
+
}
|
|
217
|
+
// Background tasks next: work this conversation set going, still part of
|
|
218
|
+
// what is happening around the message being written.
|
|
219
|
+
appendTaskRows(queuedEl, here);
|
|
49
220
|
for (const entry of waiting) {
|
|
50
221
|
const row = el("div", "q" + (entry.soon ? " soon" : ""));
|
|
51
222
|
// Two different promises: one joins the turn that is running, the other
|
|
@@ -81,47 +252,218 @@ function renderQueued() {
|
|
|
81
252
|
function renderAttachments() {
|
|
82
253
|
attachEl.replaceChildren();
|
|
83
254
|
|
|
84
|
-
|
|
85
|
-
|
|
255
|
+
if (goalMode) {
|
|
256
|
+
const chip = el("div", "att goal");
|
|
257
|
+
chip.appendChild(svgIcon("goal"));
|
|
258
|
+
chip.appendChild(el("span", "n", t("goal.chip")));
|
|
259
|
+
const drop = el("button", "x", "×");
|
|
260
|
+
drop.type = "button";
|
|
261
|
+
drop.title = t("add.remove");
|
|
262
|
+
drop.addEventListener("click", () => setGoalMode(false));
|
|
263
|
+
chip.appendChild(drop);
|
|
264
|
+
attachEl.appendChild(chip);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (worktreeMode) {
|
|
268
|
+
const chip = el("div", "att goal");
|
|
269
|
+
chip.appendChild(svgIcon("worktree"));
|
|
270
|
+
chip.appendChild(el("span", "n", t("worktree.chip")));
|
|
271
|
+
const drop = el("button", "x", "×");
|
|
272
|
+
drop.type = "button";
|
|
273
|
+
drop.title = t("add.remove");
|
|
274
|
+
drop.addEventListener("click", () => setWorktreeMode(false));
|
|
275
|
+
chip.appendChild(drop);
|
|
276
|
+
attachEl.appendChild(chip);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Computer use is a state of the conversation, so its chip stays until it is
|
|
280
|
+
// switched off, and says what is missing when the OS has not allowed it yet.
|
|
281
|
+
const computer = computerHere();
|
|
282
|
+
let missing = false;
|
|
283
|
+
if (computer && computer.on) {
|
|
284
|
+
const chip = el("div", "att computer");
|
|
285
|
+
chip.appendChild(svgIcon("computer"));
|
|
286
|
+
const label = el("span", "n", t("computer.chip"));
|
|
287
|
+
// The chip is also the way back to the live card once it was folded.
|
|
288
|
+
label.classList.add("clickable");
|
|
289
|
+
label.title = t("computer.showPane");
|
|
290
|
+
label.addEventListener("click", computerShow);
|
|
291
|
+
chip.appendChild(label);
|
|
292
|
+
const drop = el("button", "x", "×");
|
|
293
|
+
drop.type = "button";
|
|
294
|
+
drop.title = t("computer.off");
|
|
295
|
+
drop.addEventListener("click", () => setComputerUse(false));
|
|
296
|
+
chip.appendChild(drop);
|
|
297
|
+
attachEl.appendChild(chip);
|
|
298
|
+
const perms = computer.permissions;
|
|
299
|
+
missing = Boolean(computer.platform === "darwin" && perms && (!perms.screen || !perms.accessibility));
|
|
300
|
+
if (missing) attachEl.appendChild(computerPermissionCard(computer));
|
|
301
|
+
else if (computer.error) attachEl.appendChild(el("div", "att computer-perm cp-body", t("computer.unavailable", { error: computer.error })));
|
|
302
|
+
}
|
|
303
|
+
watchComputerGrants(missing);
|
|
304
|
+
|
|
305
|
+
// Capabilities named for this message — a connector, a plugin, a skill —
|
|
306
|
+
// stay in the box beside the goal and Computer use: they are how it is
|
|
307
|
+
// sent. What it carries goes in the tray above.
|
|
86
308
|
for (const ref of refs) {
|
|
309
|
+
if (ref.kind === "file" || ref.kind === "folder") continue;
|
|
87
310
|
const chip = el("div", "att");
|
|
88
311
|
chip.appendChild(svgIcon(REF_ICON[ref.kind]));
|
|
89
312
|
chip.appendChild(el("span", "n", ref.label));
|
|
90
313
|
if (ref.sub) chip.appendChild(el("span", "where", ref.sub));
|
|
91
|
-
|
|
92
|
-
drop.type = "button";
|
|
93
|
-
drop.title = t("add.remove");
|
|
94
|
-
drop.addEventListener("click", () => {
|
|
314
|
+
chip.appendChild(dropButton(() => {
|
|
95
315
|
refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
|
|
96
316
|
renderAttachments();
|
|
97
|
-
});
|
|
98
|
-
chip.appendChild(drop);
|
|
317
|
+
}));
|
|
99
318
|
attachEl.appendChild(chip);
|
|
100
319
|
}
|
|
320
|
+
renderTray();
|
|
321
|
+
}
|
|
101
322
|
|
|
323
|
+
/**
|
|
324
|
+
* What the message carries, above the box rather than in it.
|
|
325
|
+
*
|
|
326
|
+
* Inside, a screenshot was a 26px square beside its file name, and a row of
|
|
327
|
+
* them pushed the text being written down the box. Out here each is a card
|
|
328
|
+
* of its own: an image or a video as a picture of itself, a file or a folder
|
|
329
|
+
* as its name and where it is. Files and folders picked from the disk first —
|
|
330
|
+
* picked on purpose — then what was pasted or dropped.
|
|
331
|
+
*/
|
|
332
|
+
function renderTray() {
|
|
333
|
+
trayEl.replaceChildren();
|
|
334
|
+
for (const ref of refs) {
|
|
335
|
+
if (ref.kind !== "file" && ref.kind !== "folder") continue;
|
|
336
|
+
const media = ref.kind === "file" ? mediaKindOf(ref.value) : "";
|
|
337
|
+
const cwd = P && P.cwd;
|
|
338
|
+
// Inside the project the file browser serves it; outside, only a file
|
|
339
|
+
// picked in the dialog can be shown, from the server's list of those.
|
|
340
|
+
const outside = /^(\/|[A-Za-z]:[\\/])/.test(ref.value);
|
|
341
|
+
const src = !media ? "" : outside
|
|
342
|
+
? q("/pick/preview?path=" + encodeURIComponent(ref.value))
|
|
343
|
+
: cwd ? q("/files/raw?cwd=" + encodeURIComponent(cwd) + "&path=" + encodeURIComponent(ref.value)) : "";
|
|
344
|
+
trayEl.appendChild(trayCard({
|
|
345
|
+
media: src ? media : "",
|
|
346
|
+
src,
|
|
347
|
+
icon: REF_ICON[ref.kind],
|
|
348
|
+
name: ref.label,
|
|
349
|
+
sub: ref.sub || "",
|
|
350
|
+
remove: () => {
|
|
351
|
+
refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
|
|
352
|
+
renderAttachments();
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
102
356
|
for (const file of staged) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
357
|
+
const media = file.preview ? file.previewKind || "image" : "";
|
|
358
|
+
trayEl.appendChild(trayCard({
|
|
359
|
+
media,
|
|
360
|
+
src: file.preview || "",
|
|
361
|
+
icon: "file",
|
|
362
|
+
name: file.name,
|
|
363
|
+
sub: formatBytes(file.bytes),
|
|
364
|
+
remove: () => {
|
|
365
|
+
staged = staged.filter((entry) => entry.id !== file.id);
|
|
366
|
+
// The server is holding these bytes for a prompt that is no longer
|
|
367
|
+
// going to name them, and it has no other way to learn that.
|
|
368
|
+
void api("/upload/drop", { id: file.id });
|
|
369
|
+
if (file.preview) URL.revokeObjectURL(file.preview);
|
|
370
|
+
renderAttachments();
|
|
371
|
+
},
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** An image or a video can be shown as itself; anything else is a name. */
|
|
377
|
+
function mediaKindOf(name) {
|
|
378
|
+
const ext = (String(name).split(".").pop() || "").toLowerCase();
|
|
379
|
+
if (["png", "jpg", "jpeg", "gif", "webp", "avif"].includes(ext)) return "image";
|
|
380
|
+
if (["mp4", "webm", "mov", "m4v"].includes(ext)) return "video";
|
|
381
|
+
return "";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function formatBytes(bytes) {
|
|
385
|
+
if (!bytes && bytes !== 0) return "";
|
|
386
|
+
if (bytes < 1024 * 1024) return Math.max(1, Math.round(bytes / 1024)) + " KB";
|
|
387
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function dropButton(onDrop) {
|
|
391
|
+
const drop = el("button", "x", "×");
|
|
392
|
+
drop.type = "button";
|
|
393
|
+
drop.title = t("add.remove");
|
|
394
|
+
drop.setAttribute("aria-label", t("add.remove"));
|
|
395
|
+
drop.addEventListener("click", (e) => {
|
|
396
|
+
e.stopPropagation();
|
|
397
|
+
onDrop();
|
|
398
|
+
});
|
|
399
|
+
return drop;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* One card in the tray. A picture is the card — click it for the full size,
|
|
404
|
+
* the same preview the conversation uses — and a video plays muted on hover,
|
|
405
|
+
* so which clip it is can be told without opening it. A file is its icon, its
|
|
406
|
+
* name and a line under it.
|
|
407
|
+
*/
|
|
408
|
+
function trayCard({ media, src, icon, name, sub, remove }) {
|
|
409
|
+
const card = el("div", "tray-card" + (media === "image" ? " tray-media image" : media === "video" ? " video" : ""));
|
|
410
|
+
card.title = name;
|
|
411
|
+
if (media === "image") {
|
|
412
|
+
const img = document.createElement("img");
|
|
413
|
+
img.src = src;
|
|
414
|
+
img.alt = name;
|
|
415
|
+
img.addEventListener("click", () => previewImage(img));
|
|
416
|
+
// A preview that will not load is a file card, not a broken picture.
|
|
417
|
+
img.addEventListener("error", () => card.replaceWith(trayCard({ media: "", src: "", icon, name, sub, remove })));
|
|
418
|
+
card.appendChild(img);
|
|
419
|
+
} else if (media === "video") {
|
|
420
|
+
// A clip keeps its name and size beside it: one frame of a video says
|
|
421
|
+
// less about which file it is than a photo does.
|
|
422
|
+
const thumb = el("span", "tray-thumb");
|
|
423
|
+
const video = document.createElement("video");
|
|
424
|
+
video.src = src;
|
|
425
|
+
video.muted = true;
|
|
426
|
+
video.preload = "metadata";
|
|
427
|
+
video.playsInline = true;
|
|
428
|
+
video.loop = true;
|
|
429
|
+
// The first frame rather than black, which is what a clip shows before it plays.
|
|
430
|
+
video.addEventListener("loadedmetadata", () => {
|
|
431
|
+
try {
|
|
432
|
+
video.currentTime = Math.min(0.1, video.duration || 0);
|
|
433
|
+
} catch {
|
|
434
|
+
// Not seekable: its poster frame is whatever it shows.
|
|
435
|
+
}
|
|
436
|
+
if (Number.isFinite(video.duration) && video.duration > 0) {
|
|
437
|
+
const s = Math.round(video.duration);
|
|
438
|
+
duration.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
439
|
+
}
|
|
121
440
|
});
|
|
122
|
-
|
|
123
|
-
|
|
441
|
+
card.addEventListener("mouseenter", () => void video.play().catch(() => {}));
|
|
442
|
+
card.addEventListener("mouseleave", () => video.pause());
|
|
443
|
+
video.addEventListener("error", () => card.replaceWith(trayCard({ media: "", src: "", icon, name, sub, remove })));
|
|
444
|
+
thumb.appendChild(video);
|
|
445
|
+
const play = el("span", "tray-play");
|
|
446
|
+
play.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.8v8.4L12 8z" fill="currentColor"/></svg>';
|
|
447
|
+
thumb.appendChild(play);
|
|
448
|
+
card.appendChild(thumb);
|
|
449
|
+
const text = el("span", "tray-text");
|
|
450
|
+
text.appendChild(el("span", "tray-name", name));
|
|
451
|
+
const line = el("span", "tray-sub");
|
|
452
|
+
const duration = el("span", "tray-duration");
|
|
453
|
+
line.append(duration, document.createTextNode(sub ? (sub) : ""));
|
|
454
|
+
text.appendChild(line);
|
|
455
|
+
card.appendChild(text);
|
|
456
|
+
} else {
|
|
457
|
+
const mark = el("span", "tray-icon");
|
|
458
|
+
mark.appendChild(svgIcon(icon));
|
|
459
|
+
card.appendChild(mark);
|
|
460
|
+
const text = el("span", "tray-text");
|
|
461
|
+
text.appendChild(el("span", "tray-name", name));
|
|
462
|
+
if (sub) text.appendChild(el("span", "tray-sub", sub));
|
|
463
|
+
card.appendChild(text);
|
|
124
464
|
}
|
|
465
|
+
card.appendChild(dropButton(remove));
|
|
466
|
+
return card;
|
|
125
467
|
}
|
|
126
468
|
|
|
127
469
|
async function upload(file) {
|
|
@@ -135,6 +477,10 @@ async function upload(file) {
|
|
|
135
477
|
|
|
136
478
|
// A local preview rather than a round trip: the bytes are already here.
|
|
137
479
|
if (body.kind === "image") body.preview = URL.createObjectURL(file);
|
|
480
|
+
else if ((file.type || "").startsWith("video/")) {
|
|
481
|
+
body.preview = URL.createObjectURL(file);
|
|
482
|
+
body.previewKind = "video";
|
|
483
|
+
}
|
|
138
484
|
staged.push(body);
|
|
139
485
|
renderAttachments();
|
|
140
486
|
}
|
|
@@ -414,16 +760,35 @@ function attachRows() {
|
|
|
414
760
|
const plugins = (offer.plugins || []).length;
|
|
415
761
|
const skills = (offer.skills || []).length;
|
|
416
762
|
return [
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
763
|
+
// What goes with the message: from this machine, first. Two rows for
|
|
764
|
+
// files and folders, because the dialogs behind them are two: only macOS
|
|
765
|
+
// has a panel that takes a file and a folder in one pass, and everywhere
|
|
766
|
+
// else a single row promising both could open only one of them. Saying
|
|
767
|
+
// which one opens before it opens is also how someone reaching for a
|
|
768
|
+
// screenshot stops reading at the first row.
|
|
769
|
+
{ type: "group", label: t("add.sectionAttach") },
|
|
422
770
|
{ type: "pick", kind: "files", icon: "file", label: t("add.attachFiles") },
|
|
423
771
|
{ type: "pick", kind: "folder", icon: "folder", label: t("add.attachFolders") },
|
|
424
|
-
|
|
425
|
-
//
|
|
426
|
-
//
|
|
772
|
+
{ type: "group", label: t("add.sectionMode") },
|
|
773
|
+
// The permission mode of the same name, from the place people look for a
|
|
774
|
+
// way of working. The picker by the send button still holds all five.
|
|
775
|
+
{ type: "plan", icon: "modePlan", label: t("add.planMode"), on: currentMode() === "plan" },
|
|
776
|
+
// How the agent works on it: a way of sending, and a capability of the
|
|
777
|
+
// conversation, rather than things sent.
|
|
778
|
+
{ type: "goal", icon: "goal", label: t("add.goal"), on: goalMode },
|
|
779
|
+
// Where it happens: a tree of its own beside this one, for work that
|
|
780
|
+
// should not touch what is being edited here.
|
|
781
|
+
{ type: "worktree", icon: "worktree", label: t("add.worktree"), on: worktreeMode },
|
|
782
|
+
// Only where there is a computer to use: the desktop app, whose server
|
|
783
|
+
// reports the feature in the conversation's state.
|
|
784
|
+
...(IS_DESKTOP && computerHere()
|
|
785
|
+
? [{ type: "computer", icon: "computer", label: t("add.computerUse"), on: Boolean(computerHere().on) }]
|
|
786
|
+
: []),
|
|
787
|
+
{ type: "group", label: t("add.sectionReach") },
|
|
788
|
+
// What it can reach, each a list of its own. A count when there is one,
|
|
789
|
+
// and nothing when there is not: "none" is a word in the slot that
|
|
790
|
+
// otherwise holds a number, and it reads as a value rather than as an
|
|
791
|
+
// absence.
|
|
427
792
|
{
|
|
428
793
|
type: "into",
|
|
429
794
|
kind: "connectors",
|
|
@@ -448,21 +813,29 @@ function attachRows() {
|
|
|
448
813
|
sub: skills ? String(skills) : "",
|
|
449
814
|
into: true,
|
|
450
815
|
},
|
|
451
|
-
{ type: "upload", icon: "upload", label: t("add.upload") },
|
|
452
816
|
];
|
|
453
817
|
}
|
|
454
818
|
|
|
819
|
+
/** Headings and dividers: in the list, but not something to pick. */
|
|
820
|
+
function notChoice(row) {
|
|
821
|
+
return row.type === "group" || row.type === "sep";
|
|
822
|
+
}
|
|
823
|
+
|
|
455
824
|
function paintAttachMenu() {
|
|
456
825
|
const rows = attachRows();
|
|
457
826
|
if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
|
|
458
827
|
// The list opens with the cursor at 0, and 0 is a heading whenever the list
|
|
459
828
|
// is grouped. Nudged here rather than at every call site that sets it.
|
|
460
|
-
while (rows[attachCursor] && rows[attachCursor]
|
|
829
|
+
while (rows[attachCursor] && notChoice(rows[attachCursor]) && attachCursor < rows.length - 1) {
|
|
461
830
|
attachCursor++;
|
|
462
831
|
}
|
|
463
832
|
attachBody.replaceChildren();
|
|
464
833
|
|
|
465
834
|
rows.forEach((row, i) => {
|
|
835
|
+
if (row.type === "sep") {
|
|
836
|
+
attachBody.appendChild(el("div", "menu-sep"));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
466
839
|
if (row.type === "group") {
|
|
467
840
|
attachBody.appendChild(el("div", "menu-head", row.label));
|
|
468
841
|
return;
|
|
@@ -473,7 +846,7 @@ function paintAttachMenu() {
|
|
|
473
846
|
}
|
|
474
847
|
const item = el(
|
|
475
848
|
"div",
|
|
476
|
-
"menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
|
|
849
|
+
"menu-item" + ((row.ref && referenced(row.ref)) || row.on ? " on" : "") + (i === attachCursor ? " cursor" : ""),
|
|
477
850
|
);
|
|
478
851
|
item.setAttribute("role", "button");
|
|
479
852
|
item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
|
|
@@ -491,7 +864,7 @@ function paintAttachMenu() {
|
|
|
491
864
|
// is chosen still held a column open in front of every row that was not,
|
|
492
865
|
// which put the icons a third of the way into the menu and the words
|
|
493
866
|
// further still. On the right it costs nothing until there is one.
|
|
494
|
-
if (row.ref && referenced(row.ref)) item.appendChild(el("span", "tick", "✓"));
|
|
867
|
+
if ((row.ref && referenced(row.ref)) || row.on) item.appendChild(el("span", "tick", "✓"));
|
|
495
868
|
if (row.into) item.appendChild(el("span", "chev"));
|
|
496
869
|
// An installed connector is removed from the row that says it is
|
|
497
870
|
// installed, which is the row someone looking to remove it goes to.
|
|
@@ -510,7 +883,7 @@ function paintAttachMenu() {
|
|
|
510
883
|
});
|
|
511
884
|
|
|
512
885
|
// One line saying why a screen is empty, rather than an empty screen.
|
|
513
|
-
const listed = rows.filter((row) => row.type !== "up").length;
|
|
886
|
+
const listed = rows.filter((row) => row.type !== "up" && row.type !== "sep").length;
|
|
514
887
|
if (attachView === "connectors" && !listed) {
|
|
515
888
|
attachBody.appendChild(el("div", "menu-hint", t("add.noConnectors")));
|
|
516
889
|
} else if (attachView === "plugins" && !listed) {
|
|
@@ -524,7 +897,7 @@ function paintAttachMenu() {
|
|
|
524
897
|
}
|
|
525
898
|
|
|
526
899
|
function chooseAttachRow(row) {
|
|
527
|
-
if (row.type === "hint") return;
|
|
900
|
+
if (row.type === "hint" || notChoice(row)) return;
|
|
528
901
|
if (row.type === "up") return void showAttachView("root");
|
|
529
902
|
if (row.type === "into") return void showAttachView(row.kind);
|
|
530
903
|
if (row.type === "offer-ref") {
|
|
@@ -549,11 +922,27 @@ function chooseAttachRow(row) {
|
|
|
549
922
|
closeAttachMenu();
|
|
550
923
|
return void openMarket(row.kind);
|
|
551
924
|
}
|
|
552
|
-
if (row.type === "
|
|
925
|
+
if (row.type === "command") return void pickCommand(row.command);
|
|
926
|
+
if (row.type === "goal") {
|
|
553
927
|
closeAttachMenu();
|
|
554
|
-
|
|
928
|
+
setGoalMode(!goalMode);
|
|
929
|
+
return void input.focus();
|
|
930
|
+
}
|
|
931
|
+
if (row.type === "worktree") {
|
|
932
|
+
closeAttachMenu();
|
|
933
|
+
setWorktreeMode(!worktreeMode);
|
|
934
|
+
return void input.focus();
|
|
935
|
+
}
|
|
936
|
+
if (row.type === "plan") {
|
|
937
|
+
closeAttachMenu();
|
|
938
|
+
togglePlanMode();
|
|
939
|
+
return void input.focus();
|
|
940
|
+
}
|
|
941
|
+
if (row.type === "computer") {
|
|
942
|
+
closeAttachMenu();
|
|
943
|
+
setComputerUse(!row.on);
|
|
944
|
+
return void input.focus();
|
|
555
945
|
}
|
|
556
|
-
if (row.type === "command") return void insertCommand(row.command);
|
|
557
946
|
if (attachView === "mention" && row.ref) {
|
|
558
947
|
return void insertMention(row.ref.value);
|
|
559
948
|
}
|
|
@@ -579,7 +968,14 @@ async function pickFromDisk(kind) {
|
|
|
579
968
|
const picked = await api("/pick", kind === "folder" ? {} : { kind: "files" }, 5 * 60_000);
|
|
580
969
|
// A dismissed dialog is an answer, and the only one that says nothing.
|
|
581
970
|
if (picked.cancelled) return;
|
|
582
|
-
|
|
971
|
+
// No dialog on this machine (a browser on another device, a desktop with no
|
|
972
|
+
// picker): the browser's own file chooser, which carries the bytes. It was a
|
|
973
|
+
// row of its own, "Upload from computer", beside this one — two rows for
|
|
974
|
+
// one thing, told apart only by what happened afterwards.
|
|
975
|
+
if (picked.unavailable || picked.error) {
|
|
976
|
+
if (kind === "folder") return void addNotice(t("add.noDialog"), "err");
|
|
977
|
+
return void fileInput.click();
|
|
978
|
+
}
|
|
583
979
|
|
|
584
980
|
if (kind === "folder") {
|
|
585
981
|
// Relative when it is inside the project, which is shorter to read and is
|
|
@@ -618,11 +1014,12 @@ async function pickFromDisk(kind) {
|
|
|
618
1014
|
* something the server had already forgotten.
|
|
619
1015
|
*/
|
|
620
1016
|
async function withPreview(file) {
|
|
621
|
-
|
|
1017
|
+
const video = String(file.mediaType || "").startsWith("video/");
|
|
1018
|
+
if (file.kind !== "image" && !video) return file;
|
|
622
1019
|
try {
|
|
623
1020
|
const response = await fetch(q("/upload/preview?id=" + encodeURIComponent(file.id)));
|
|
624
1021
|
if (!response.ok) return file;
|
|
625
|
-
return { ...file, preview: URL.createObjectURL(await response.blob()) };
|
|
1022
|
+
return { ...file, preview: URL.createObjectURL(await response.blob()), ...(video ? { previewKind: "video" } : {}) };
|
|
626
1023
|
} catch {
|
|
627
1024
|
// A chip without a thumbnail is still a chip; nothing here is worth a
|
|
628
1025
|
// notice the person cannot act on.
|
|
@@ -1057,7 +1454,7 @@ function moveAttachCursor(e) {
|
|
|
1057
1454
|
let next = attachCursor;
|
|
1058
1455
|
for (let i = 0; i < rows.length; i++) {
|
|
1059
1456
|
next = (next + step + rows.length) % rows.length;
|
|
1060
|
-
if (rows[next]
|
|
1457
|
+
if (!notChoice(rows[next])) break;
|
|
1061
1458
|
}
|
|
1062
1459
|
attachCursor = next;
|
|
1063
1460
|
paintAttachMenu();
|
|
@@ -1137,6 +1534,14 @@ input.addEventListener("keydown", (e) => {
|
|
|
1137
1534
|
* what a reader is checking when they glance at it.
|
|
1138
1535
|
*/
|
|
1139
1536
|
function paintMirror() {
|
|
1537
|
+
paintMarks();
|
|
1538
|
+
// Every caller has just rewritten the text, and a rewrite from code — the
|
|
1539
|
+
// box emptied on send, a command inserted — fires no input event, so the
|
|
1540
|
+
// drawn caret would stay where the old text put it.
|
|
1541
|
+
placeCaret();
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function paintMarks() {
|
|
1140
1545
|
if (!inputMirror) return;
|
|
1141
1546
|
// Typing an @ asks for the list, but text can also arrive whole — pasted, or
|
|
1142
1547
|
// restored as a draft — and that text has mentions in it too. Guarded, so
|
|
@@ -1310,6 +1715,24 @@ const COMMANDS = [
|
|
|
1310
1715
|
return void api("/routine", { name: arg }).then(pushNotice);
|
|
1311
1716
|
},
|
|
1312
1717
|
},
|
|
1718
|
+
{
|
|
1719
|
+
name: "goal",
|
|
1720
|
+
arg: "goal",
|
|
1721
|
+
hint: () => t("cmd.goal"),
|
|
1722
|
+
run: (arg) => {
|
|
1723
|
+
// Bare, it switches the composer into goal mode for the next message;
|
|
1724
|
+
// "clear" lets go of the one being worked toward.
|
|
1725
|
+
if (!arg) return void setGoalMode(true);
|
|
1726
|
+
if (/^(clear|off|stop)$/i.test(arg)) return void api("/goal", { session: activeId }).then(applyState);
|
|
1727
|
+
sendGoal(arg);
|
|
1728
|
+
},
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
name: "compact",
|
|
1732
|
+
arg: "",
|
|
1733
|
+
hint: () => t("cmd.compact"),
|
|
1734
|
+
run: () => compactConversation(),
|
|
1735
|
+
},
|
|
1313
1736
|
{
|
|
1314
1737
|
name: "new",
|
|
1315
1738
|
arg: "",
|
|
@@ -1318,6 +1741,18 @@ const COMMANDS = [
|
|
|
1318
1741
|
},
|
|
1319
1742
|
];
|
|
1320
1743
|
|
|
1744
|
+
/** /compact, and the context panel's button: summarize the conversation now. */
|
|
1745
|
+
function compactConversation() {
|
|
1746
|
+
addNotice(t("cmd.compacting"), "notice");
|
|
1747
|
+
// A summary is a model call over the whole conversation, well past the
|
|
1748
|
+
// half-minute a settings toggle is given.
|
|
1749
|
+
api("/compact", { session: activeId }, 180000).then((body) => {
|
|
1750
|
+
if (body && body.error) return addNotice(body.error, "notice err");
|
|
1751
|
+
// A compaction posts its own notice; only the nothing-to-do case needs one.
|
|
1752
|
+
if (body && body.compacted === false) addNotice(t("cmd.nothingToCompact"), "notice");
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1321
1756
|
function pushNotice(body) {
|
|
1322
1757
|
if (body && body.error) addNotice(body.error, "notice err");
|
|
1323
1758
|
}
|
|
@@ -1346,6 +1781,23 @@ function openCommands(command) {
|
|
|
1346
1781
|
paintAttachMenu();
|
|
1347
1782
|
}
|
|
1348
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* A command chosen from the menu.
|
|
1786
|
+
*
|
|
1787
|
+
* One that takes nothing runs at once: inserting "/new " only to need a second
|
|
1788
|
+
* Enter is a step that decides nothing. One that takes an argument is written
|
|
1789
|
+
* into the box, ready for it.
|
|
1790
|
+
*/
|
|
1791
|
+
function pickCommand(name) {
|
|
1792
|
+
const command = COMMANDS.find((one) => one.name === name);
|
|
1793
|
+
if (!command || command.arg) return void insertCommand(name);
|
|
1794
|
+
input.value = "";
|
|
1795
|
+
autosize();
|
|
1796
|
+
paintMirror();
|
|
1797
|
+
closeAttachMenu();
|
|
1798
|
+
command.run("");
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1349
1801
|
/** Insert the command, with the space its argument starts after. */
|
|
1350
1802
|
function insertCommand(name) {
|
|
1351
1803
|
const rest = input.value.slice(input.selectionStart);
|
|
@@ -1463,7 +1915,12 @@ input.addEventListener("input", () => {
|
|
|
1463
1915
|
// Before the composer's own Enter handling, which would otherwise send the
|
|
1464
1916
|
// message out from under a menu the user was choosing from.
|
|
1465
1917
|
input.addEventListener("keydown", (e) => {
|
|
1466
|
-
if (attachView !== "mention" || attachMenu.hidden) return;
|
|
1918
|
+
if ((attachView !== "mention" && attachView !== "command") || attachMenu.hidden) return;
|
|
1919
|
+
// A slash word no command starts with is prose — /etc, and/or — so with
|
|
1920
|
+
// nothing to pick, Enter goes on to send it rather than choosing nothing.
|
|
1921
|
+
if (attachView === "command" && (e.key === "Enter" || e.key === "Tab") && !attachRows().length) {
|
|
1922
|
+
return void closeAttachMenu();
|
|
1923
|
+
}
|
|
1467
1924
|
if (e.key === "Escape") {
|
|
1468
1925
|
e.stopImmediatePropagation();
|
|
1469
1926
|
return void closeAttachMenu();
|
|
@@ -1524,23 +1981,43 @@ document.getElementById("composer").addEventListener("submit", (e) => {
|
|
|
1524
1981
|
const ids = staged.map((file) => file.id);
|
|
1525
1982
|
const shots = staged
|
|
1526
1983
|
.filter((file) => file.preview)
|
|
1527
|
-
.map((file) => ({ src: file.preview, name: file.name }));
|
|
1984
|
+
.map((file) => ({ src: file.preview, name: file.name, kind: file.previewKind || "image" }));
|
|
1528
1985
|
if (shots.length) sentPreviews.set(activeId, shots);
|
|
1529
1986
|
const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
|
|
1987
|
+
const asGoal = goalMode;
|
|
1988
|
+
const asWorktree = worktreeMode;
|
|
1989
|
+
if (asGoal && !text) return void addNotice(t("goal.needsText"), "notice err");
|
|
1990
|
+
if (asWorktree && !text) return void addNotice(t("worktree.placeholder"), "notice err");
|
|
1530
1991
|
input.value = "";
|
|
1531
1992
|
autosize();
|
|
1532
1993
|
paintMirror();
|
|
1533
1994
|
staged = [];
|
|
1534
1995
|
refs = [];
|
|
1996
|
+
if (asGoal) setGoalMode(false);
|
|
1997
|
+
if (asWorktree) setWorktreeMode(false);
|
|
1535
1998
|
renderAttachments();
|
|
1536
1999
|
// The counts, never the text, the file names or the paths.
|
|
1537
|
-
track("prompt_sent", { attachments: ids.length, references: referenced.length });
|
|
2000
|
+
track("prompt_sent", { attachments: ids.length, references: referenced.length, goal: asGoal, worktree: asWorktree });
|
|
2001
|
+
if (asWorktree) {
|
|
2002
|
+
// From the project the tab on screen is in, named after the first line.
|
|
2003
|
+
const here = panes.get(activeId);
|
|
2004
|
+
const task = text.split("\n")[0].trim().slice(0, 80);
|
|
2005
|
+
return void startInWorktree({
|
|
2006
|
+
...(here && here.cwd ? { cwd: here.cwd } : {}),
|
|
2007
|
+
task,
|
|
2008
|
+
prompt: text,
|
|
2009
|
+
attachments: ids,
|
|
2010
|
+
references: referenced,
|
|
2011
|
+
...(asGoal ? { goal: true } : {}),
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
1538
2014
|
void api("/prompt", {
|
|
1539
2015
|
session: activeId,
|
|
1540
2016
|
prompt: text,
|
|
1541
2017
|
attachments: ids,
|
|
1542
2018
|
references: referenced,
|
|
1543
|
-
|
|
2019
|
+
...(asGoal ? { goal: true } : {}),
|
|
2020
|
+
}).then(pushNotice);
|
|
1544
2021
|
});
|
|
1545
2022
|
|
|
1546
2023
|
input.addEventListener("keydown", (e) => {
|
|
@@ -1552,7 +2029,6 @@ input.addEventListener("keydown", (e) => {
|
|
|
1552
2029
|
input.addEventListener("input", () => {
|
|
1553
2030
|
autosize();
|
|
1554
2031
|
paintMirror();
|
|
1555
|
-
placeCaret();
|
|
1556
2032
|
});
|
|
1557
2033
|
|
|
1558
2034
|
// --- the field's height ---------------------------------------------------
|
|
@@ -1722,5 +2198,168 @@ if (window.visualViewport) {
|
|
|
1722
2198
|
window.addEventListener("resize", followKeyboard);
|
|
1723
2199
|
}
|
|
1724
2200
|
|
|
2201
|
+
// ── Dictation ────────────────────────────────────────────────────────────
|
|
2202
|
+
// The mic records in the browser (MediaRecorder), the server turns it into
|
|
2203
|
+
// text (POST /voice/transcribe: the account's gateway, or the user's own
|
|
2204
|
+
// OpenAI key), and the words go into the box at the end of what is there.
|
|
2205
|
+
// Then POST /voice/tidy cleans them up — on a paid plan, or with the user's
|
|
2206
|
+
// own small model — and swaps the clean version in, unless the box was
|
|
2207
|
+
// touched meanwhile. Five minutes at most.
|
|
2208
|
+
//
|
|
2209
|
+
// What is going on shows in a panel floating above the box by the mic:
|
|
2210
|
+
// listening (the voice as bars, the time, cancel and done), working
|
|
2211
|
+
// (transcribing, tidying), or what went wrong. Esc cancels, Enter finishes.
|
|
2212
|
+
const micBtn = document.getElementById("mic");
|
|
2213
|
+
const pop = document.getElementById("dictate-pop");
|
|
2214
|
+
const popText = pop.querySelector(".dp-text");
|
|
2215
|
+
const popTime = pop.querySelector(".dp-time");
|
|
2216
|
+
const popWave = pop.querySelector(".dp-wave");
|
|
2217
|
+
let dictation = null;
|
|
2218
|
+
let popTimer = 0;
|
|
2219
|
+
|
|
2220
|
+
function dictateShow(state, text) {
|
|
2221
|
+
clearTimeout(popTimer);
|
|
2222
|
+
if (!state) { pop.hidden = true; pop.removeAttribute("data-state"); return; }
|
|
2223
|
+
pop.dataset.state = state;
|
|
2224
|
+
popText.textContent = text || "";
|
|
2225
|
+
pop.hidden = false;
|
|
2226
|
+
if (state === "error") popTimer = setTimeout(() => dictateShow(""), 8000);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/** The voice, as bars: an analyser on the microphone, drawn every frame. */
|
|
2230
|
+
function drawWave(session) {
|
|
2231
|
+
const ctx = popWave.getContext("2d");
|
|
2232
|
+
const ratio = window.devicePixelRatio || 1;
|
|
2233
|
+
popWave.width = 168 * ratio; popWave.height = 32 * ratio;
|
|
2234
|
+
const bins = new Uint8Array(session.analyser.fftSize);
|
|
2235
|
+
const bars = 28;
|
|
2236
|
+
const history = new Array(bars).fill(0);
|
|
2237
|
+
const color = getComputedStyle(popWave).color;
|
|
2238
|
+
const frame = () => {
|
|
2239
|
+
if (dictation !== session) return;
|
|
2240
|
+
session.analyser.getByteTimeDomainData(bins);
|
|
2241
|
+
let peak = 0;
|
|
2242
|
+
for (const value of bins) peak = Math.max(peak, Math.abs(value - 128) / 128);
|
|
2243
|
+
history.shift(); history.push(Math.min(1, peak * 2.4));
|
|
2244
|
+
ctx.clearRect(0, 0, popWave.width, popWave.height);
|
|
2245
|
+
ctx.fillStyle = color;
|
|
2246
|
+
const step = popWave.width / bars, width = Math.max(2 * ratio, step * 0.45);
|
|
2247
|
+
history.forEach((level, index) => {
|
|
2248
|
+
const height = Math.max(3 * ratio, level * popWave.height);
|
|
2249
|
+
ctx.globalAlpha = 0.35 + 0.65 * (index / bars);
|
|
2250
|
+
ctx.fillRect(index * step + (step - width) / 2, (popWave.height - height) / 2, width, height);
|
|
2251
|
+
});
|
|
2252
|
+
session.frame = requestAnimationFrame(frame);
|
|
2253
|
+
};
|
|
2254
|
+
frame();
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function startDictation() {
|
|
2258
|
+
// The first time, macOS asks before the browser answers, and the mic would
|
|
2259
|
+
// sit there looking dead until someone clicks Allow: say so meanwhile.
|
|
2260
|
+
dictateShow("working", t("dictate.opening"));
|
|
2261
|
+
micBtn.disabled = true;
|
|
2262
|
+
let stream;
|
|
2263
|
+
try {
|
|
2264
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
2265
|
+
} catch {
|
|
2266
|
+
micBtn.disabled = false;
|
|
2267
|
+
return dictateShow("error", t("dictate.denied"));
|
|
2268
|
+
}
|
|
2269
|
+
micBtn.disabled = false;
|
|
2270
|
+
const mimeType = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
|
|
2271
|
+
.find((type) => MediaRecorder.isTypeSupported(type));
|
|
2272
|
+
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
|
2273
|
+
const chunks = [];
|
|
2274
|
+
recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); };
|
|
2275
|
+
const audio = new AudioContext();
|
|
2276
|
+
const analyser = audio.createAnalyser();
|
|
2277
|
+
analyser.fftSize = 512;
|
|
2278
|
+
audio.createMediaStreamSource(stream).connect(analyser);
|
|
2279
|
+
const session = { recorder, stream, chunks, audio, analyser, startedAt: Date.now(), timer: 0, frame: 0, cancelled: false };
|
|
2280
|
+
recorder.onstop = () => finishDictation(session);
|
|
2281
|
+
dictation = session;
|
|
2282
|
+
recorder.start(1000);
|
|
2283
|
+
micBtn.classList.add("is-recording");
|
|
2284
|
+
micBtn.setAttribute("aria-pressed", "true");
|
|
2285
|
+
micBtn.title = t("composer.dictateStop");
|
|
2286
|
+
dictateShow("listening", t("dictate.listening"));
|
|
2287
|
+
const tick = () => {
|
|
2288
|
+
const seconds = Math.floor((Date.now() - session.startedAt) / 1000);
|
|
2289
|
+
popTime.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
|
|
2290
|
+
if (seconds >= 300) stopDictation(false);
|
|
2291
|
+
};
|
|
2292
|
+
tick();
|
|
2293
|
+
session.timer = setInterval(tick, 500);
|
|
2294
|
+
drawWave(session);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/** Done (keep what was said) or cancelled (throw it away). */
|
|
2298
|
+
function stopDictation(cancel) {
|
|
2299
|
+
const session = dictation;
|
|
2300
|
+
if (!session) return;
|
|
2301
|
+
dictation = null;
|
|
2302
|
+
session.cancelled = cancel;
|
|
2303
|
+
clearInterval(session.timer);
|
|
2304
|
+
cancelAnimationFrame(session.frame);
|
|
2305
|
+
if (session.recorder.state !== "inactive") session.recorder.stop();
|
|
2306
|
+
session.stream.getTracks().forEach((track) => track.stop());
|
|
2307
|
+
session.audio.close().catch(() => {});
|
|
2308
|
+
micBtn.classList.remove("is-recording");
|
|
2309
|
+
micBtn.setAttribute("aria-pressed", "false");
|
|
2310
|
+
micBtn.title = t("composer.dictate");
|
|
2311
|
+
if (cancel) dictateShow("");
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
async function finishDictation(session) {
|
|
2315
|
+
if (session.cancelled) return;
|
|
2316
|
+
const duration = (Date.now() - session.startedAt) / 1000;
|
|
2317
|
+
const blob = new Blob(session.chunks, { type: session.recorder.mimeType || "audio/webm" });
|
|
2318
|
+
if (duration < 0.6 || !blob.size) return dictateShow("");
|
|
2319
|
+
const format = /mp4/.test(blob.type) ? "m4a" : /ogg/.test(blob.type) ? "ogg" : "webm";
|
|
2320
|
+
const audio = await new Promise((resolve) => {
|
|
2321
|
+
const reader = new FileReader();
|
|
2322
|
+
reader.onload = () => resolve(String(reader.result).replace(/^data:[^,]*,/, ""));
|
|
2323
|
+
reader.readAsDataURL(blob);
|
|
2324
|
+
});
|
|
2325
|
+
micBtn.disabled = true;
|
|
2326
|
+
dictateShow("working", t("dictate.transcribing"));
|
|
2327
|
+
const heard = await api("/voice/transcribe", { audio, format, duration, language: navigator.language || "" }, 90_000);
|
|
2328
|
+
micBtn.disabled = false;
|
|
2329
|
+
const words = heard && typeof heard.text === "string" ? heard.text.trim() : "";
|
|
2330
|
+
if (!words) {
|
|
2331
|
+
if (heard && heard.code === "sign_in_required") return dictateShow("error", t("dictate.signIn"));
|
|
2332
|
+
return heard && heard.error ? dictateShow("error", t("dictate.failed", { error: heard.error })) : dictateShow("");
|
|
2333
|
+
}
|
|
2334
|
+
const before = input.value;
|
|
2335
|
+
const lead = before && !/\s$/.test(before) ? " " : "";
|
|
2336
|
+
const placed = before + lead + words;
|
|
2337
|
+
input.value = placed;
|
|
2338
|
+
input.dispatchEvent(new Event("input"));
|
|
2339
|
+
input.focus();
|
|
2340
|
+
input.setSelectionRange(placed.length, placed.length);
|
|
2341
|
+
dictateShow("working", t("dictate.tidying"));
|
|
2342
|
+
const tidy = await api("/voice/tidy", { text: words, language: navigator.language || "" }, 20_000);
|
|
2343
|
+
dictateShow("");
|
|
2344
|
+
if (tidy && tidy.tidied === true && typeof tidy.text === "string" && input.value === placed) {
|
|
2345
|
+
input.value = before + lead + tidy.text;
|
|
2346
|
+
input.dispatchEvent(new Event("input"));
|
|
2347
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
if (micBtn) {
|
|
2352
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) micBtn.hidden = true;
|
|
2353
|
+
micBtn.addEventListener("click", () => (dictation ? stopDictation(false) : startDictation()));
|
|
2354
|
+
pop.querySelector(".dp-cancel").addEventListener("click", () => stopDictation(true));
|
|
2355
|
+
pop.querySelector(".dp-done").addEventListener("click", () => stopDictation(false));
|
|
2356
|
+
pop.querySelector(".dp-close").addEventListener("click", () => dictateShow(""));
|
|
2357
|
+
// While listening, the keyboard ends it: Esc throws it away, Enter keeps it.
|
|
2358
|
+
document.addEventListener("keydown", (event) => {
|
|
2359
|
+
if (!dictation) return;
|
|
2360
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); stopDictation(true); }
|
|
2361
|
+
else if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); event.stopPropagation(); stopDictation(false); }
|
|
2362
|
+
}, true);
|
|
2363
|
+
}
|
|
1725
2364
|
`;
|
|
1726
2365
|
//# sourceMappingURL=composer.js.map
|