zigmini 0.9.0-beta.3 → 1.0.0-beta.1
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.ko.md +24 -4
- package/README.md +24 -4
- package/assets/agent/gateway-rules.md +1 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
package/web/app.js
CHANGED
|
@@ -45,13 +45,92 @@ import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } f
|
|
|
45
45
|
import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
|
|
46
46
|
import { createAppDownload } from "./app-download.js";
|
|
47
47
|
import { setupNotificationSettings } from "./notifications.js";
|
|
48
|
-
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
|
|
48
|
+
import { cleanRequest, cleanResultLine, formatBytes, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
|
|
49
|
+
import {
|
|
50
|
+
applyAgentApplied, applyAgentUpdate, clearField, cloneRequest, DEFAULTS_PATH, DELETE_HINT, deleteEnabled, deleteRequest, editorRows, editorState,
|
|
51
|
+
EMPTY_FILES_LINE, EMPTY_AGENTS_LINE, EMPTY_TRASH_LINE, errorLine, fileRows, filesPath, headerView, mainConversations, shownConversation, AGENTS_PATH,
|
|
52
|
+
restoreRequest, saveRequest, setBody, setField, setUnknownRaw, sidebarItems, sourceUploadPath, STALE_TITLE, TRASH_PATH, trashRows, validateEditor, widgetValue,
|
|
53
|
+
} from "./agents.js";
|
|
49
54
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
50
|
-
import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityInbox } from "./security-inbox.js";
|
|
55
|
+
import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityBaseline, createSecurityInbox, repoAllowed, repoScopeBlock } from "./security-inbox.js";
|
|
56
|
+
import { getLanguage, isUiLanguage, onLanguageChange, registerStrings, setLanguage, t, UI_LANGUAGES } from "./i18n.js";
|
|
57
|
+
import mainStrings from "./i18n/main.js";
|
|
51
58
|
|
|
52
59
|
installTooltips(document, window);
|
|
60
|
+
registerStrings(mainStrings);
|
|
53
61
|
|
|
54
62
|
const $ = (id) => document.getElementById(id);
|
|
63
|
+
|
|
64
|
+
// ---- The UI language (English or Korean), one gateway-wide setting: GET/PUT /api/settings/language, ui-language.updated ----
|
|
65
|
+
const UI_LANGUAGE_PATH = "/api/settings/language";
|
|
66
|
+
const languageSelect = $("ui-language");
|
|
67
|
+
|
|
68
|
+
/** The BCP 47 locale of the UI language, for dates and times. */
|
|
69
|
+
function uiLocale(lang = getLanguage()) {
|
|
70
|
+
return lang === "ko" ? "ko-KR" : "en-US";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The select lists every language under its own name and shows the current one. */
|
|
74
|
+
function renderLanguageSelect() {
|
|
75
|
+
languageSelect.replaceChildren(...UI_LANGUAGES.map((lang) => new Option(t(`language.name.${lang}`), lang)));
|
|
76
|
+
languageSelect.value = getLanguage();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A GET or PUT answer or the ui-language.updated event: a known language other than the current one switches the page. */
|
|
80
|
+
function applyUiLanguage(body) {
|
|
81
|
+
if (isUiLanguage(body?.language) && body.language !== getLanguage()) setLanguage(body.language);
|
|
82
|
+
renderLanguageSelect();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The page waits for this before its first render (first = true), so every label is drawn once in the right language. */
|
|
86
|
+
async function readUiLanguage(first = false) {
|
|
87
|
+
let language = getLanguage();
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(UI_LANGUAGE_PATH, { cache: "no-store", signal: AbortSignal.timeout(5000) });
|
|
90
|
+
const body = res.ok ? await res.json() : null;
|
|
91
|
+
if (isUiLanguage(body?.language)) language = body.language;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn("language load:", error);
|
|
94
|
+
}
|
|
95
|
+
if (first || language !== getLanguage()) setLanguage(language);
|
|
96
|
+
renderLanguageSelect();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function saveUiLanguage(language) {
|
|
100
|
+
languageSelect.disabled = true;
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(UI_LANGUAGE_PATH, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ language }) });
|
|
103
|
+
const body = await res.json().catch(() => ({}));
|
|
104
|
+
if (!res.ok) throw new Error(body.error ?? String(res.status));
|
|
105
|
+
applyUiLanguage(body);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
renderLanguageSelect();
|
|
108
|
+
systemLine(t("settings.language.saveFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
109
|
+
} finally {
|
|
110
|
+
languageSelect.disabled = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
languageSelect.onchange = () => void saveUiLanguage(languageSelect.value);
|
|
115
|
+
await readUiLanguage(true);
|
|
116
|
+
|
|
117
|
+
/** A role=switch button: aria-checked carries the state, .on the look; a busy or unknown setting disables it. */
|
|
118
|
+
function renderSwitch(button, on, busy = false) {
|
|
119
|
+
button.setAttribute("aria-checked", String(on));
|
|
120
|
+
button.classList.toggle("on", on);
|
|
121
|
+
button.disabled = busy;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The info icon of a settings row built by app.js (the per-tool formal rows); its tip is the full description. */
|
|
125
|
+
function infoTipButton(text) {
|
|
126
|
+
const button = make("button", "info-tip", "i");
|
|
127
|
+
button.type = "button";
|
|
128
|
+
button.setAttribute("data-tip-keep", "");
|
|
129
|
+
button.setAttribute("data-tip", text);
|
|
130
|
+
button.setAttribute("aria-label", t("common.details"));
|
|
131
|
+
return button;
|
|
132
|
+
}
|
|
133
|
+
|
|
55
134
|
const state = {
|
|
56
135
|
conversations: [],
|
|
57
136
|
current: "main",
|
|
@@ -163,8 +242,8 @@ async function changeAgentIcon(method, body) {
|
|
|
163
242
|
const res = await fetch("/api/profile/icon", { method, body, headers: body ? { "content-type": body.type } : {} }).catch(() => null);
|
|
164
243
|
const data = await res?.json().catch(() => null);
|
|
165
244
|
if (!res?.ok) {
|
|
166
|
-
const reason = { icon_too_large: "
|
|
167
|
-
throw new Error(reason ?? "
|
|
245
|
+
const reason = { icon_too_large: "profile.icon.tooLarge", icon_type: "profile.icon.type", icon_empty: "profile.icon.empty" }[data?.error];
|
|
246
|
+
throw new Error(t(reason ?? "profile.icon.saveFailed"));
|
|
168
247
|
}
|
|
169
248
|
receiveProfile(data);
|
|
170
249
|
}
|
|
@@ -184,7 +263,7 @@ document.querySelector("#owner-profile").addEventListener("dblclick", () => {
|
|
|
184
263
|
editNickname(document.querySelector("#owner-profile .me-name"), nickname, (value) => {
|
|
185
264
|
nickname = writeNickname(localStorage, value);
|
|
186
265
|
queueMicrotask(applyNickname);
|
|
187
|
-
void saveProfile({ nickname }, "
|
|
266
|
+
void saveProfile({ nickname }, t("profile.nickname.saveFailed"));
|
|
188
267
|
return nickname;
|
|
189
268
|
});
|
|
190
269
|
});
|
|
@@ -201,12 +280,12 @@ document.querySelector("#agent-profile").addEventListener("dblclick", () => {
|
|
|
201
280
|
editNickname(document.querySelector("#agent-profile .agent-name"), agentName, (value) => {
|
|
202
281
|
agentName = writeAgentName(localStorage, value);
|
|
203
282
|
queueMicrotask(applyAgentName);
|
|
204
|
-
void saveProfile({ agentName }, "
|
|
283
|
+
void saveProfile({ agentName }, t("profile.agentName.saveFailed"));
|
|
205
284
|
return agentName;
|
|
206
285
|
}, {
|
|
207
|
-
label: "
|
|
286
|
+
label: t("profile.agentName"),
|
|
208
287
|
fits: agentNameFits,
|
|
209
|
-
onReject: () => systemLine(
|
|
288
|
+
onReject: () => systemLine(t("profile.agentName.tooLong", { hangul: AGENT_NAME_WIDTH / 2, ascii: AGENT_NAME_WIDTH }), true),
|
|
210
289
|
});
|
|
211
290
|
});
|
|
212
291
|
|
|
@@ -231,7 +310,7 @@ async function dismissWorkSlot(id) {
|
|
|
231
310
|
if (applyWorkStatusEvent(workStatus, workStatus.generation, body)) renderWorkStatusAll();
|
|
232
311
|
} catch (error) {
|
|
233
312
|
console.warn("work slot dismiss:", error);
|
|
234
|
-
statusParts.caption.textContent = "
|
|
313
|
+
statusParts.caption.textContent = t("status.dismissFailed");
|
|
235
314
|
} finally {
|
|
236
315
|
dismissing.delete(id);
|
|
237
316
|
}
|
|
@@ -287,15 +366,20 @@ function setStatusOpen(open, fromHistory = false) {
|
|
|
287
366
|
syncStatusFold();
|
|
288
367
|
renderWorkStatusAll();
|
|
289
368
|
$("toggle-status").setAttribute("aria-expanded", String(open));
|
|
290
|
-
|
|
291
|
-
$("toggle-status").setAttribute("aria-label", label);
|
|
292
|
-
$("toggle-status").title = label;
|
|
369
|
+
renderStatusToggle();
|
|
293
370
|
if (fromHistory) return;
|
|
294
371
|
// Back returns to the chat on phones: the open state is one history entry.
|
|
295
372
|
if (open) history.pushState({ workStatus: true }, "");
|
|
296
373
|
else if (history.state?.workStatus) history.back();
|
|
297
374
|
}
|
|
298
375
|
|
|
376
|
+
/** The head Zig button's name follows the phone's status screen: it returns to the chat while that screen is open. */
|
|
377
|
+
function renderStatusToggle() {
|
|
378
|
+
const label = t(els.app.classList.contains("status-open") ? "status.backToChat" : "status.open");
|
|
379
|
+
$("toggle-status").setAttribute("aria-label", label);
|
|
380
|
+
$("toggle-status").title = label;
|
|
381
|
+
}
|
|
382
|
+
|
|
299
383
|
$("toggle-status").addEventListener("click", () => {
|
|
300
384
|
if (phone.matches) {
|
|
301
385
|
setStatusOpen(!els.app.classList.contains("status-open"));
|
|
@@ -376,7 +460,12 @@ setupWorkIdCopy(statusParts.graph);
|
|
|
376
460
|
|
|
377
461
|
function timeLabel(ts) {
|
|
378
462
|
const d = ts ? new Date(ts) : new Date();
|
|
379
|
-
return d.toLocaleTimeString(
|
|
463
|
+
return d.toLocaleTimeString(uiLocale(), { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** "Due 14:05" or "No deadline" for a card's meta line. */
|
|
467
|
+
function deadlineLabel(deadlineAt) {
|
|
468
|
+
return deadlineAt ? t("approval.deadline", { time: timeLabel(deadlineAt) }) : t("approval.noDeadline");
|
|
380
469
|
}
|
|
381
470
|
|
|
382
471
|
const DEV_MAX_ROWS = 400;
|
|
@@ -447,7 +536,7 @@ function updateScrollNav() {
|
|
|
447
536
|
nav.prev.disabled = els.messages.scrollTop <= SCROLL_EDGE_PX;
|
|
448
537
|
nav.next.disabled = distanceFromBottom() <= SCROLL_EDGE_PX;
|
|
449
538
|
nav.next.classList.toggle("unread", scroll.unread);
|
|
450
|
-
nav.next.title = scroll.unread ? "
|
|
539
|
+
nav.next.title = t(scroll.unread ? "chat.nav.unread" : "chat.nav.next");
|
|
451
540
|
}
|
|
452
541
|
|
|
453
542
|
function scrollToBottom(force = false) {
|
|
@@ -506,10 +595,12 @@ function messageRow({ who, bot, ts, tag }) {
|
|
|
506
595
|
time.textContent = timeLabel(ts);
|
|
507
596
|
head.append(name, time);
|
|
508
597
|
if (tag) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
598
|
+
// tag is a string key: data-i18n lets applyI18n (web/i18n.js) redraw it on a language change (bug 260).
|
|
599
|
+
const tagEl = document.createElement("span");
|
|
600
|
+
tagEl.className = "msg-tag";
|
|
601
|
+
tagEl.dataset.i18n = tag;
|
|
602
|
+
tagEl.textContent = t(tag);
|
|
603
|
+
head.append(tagEl);
|
|
513
604
|
}
|
|
514
605
|
body.append(head);
|
|
515
606
|
row.append(avatar, body);
|
|
@@ -524,7 +615,11 @@ function markRead(body, seq) {
|
|
|
524
615
|
// A cleared transcript (conversation switch, reload) detaches the marker; start over for the new list.
|
|
525
616
|
if (readReceipt.el && !readReceipt.el.isConnected) readReceipt.seq = -1;
|
|
526
617
|
if (typeof seq !== "number" || seq <= readReceipt.seq) return;
|
|
527
|
-
|
|
618
|
+
// data-i18n: a language change redraws the attached marker; textContent: a marker re-attached after a cleared list
|
|
619
|
+
// may have missed that change (bug 260).
|
|
620
|
+
readReceipt.el ??= Object.assign(document.createElement("span"), { className: "msg-read" });
|
|
621
|
+
readReceipt.el.dataset.i18n = "chat.read";
|
|
622
|
+
readReceipt.el.textContent = t("chat.read");
|
|
528
623
|
readReceipt.seq = seq;
|
|
529
624
|
body.querySelector(".msg-head")?.append(readReceipt.el);
|
|
530
625
|
}
|
|
@@ -580,7 +675,8 @@ function thinkingBlock(item) {
|
|
|
580
675
|
box.className = "thinking";
|
|
581
676
|
box.open = true;
|
|
582
677
|
const label = document.createElement("summary");
|
|
583
|
-
label.
|
|
678
|
+
label.dataset.i18n = "chat.thinking";
|
|
679
|
+
label.textContent = t("chat.thinking");
|
|
584
680
|
const pre = document.createElement("div");
|
|
585
681
|
pre.className = "thinking-text";
|
|
586
682
|
box.append(label, pre);
|
|
@@ -594,7 +690,7 @@ const progressEls = new Map();
|
|
|
594
690
|
|
|
595
691
|
function renderProgress(e) {
|
|
596
692
|
const d = e.data;
|
|
597
|
-
const label =
|
|
693
|
+
const label = PROGRESS_KINDS.includes(d.kind) ? t(`progress.${d.kind}`) : d.kind;
|
|
598
694
|
if (sinkSkip?.has(d.itemId)) return;
|
|
599
695
|
let el = progressEls.get(d.itemId);
|
|
600
696
|
if (!el) {
|
|
@@ -607,14 +703,20 @@ function renderProgress(e) {
|
|
|
607
703
|
el.textContent = `${label} [${d.status}] ${d.summary ?? ""}${output}`;
|
|
608
704
|
}
|
|
609
705
|
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
"Allow
|
|
613
|
-
|
|
614
|
-
|
|
706
|
+
const PROGRESS_KINDS = ["bash", "eval", "write", "edit", "read"];
|
|
707
|
+
const OPTION_KEYS = {
|
|
708
|
+
"Allow once": "approval.allowOnce",
|
|
709
|
+
"Allow always": "approval.allowAlways",
|
|
710
|
+
Deny: "approval.deny",
|
|
711
|
+
"Deny with feedback": "approval.denyFeedback",
|
|
615
712
|
};
|
|
616
713
|
const OPTION_STYLE = { "Allow once": "ok", "Allow always": "neutral", Deny: "danger", "Deny with feedback": "danger" };
|
|
617
714
|
|
|
715
|
+
/** The permission options come from the engine in English; a known one reads in the UI language, any other as it is. */
|
|
716
|
+
function optionLabel(value) {
|
|
717
|
+
return OPTION_KEYS[value] ? t(OPTION_KEYS[value]) : value;
|
|
718
|
+
}
|
|
719
|
+
|
|
618
720
|
/**
|
|
619
721
|
* Legacy card bodies (rendered before their decision was known) share the canonical slot, revision and HTTP route;
|
|
620
722
|
* only a genuinely unbound id-less card still answers over the socket.
|
|
@@ -625,13 +727,13 @@ function answer(req, data) {
|
|
|
625
727
|
const knownAlias = explicitId === null && inbox.state.decisionIdsByAction.has(req.actionId);
|
|
626
728
|
const card = cardFor(req.actionId);
|
|
627
729
|
if (!inbox.online) {
|
|
628
|
-
systemLine("
|
|
730
|
+
systemLine(t("decision.offline.answer"), true);
|
|
629
731
|
return;
|
|
630
732
|
}
|
|
631
733
|
if (!decision && (explicitId || knownAlias)) {
|
|
632
734
|
if (card) setCardState(card, {
|
|
633
735
|
disabled: true,
|
|
634
|
-
note: explicitId ? "
|
|
736
|
+
note: t(explicitId ? "common.checking" : "decision.checkInboxLatest"),
|
|
635
737
|
});
|
|
636
738
|
if (explicitId) void loadDecisionDetail(explicitId, card);
|
|
637
739
|
return;
|
|
@@ -656,9 +758,7 @@ function answer(req, data) {
|
|
|
656
758
|
if (!composed.ok) {
|
|
657
759
|
if (card) setCardState(card, {
|
|
658
760
|
disabled: false,
|
|
659
|
-
note: composed.reason === "incomplete"
|
|
660
|
-
? "필수 항목을 모두 고르거나 적어야 보낼 수 있습니다."
|
|
661
|
-
: "이 질문 형식은 웹에서 답할 수 없습니다.",
|
|
761
|
+
note: t(composed.reason === "incomplete" ? "decision.incomplete" : "decision.unsupported"),
|
|
662
762
|
});
|
|
663
763
|
return;
|
|
664
764
|
}
|
|
@@ -707,7 +807,7 @@ function setCardState(card, { disabled, note, retry }) {
|
|
|
707
807
|
}
|
|
708
808
|
state.replaceChildren(note);
|
|
709
809
|
if (retry) {
|
|
710
|
-
const button = make("button", "text-btn", "
|
|
810
|
+
const button = make("button", "text-btn", t("common.retry"));
|
|
711
811
|
button.type = "button";
|
|
712
812
|
button.onclick = retry;
|
|
713
813
|
state.append(" ", button);
|
|
@@ -717,7 +817,7 @@ function setCardState(card, { disabled, note, retry }) {
|
|
|
717
817
|
/** The socket answer of a legacy card; a canonical card renders its shared in-flight state instead. */
|
|
718
818
|
function setCardBusy(actionId, busy) {
|
|
719
819
|
const card = cardFor(actionId);
|
|
720
|
-
if (card && card.dataset.decisionRevision === undefined) setCardState(card, { disabled: busy, note: busy ? "
|
|
820
|
+
if (card && card.dataset.decisionRevision === undefined) setCardState(card, { disabled: busy, note: busy ? t("common.sending") : null });
|
|
721
821
|
}
|
|
722
822
|
|
|
723
823
|
/** Re-renders the mounted card of a decision from canonical state; an id-less card follows only its own unique alias. */
|
|
@@ -733,15 +833,15 @@ function syncCard(decision, except = null) {
|
|
|
733
833
|
|
|
734
834
|
/** One line of state under a canonical card: in transit, locked for review, offline, finished, or why it still waits. */
|
|
735
835
|
function cardStatus(decision, p, busy, review) {
|
|
736
|
-
if (busy) return "
|
|
737
|
-
if (review) return "
|
|
836
|
+
if (busy) return t("common.sending");
|
|
837
|
+
if (review) return t("decision.reviewFirst");
|
|
738
838
|
if (!isUnresolved(decision)) {
|
|
739
|
-
if (decision.status === "delivered") return "
|
|
740
|
-
if (decision.reason === "user_cancelled") return "
|
|
741
|
-
if (decision.reason === "user_dismissed") return "
|
|
742
|
-
return decision.supersededBy ? "
|
|
839
|
+
if (decision.status === "delivered") return t("decision.delivered");
|
|
840
|
+
if (decision.reason === "user_cancelled") return t("decision.userCancelled");
|
|
841
|
+
if (decision.reason === "user_dismissed") return t("decision.userDismissed");
|
|
842
|
+
return t(decision.supersededBy ? "decision.superseded" : "decision.closed");
|
|
743
843
|
}
|
|
744
|
-
if (!inbox.online) return "
|
|
844
|
+
if (!inbox.online) return t("decision.offline.answer");
|
|
745
845
|
return p.kind === "live" ? null : waitReason(decision);
|
|
746
846
|
}
|
|
747
847
|
|
|
@@ -761,10 +861,10 @@ function renderCardBody(card, decision) {
|
|
|
761
861
|
card.dataset.decisionRevision = String(decision.revision);
|
|
762
862
|
card.classList.toggle("resolved", p.terminal || !isUnresolved(decision));
|
|
763
863
|
card.setAttribute("aria-busy", String(busy));
|
|
764
|
-
const nodes = [make("div", "approval-title", payload.title || request?.title || (decision.permission ? "
|
|
864
|
+
const nodes = [make("div", "approval-title", payload.title || request?.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"))];
|
|
765
865
|
if (payload.detail) nodes.push(make("div", "approval-detail", payload.detail));
|
|
766
866
|
// A question cancelled by deleting it from the inbox stores { cancelled: true }, which is not an answer the user gave.
|
|
767
|
-
if (decision.answer && decision.reason !== "user_cancelled") nodes.push(make("div", "inbox-note",
|
|
867
|
+
if (decision.answer && decision.reason !== "user_cancelled") nodes.push(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
|
|
768
868
|
if (p.form) nodes.push(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, card)));
|
|
769
869
|
else {
|
|
770
870
|
const saved = renderSavedChoices(decision);
|
|
@@ -772,9 +872,9 @@ function renderCardBody(card, decision) {
|
|
|
772
872
|
}
|
|
773
873
|
const lost = renderReference(decision);
|
|
774
874
|
if (lost) nodes.push(lost);
|
|
775
|
-
const kind = decision.permission ? "
|
|
875
|
+
const kind = decision.permission ? t("approval.permission") : decision.method === "question" ? t("approval.question") : decision.method;
|
|
776
876
|
const binding = p.kind === "live" ? "" : ` · ${bindingLabel(decision)}`;
|
|
777
|
-
nodes.push(make("div", "approval-meta", `${
|
|
877
|
+
nodes.push(make("div", "approval-meta", `${deadlineLabel(decision.deadlineAt)} · ${kind}${binding}`));
|
|
778
878
|
const status = cardStatus(decision, p, busy, review);
|
|
779
879
|
if (status) {
|
|
780
880
|
const state = make("div", "approval-meta approval-state", status);
|
|
@@ -800,7 +900,7 @@ function renderApproval(req, resolved, ts) {
|
|
|
800
900
|
const explicitId = typeof req.decisionId === "string" ? req.decisionId : null;
|
|
801
901
|
if (explicitId) card.dataset.decisionId = explicitId;
|
|
802
902
|
cardRequests.set(card, req);
|
|
803
|
-
const body = messageRow({ who: agentName, bot: true, ts: req.createdAt ? new Date(req.createdAt).toISOString() : ts, tag: req.permission ? "
|
|
903
|
+
const body = messageRow({ who: agentName, bot: true, ts: req.createdAt ? new Date(req.createdAt).toISOString() : ts, tag: req.permission ? "approval.permissionRequest" : "approval.question" });
|
|
804
904
|
body.appendChild(card);
|
|
805
905
|
const decision = decisionForCard(inbox.state, req);
|
|
806
906
|
if (decision) {
|
|
@@ -809,7 +909,7 @@ function renderApproval(req, resolved, ts) {
|
|
|
809
909
|
}
|
|
810
910
|
renderLegacyBody(card, req);
|
|
811
911
|
if (explicitId) {
|
|
812
|
-
setCardState(card, { disabled: true, note: "
|
|
912
|
+
setCardState(card, { disabled: true, note: t("common.checking") });
|
|
813
913
|
void loadDecisionDetail(explicitId, card);
|
|
814
914
|
} else if (resolved) markResolved(card, resolved);
|
|
815
915
|
}
|
|
@@ -818,7 +918,7 @@ function renderApproval(req, resolved, ts) {
|
|
|
818
918
|
function renderLegacyBody(card, req) {
|
|
819
919
|
const title = document.createElement("div");
|
|
820
920
|
title.className = "approval-title";
|
|
821
|
-
title.textContent = req.title || "
|
|
921
|
+
title.textContent = req.title || t("approval.confirmRequest");
|
|
822
922
|
card.append(title);
|
|
823
923
|
if (req.detail) {
|
|
824
924
|
const detail = document.createElement("div");
|
|
@@ -828,7 +928,7 @@ function renderLegacyBody(card, req) {
|
|
|
828
928
|
}
|
|
829
929
|
const meta = document.createElement("div");
|
|
830
930
|
meta.className = "approval-meta";
|
|
831
|
-
meta.textContent = `${
|
|
931
|
+
meta.textContent = `${deadlineLabel(req.deadlineAt)} · ${req.permission ? t("approval.permission") : req.method === "question" ? t("approval.question") : req.method}`;
|
|
832
932
|
const actions = document.createElement("div");
|
|
833
933
|
actions.className = "approval-actions";
|
|
834
934
|
const button = (label, cls, onClick) => {
|
|
@@ -846,7 +946,7 @@ function renderLegacyBody(card, req) {
|
|
|
846
946
|
block.className = "approval-question";
|
|
847
947
|
const head = document.createElement("div");
|
|
848
948
|
head.className = "approval-qtitle";
|
|
849
|
-
head.textContent = `${q.header ? `[${q.header}] ` : ""}${q.question}${q.multiSelect ? "
|
|
949
|
+
head.textContent = `${q.header ? `[${q.header}] ` : ""}${q.question}${q.multiSelect ? t("approval.multiSelect") : ""}`;
|
|
850
950
|
const opts = document.createElement("div");
|
|
851
951
|
opts.className = "approval-options";
|
|
852
952
|
const chosen = new Set();
|
|
@@ -870,7 +970,7 @@ function renderLegacyBody(card, req) {
|
|
|
870
970
|
}
|
|
871
971
|
const other = document.createElement("input");
|
|
872
972
|
other.className = "approval-input";
|
|
873
|
-
other.placeholder = "
|
|
973
|
+
other.placeholder = t("approval.otherPlaceholder");
|
|
874
974
|
texts.set(q.id, other);
|
|
875
975
|
block.append(head, opts, other);
|
|
876
976
|
card.append(block);
|
|
@@ -878,10 +978,10 @@ function renderLegacyBody(card, req) {
|
|
|
878
978
|
const comment = document.createElement("textarea");
|
|
879
979
|
comment.className = "approval-input";
|
|
880
980
|
comment.rows = 2;
|
|
881
|
-
comment.placeholder = "
|
|
981
|
+
comment.placeholder = t("approval.commentPlaceholder");
|
|
882
982
|
card.append(comment);
|
|
883
983
|
actions.append(
|
|
884
|
-
button("
|
|
984
|
+
button(t("common.send"), "ok", () => {
|
|
885
985
|
const answers = {};
|
|
886
986
|
for (const q of req.questions) {
|
|
887
987
|
const selected = [...picked.get(q.id)];
|
|
@@ -891,22 +991,22 @@ function renderLegacyBody(card, req) {
|
|
|
891
991
|
if (!Object.keys(answers).length && !comment.value.trim()) return;
|
|
892
992
|
answer(req, { answers, comment: comment.value.trim() });
|
|
893
993
|
}),
|
|
894
|
-
button("
|
|
994
|
+
button(t("common.cancel"), "neutral", () => answer(req, { cancel: true })),
|
|
895
995
|
);
|
|
896
996
|
} else if (req.method === "select") {
|
|
897
997
|
for (const opt of req.options) {
|
|
898
|
-
actions.append(button(
|
|
998
|
+
actions.append(button(optionLabel(opt), req.permission ? (OPTION_STYLE[opt] ?? "neutral") : "neutral", () => answer(req, { value: opt })));
|
|
899
999
|
}
|
|
900
|
-
actions.append(button("
|
|
1000
|
+
actions.append(button(t("common.cancel"), "neutral", () => answer(req, { cancel: true })));
|
|
901
1001
|
} else if (req.method === "confirm") {
|
|
902
|
-
actions.append(button("
|
|
1002
|
+
actions.append(button(t("common.yes"), "ok", () => answer(req, { confirmed: true })), button(t("common.no"), "danger", () => answer(req, { confirmed: false })));
|
|
903
1003
|
} else {
|
|
904
1004
|
const box = document.createElement("textarea");
|
|
905
1005
|
box.className = "approval-input";
|
|
906
1006
|
box.rows = req.method === "editor" ? 6 : 2;
|
|
907
1007
|
box.value = "";
|
|
908
1008
|
card.append(box);
|
|
909
|
-
actions.append(button("
|
|
1009
|
+
actions.append(button(t("common.send"), "ok", () => answer(req, { value: box.value })), button(t("common.cancel"), "neutral", () => answer(req, { cancel: true })));
|
|
910
1010
|
}
|
|
911
1011
|
card.append(meta, actions);
|
|
912
1012
|
}
|
|
@@ -916,15 +1016,15 @@ function markResolved(card, res) {
|
|
|
916
1016
|
card.querySelector(".approval-state")?.remove();
|
|
917
1017
|
for (const el of card.querySelectorAll("button, textarea, input")) el.disabled = true;
|
|
918
1018
|
const a = res.answer ?? {};
|
|
919
|
-
let label = "
|
|
920
|
-
if (a.kind === "value") label =
|
|
1019
|
+
let label = t("approval.cancelled");
|
|
1020
|
+
if (a.kind === "value") label = t("approval.chosen", { value: optionLabel(a.value) });
|
|
921
1021
|
else if (a.kind === "question" && res.by === "user") {
|
|
922
1022
|
const parts = Object.values(a.answers ?? {}).map((x) => [...(x.selected ?? []), x.text].filter(Boolean).join(", "));
|
|
923
1023
|
if (a.comment) parts.push(a.comment);
|
|
924
|
-
label =
|
|
1024
|
+
label = t("approval.answered", { value: parts.join(" / ") || t("common.none") });
|
|
925
1025
|
}
|
|
926
|
-
else if (a.kind === "confirm") label = a.confirmed ? "
|
|
927
|
-
const by = {
|
|
1026
|
+
else if (a.kind === "confirm") label = t(a.confirmed ? "common.yes" : "common.no");
|
|
1027
|
+
const by = { timeout: t("approval.byTimeout"), cancelled: t("approval.byCancelled") }[res.by] ?? "";
|
|
928
1028
|
const note = document.createElement("div");
|
|
929
1029
|
note.className = "approval-meta";
|
|
930
1030
|
note.textContent = `${label}${by}${a.reason ? ` · ${a.reason}` : ""}`;
|
|
@@ -936,7 +1036,7 @@ function renderEvent(e, fromHistory = false) {
|
|
|
936
1036
|
switch (e.type) {
|
|
937
1037
|
case "message.user": {
|
|
938
1038
|
if (decisionCommand(e)) break;
|
|
939
|
-
const tag = e.data.trust === "owner-voice" ? "
|
|
1039
|
+
const tag = e.data.trust === "owner-voice" ? "chat.tag.voice" : undefined;
|
|
940
1040
|
const body = messageRow({ who: nickname, bot: false, ts: e.ts, tag });
|
|
941
1041
|
const quoted = sentReferences(e.data);
|
|
942
1042
|
if (quoted.length) body.appendChild(sentReferenceBlock(quoted, roleLabels()));
|
|
@@ -951,7 +1051,7 @@ function renderEvent(e, fromHistory = false) {
|
|
|
951
1051
|
}
|
|
952
1052
|
case "message.precomment": {
|
|
953
1053
|
// Main's one-line notice before longer work: its own lighter, smaller message, never merged into the answer.
|
|
954
|
-
const body = messageRow({ who: agentName, bot: true, ts: e.ts, tag: "
|
|
1054
|
+
const body = messageRow({ who: agentName, bot: true, ts: e.ts, tag: "chat.tag.notice" });
|
|
955
1055
|
body.parentElement.classList.add("precomment");
|
|
956
1056
|
textBlock(body, e.data.text ?? "");
|
|
957
1057
|
break;
|
|
@@ -1009,7 +1109,7 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1009
1109
|
break;
|
|
1010
1110
|
}
|
|
1011
1111
|
case "turn.started":
|
|
1012
|
-
if (!fromHistory) systemLine("
|
|
1112
|
+
if (!fromHistory) systemLine(t("chat.turnStarted"));
|
|
1013
1113
|
break;
|
|
1014
1114
|
case "turn.completed": {
|
|
1015
1115
|
if (!sink) {
|
|
@@ -1031,10 +1131,10 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1031
1131
|
els.thread.textContent = `thread ${e.data.threadId}`;
|
|
1032
1132
|
break;
|
|
1033
1133
|
case "error":
|
|
1034
|
-
systemLine(
|
|
1134
|
+
systemLine(e.data?.kind === "agent-definition" ? t("agent.definitionError") : t("chat.error", { message: e.data.message }), true);
|
|
1035
1135
|
break;
|
|
1036
1136
|
case "notice":
|
|
1037
|
-
systemLine(
|
|
1137
|
+
systemLine(t("chat.notice", { message: e.data.message }), e.data.level === "error");
|
|
1038
1138
|
break;
|
|
1039
1139
|
}
|
|
1040
1140
|
renderSeq = null;
|
|
@@ -1043,7 +1143,8 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1043
1143
|
|
|
1044
1144
|
function renderConversations() {
|
|
1045
1145
|
els.convList.innerHTML = "";
|
|
1046
|
-
|
|
1146
|
+
renderAgentGroup();
|
|
1147
|
+
for (const c of mainConversations(state.conversations)) {
|
|
1047
1148
|
const li = document.createElement("li");
|
|
1048
1149
|
li.className = `${c.id === state.current ? "active" : ""}${state.pendingByConv.get(c.id) ? " pending" : ""}`;
|
|
1049
1150
|
const hash = document.createElement("span");
|
|
@@ -1063,6 +1164,7 @@ const lastSent = { el: $("last-sent"), rec: null };
|
|
|
1063
1164
|
function setLastSent(rec) {
|
|
1064
1165
|
lastSent.rec = rec;
|
|
1065
1166
|
const label = lastSentLabel(rec);
|
|
1167
|
+
lastSent.el.dataset.label = t("chat.lastSent.prefix");
|
|
1066
1168
|
lastSent.el.hidden = !label;
|
|
1067
1169
|
lastSent.el.textContent = label;
|
|
1068
1170
|
lastSent.el.title = lastSentTooltip(rec);
|
|
@@ -1118,17 +1220,21 @@ function clearMessages(conv) {
|
|
|
1118
1220
|
}
|
|
1119
1221
|
|
|
1120
1222
|
/** `resume`: after a reconnect, keep what is on screen and fetch only the events missed meanwhile. */
|
|
1121
|
-
function selectConversation(
|
|
1223
|
+
function selectConversation(requested, resume = false) {
|
|
1224
|
+
// Improvement 262: a hidden conversation (old "+" conversation, a decision or link that names one) opens main.
|
|
1225
|
+
const id = shownConversation(state.conversations, requested);
|
|
1122
1226
|
const canResume = resume && id === state.current && hist.conv === id && hist.newest > 0;
|
|
1123
1227
|
if (id !== state.current) {
|
|
1124
1228
|
clearPendingReportSpeech(state.current);
|
|
1125
1229
|
clearReferences(refs.state);
|
|
1126
1230
|
renderReferences();
|
|
1231
|
+
resetSlashCommands();
|
|
1127
1232
|
}
|
|
1128
1233
|
state.current = id;
|
|
1129
1234
|
const conv = state.conversations.find((c) => c.id === id);
|
|
1130
1235
|
els.title.textContent = conv ? conv.title : id;
|
|
1131
1236
|
els.thread.textContent = conv?.threadId ? `thread ${conv.threadId}` : "";
|
|
1237
|
+
renderAgentHeader();
|
|
1132
1238
|
renderConversations();
|
|
1133
1239
|
renderInterrupt();
|
|
1134
1240
|
renderActivity();
|
|
@@ -1264,7 +1370,8 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1264
1370
|
renderWorkStatusAll();
|
|
1265
1371
|
setOmo(e.data.omo);
|
|
1266
1372
|
void syncInbox({ reset: true });
|
|
1267
|
-
|
|
1373
|
+
void loadAgents();
|
|
1374
|
+
selectConversation(state.current, true);
|
|
1268
1375
|
return;
|
|
1269
1376
|
case "decision.updated":
|
|
1270
1377
|
onDecisionUpdated(e.data);
|
|
@@ -1290,7 +1397,10 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1290
1397
|
setOmo(e.data.status);
|
|
1291
1398
|
return;
|
|
1292
1399
|
case "activity.updated":
|
|
1293
|
-
if (applyActivityEvent(activity, generation, e.conv, e.data)
|
|
1400
|
+
if (applyActivityEvent(activity, generation, e.conv, e.data)) {
|
|
1401
|
+
if (e.conv === state.current) renderActivity();
|
|
1402
|
+
renderAgentGroup();
|
|
1403
|
+
}
|
|
1294
1404
|
return;
|
|
1295
1405
|
case "work.status":
|
|
1296
1406
|
if (applyWorkStatusEvent(workStatus, statusGeneration, e.data)) renderWorkStatusAll();
|
|
@@ -1301,9 +1411,19 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1301
1411
|
renderSessionTransition();
|
|
1302
1412
|
return;
|
|
1303
1413
|
case "conversation.created":
|
|
1304
|
-
|
|
1305
|
-
|
|
1414
|
+
// An agent's POST answer may have listed its conversation already (receiveAgentUpdate); keep one row.
|
|
1415
|
+
if (state.conversations.some((c) => c.id === e.data.id)) state.conversations = state.conversations.map((c) => (c.id === e.data.id ? e.data : c));
|
|
1416
|
+
else state.conversations.push(e.data);
|
|
1417
|
+
if (shownConversation(state.conversations, e.data.id) === e.data.id) selectConversation(e.data.id);
|
|
1418
|
+
return;
|
|
1419
|
+
case "agent.updated":
|
|
1420
|
+
receiveAgentUpdate(e.data);
|
|
1306
1421
|
return;
|
|
1422
|
+
case "agent.applied":
|
|
1423
|
+
agentState.list = applyAgentApplied(agentState.list, e.conv, e.data);
|
|
1424
|
+
renderAgentHeader();
|
|
1425
|
+
renderAgentGroup();
|
|
1426
|
+
break;
|
|
1307
1427
|
case "action.request":
|
|
1308
1428
|
state.pendingByConv.set(e.conv, (state.pendingByConv.get(e.conv) ?? 0) + 1);
|
|
1309
1429
|
renderConversations();
|
|
@@ -1332,6 +1452,12 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1332
1452
|
case "security-check.updated":
|
|
1333
1453
|
applySecurityBody(e.data);
|
|
1334
1454
|
return;
|
|
1455
|
+
case "repo-security-check.updated":
|
|
1456
|
+
repoSecurity.apply(e.data);
|
|
1457
|
+
return;
|
|
1458
|
+
case "formal-verification.updated":
|
|
1459
|
+
applyFormal(e.data);
|
|
1460
|
+
return;
|
|
1335
1461
|
case "notifications.updated":
|
|
1336
1462
|
notificationSettings.updated(e.data);
|
|
1337
1463
|
return;
|
|
@@ -1343,6 +1469,7 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1343
1469
|
return;
|
|
1344
1470
|
case "security-check.allowed-values.updated":
|
|
1345
1471
|
allowedValues.updated(e.data.revision);
|
|
1472
|
+
securityBaseline.updated(e.data.revision);
|
|
1346
1473
|
return;
|
|
1347
1474
|
case "security-check.resolved":
|
|
1348
1475
|
void securityInbox.detail(e.data.id);
|
|
@@ -1354,12 +1481,15 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1354
1481
|
case "voice.install.progress":
|
|
1355
1482
|
voice.install = installStatusWith(voice.install, e.data);
|
|
1356
1483
|
renderVoiceInstall();
|
|
1357
|
-
if (e.data?.state === "failed") systemLine(
|
|
1358
|
-
else if (e.data?.state === "done") systemLine("
|
|
1484
|
+
if (e.data?.state === "failed") systemLine(t("voice.installFailed", { message: e.data.message }), true);
|
|
1485
|
+
else if (e.data?.state === "done") systemLine(t("voice.installDone"));
|
|
1359
1486
|
return;
|
|
1360
1487
|
case "model.changed":
|
|
1361
1488
|
renderModels(e.data);
|
|
1362
1489
|
return;
|
|
1490
|
+
case "ui-language.updated":
|
|
1491
|
+
applyUiLanguage(e.data);
|
|
1492
|
+
return;
|
|
1363
1493
|
case "device.list":
|
|
1364
1494
|
renderDeviceRequests(e.data);
|
|
1365
1495
|
enrollment.setDevices(e.data);
|
|
@@ -1384,10 +1514,12 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1384
1514
|
case "turn.started":
|
|
1385
1515
|
state.running.add(e.conv);
|
|
1386
1516
|
renderInterrupt();
|
|
1517
|
+
renderAgentGroup();
|
|
1387
1518
|
break;
|
|
1388
1519
|
case "turn.completed": {
|
|
1389
1520
|
state.running.delete(e.conv);
|
|
1390
1521
|
renderInterrupt();
|
|
1522
|
+
renderAgentGroup();
|
|
1391
1523
|
// A report-only turn names its published message (report_event_seq); user turns keep the last answer.
|
|
1392
1524
|
const speech = e.conv === state.current ? speechTextForTurn(e.data, tts.lastText.get(e.conv), (seq) => tts.finals.get(`${e.conv}/${seq}`)) : null;
|
|
1393
1525
|
if (tts.enabled && speech) speakSummary(speech);
|
|
@@ -1482,7 +1614,7 @@ function stopSpeech() {
|
|
|
1482
1614
|
const ttsButton = $("toggle-tts");
|
|
1483
1615
|
|
|
1484
1616
|
function renderTtsToggle() {
|
|
1485
|
-
ttsButton.textContent = tts.enabled ? "
|
|
1617
|
+
ttsButton.textContent = t(tts.enabled ? "voice.ttsOn" : "voice.ttsOff");
|
|
1486
1618
|
ttsButton.setAttribute("aria-pressed", String(tts.enabled));
|
|
1487
1619
|
ttsButton.classList.toggle("on", tts.enabled);
|
|
1488
1620
|
}
|
|
@@ -1507,7 +1639,7 @@ function playSpeech(url) {
|
|
|
1507
1639
|
tts.audio.onerror = resolve;
|
|
1508
1640
|
tts.audio.onpause = resolve;
|
|
1509
1641
|
tts.audio.play().catch((err) => {
|
|
1510
|
-
systemLine(
|
|
1642
|
+
systemLine(t("voice.playbackFailed", { error: err }), true);
|
|
1511
1643
|
resolve();
|
|
1512
1644
|
});
|
|
1513
1645
|
}).finally(() => {
|
|
@@ -1535,7 +1667,7 @@ function speak(text, { force = false, maxChunks = Infinity } = {}) {
|
|
|
1535
1667
|
URL.revokeObjectURL(url);
|
|
1536
1668
|
}
|
|
1537
1669
|
} catch (err) {
|
|
1538
|
-
systemLine(
|
|
1670
|
+
systemLine(t("voice.ttsFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
1539
1671
|
}
|
|
1540
1672
|
next?.then(URL.revokeObjectURL, () => {});
|
|
1541
1673
|
});
|
|
@@ -1548,7 +1680,7 @@ async function speakSummary(text) {
|
|
|
1548
1680
|
devLog("in", { type: "tts.summary", data: { summary } });
|
|
1549
1681
|
speak(summary);
|
|
1550
1682
|
} catch (err) {
|
|
1551
|
-
systemLine(
|
|
1683
|
+
systemLine(t("voice.summaryFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
1552
1684
|
speak(text, { maxChunks: 2 });
|
|
1553
1685
|
}
|
|
1554
1686
|
}
|
|
@@ -1556,7 +1688,7 @@ async function speakSummary(text) {
|
|
|
1556
1688
|
function readLastInFull() {
|
|
1557
1689
|
const text = tts.lastText.get(state.current);
|
|
1558
1690
|
if (!text) {
|
|
1559
|
-
systemLine("
|
|
1691
|
+
systemLine(t("voice.nothingToRead"));
|
|
1560
1692
|
return;
|
|
1561
1693
|
}
|
|
1562
1694
|
if (tts.audio) tts.audio.pause();
|
|
@@ -1587,7 +1719,7 @@ function renderOmo() {
|
|
|
1587
1719
|
if (omoStatus === null) return;
|
|
1588
1720
|
const ready = omoStatus === "ready";
|
|
1589
1721
|
const checking = ready && state.ws?.readyState === WebSocket.OPEN && isUnknown(activity, state.current);
|
|
1590
|
-
els.omo.textContent = checking ? "
|
|
1722
|
+
els.omo.textContent = t(checking ? "engine.checking" : ready ? "engine.ready" : omoStatus === "connecting" ? "engine.connecting" : "engine.down");
|
|
1591
1723
|
els.omo.classList.toggle("ok", ready);
|
|
1592
1724
|
}
|
|
1593
1725
|
|
|
@@ -1612,14 +1744,14 @@ function renderModels(info) {
|
|
|
1612
1744
|
modelSelect.append(group);
|
|
1613
1745
|
}
|
|
1614
1746
|
if (info.current) modelSelect.value = `${info.current.provider}/${info.current.id}`;
|
|
1615
|
-
modelSelect.title = info.current ?
|
|
1747
|
+
modelSelect.title = info.current ? t("model.current", { name: info.current.name }) : t("model.title");
|
|
1616
1748
|
// A held switch stays on the current model until the next message compacts the context for the target.
|
|
1617
1749
|
const pending = info.pending ?? null;
|
|
1618
1750
|
modelPending.hidden = !pending;
|
|
1619
1751
|
if (pending) {
|
|
1620
|
-
modelPending.textContent =
|
|
1621
|
-
modelPending.title =
|
|
1622
|
-
modelSelect.title +=
|
|
1752
|
+
modelPending.textContent = t("model.pending", { name: pending.name });
|
|
1753
|
+
modelPending.title = t("model.pending.tip", { name: pending.name, current: info.current?.name ?? t("model.currentFallback") });
|
|
1754
|
+
modelSelect.title += t("model.pendingSuffix", { name: pending.name });
|
|
1623
1755
|
}
|
|
1624
1756
|
modelSelect.disabled = info.options.length === 0;
|
|
1625
1757
|
}
|
|
@@ -1632,7 +1764,7 @@ async function loadModels() {
|
|
|
1632
1764
|
renderModels(body);
|
|
1633
1765
|
} catch (error) {
|
|
1634
1766
|
modelSelect.disabled = true;
|
|
1635
|
-
modelSelect.title =
|
|
1767
|
+
modelSelect.title = t("model.loadFailed", { error: error.message });
|
|
1636
1768
|
}
|
|
1637
1769
|
}
|
|
1638
1770
|
|
|
@@ -1646,7 +1778,7 @@ modelSelect.onchange = async () => {
|
|
|
1646
1778
|
renderModels(body);
|
|
1647
1779
|
} catch (error) {
|
|
1648
1780
|
if (modelInfo) renderModels(modelInfo);
|
|
1649
|
-
modelSelect.title =
|
|
1781
|
+
modelSelect.title = t("model.changeFailed", { error: error.message });
|
|
1650
1782
|
}
|
|
1651
1783
|
};
|
|
1652
1784
|
|
|
@@ -1657,6 +1789,14 @@ document.addEventListener("visibilitychange", () => sendWebPresence());
|
|
|
1657
1789
|
window.addEventListener("pagehide", () => sendWebPresence(false));
|
|
1658
1790
|
window.addEventListener("pageshow", () => sendWebPresence());
|
|
1659
1791
|
|
|
1792
|
+
/** The profile block's connection line: connecting (the page's first state), connected, or waiting to reconnect. */
|
|
1793
|
+
let connState = "connecting";
|
|
1794
|
+
|
|
1795
|
+
function renderConn() {
|
|
1796
|
+
els.conn.textContent = t(`conn.${connState}`);
|
|
1797
|
+
}
|
|
1798
|
+
renderConn();
|
|
1799
|
+
|
|
1660
1800
|
/** Callbacks belong to the socket that registered them: once state.ws moves on, a late open/message/close changes nothing. */
|
|
1661
1801
|
function connect() {
|
|
1662
1802
|
const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
|
@@ -1668,18 +1808,23 @@ function connect() {
|
|
|
1668
1808
|
ws.onopen = () => {
|
|
1669
1809
|
if (ws !== state.ws) return;
|
|
1670
1810
|
historyRequests.length = 0;
|
|
1671
|
-
|
|
1811
|
+
connState = "connected";
|
|
1812
|
+
renderConn();
|
|
1672
1813
|
$("head-avatar").classList.add("live");
|
|
1673
1814
|
renderActivity();
|
|
1674
1815
|
inbox.online = true;
|
|
1675
1816
|
void securityInbox.connect();
|
|
1676
1817
|
void allowedValues.connect();
|
|
1818
|
+
void securityBaseline.connect();
|
|
1677
1819
|
renderInboxOnline();
|
|
1678
1820
|
send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
|
|
1679
1821
|
sendWebPresence();
|
|
1680
1822
|
resendPendingReportSpeech();
|
|
1823
|
+
void readUiLanguage();
|
|
1681
1824
|
void readVoiceSetting();
|
|
1682
1825
|
void readSecuritySetting();
|
|
1826
|
+
void repoSecurity.read();
|
|
1827
|
+
void readFormal();
|
|
1683
1828
|
void notificationSettings.connect();
|
|
1684
1829
|
void readVoiceInstall();
|
|
1685
1830
|
// Also after an update restarted the gateway: the new version and the update result show without a reload.
|
|
@@ -1693,7 +1838,8 @@ function connect() {
|
|
|
1693
1838
|
if (ws !== state.ws) return;
|
|
1694
1839
|
endGeneration(activity, generation);
|
|
1695
1840
|
if (endStatusGeneration(workStatus, statusGeneration)) renderWorkStatusAll();
|
|
1696
|
-
|
|
1841
|
+
connState = "waiting";
|
|
1842
|
+
renderConn();
|
|
1697
1843
|
$("head-avatar").classList.remove("live");
|
|
1698
1844
|
renderActivity();
|
|
1699
1845
|
state.sessionTransitions.clear();
|
|
@@ -1702,6 +1848,7 @@ function connect() {
|
|
|
1702
1848
|
securityDetailsRequested.clear();
|
|
1703
1849
|
securityInbox.disconnect();
|
|
1704
1850
|
allowedValues.disconnect();
|
|
1851
|
+
securityBaseline.disconnect();
|
|
1705
1852
|
notificationSettings.disconnect();
|
|
1706
1853
|
historyRequests.length = 0;
|
|
1707
1854
|
renderInboxOnline();
|
|
@@ -1787,7 +1934,7 @@ function imageStrip(names) {
|
|
|
1787
1934
|
a.rel = "noopener noreferrer";
|
|
1788
1935
|
const img = document.createElement("img");
|
|
1789
1936
|
img.src = a.href;
|
|
1790
|
-
img.alt = "
|
|
1937
|
+
img.alt = t("attach.imageAlt");
|
|
1791
1938
|
img.loading = "lazy";
|
|
1792
1939
|
img.addEventListener("load", () => scrollToBottom());
|
|
1793
1940
|
a.appendChild(img);
|
|
@@ -1804,11 +1951,11 @@ function renderTray() {
|
|
|
1804
1951
|
box.className = "attach-thumb";
|
|
1805
1952
|
const img = document.createElement("img");
|
|
1806
1953
|
img.src = item.url;
|
|
1807
|
-
img.alt = item.file.name || "
|
|
1954
|
+
img.alt = item.file.name || t("attach.imageAlt");
|
|
1808
1955
|
const remove = document.createElement("button");
|
|
1809
1956
|
remove.type = "button";
|
|
1810
1957
|
remove.textContent = "×";
|
|
1811
|
-
remove.title = "
|
|
1958
|
+
remove.title = t("attach.remove");
|
|
1812
1959
|
remove.disabled = attach.busy;
|
|
1813
1960
|
remove.onclick = () => {
|
|
1814
1961
|
URL.revokeObjectURL(item.url);
|
|
@@ -1821,21 +1968,21 @@ function renderTray() {
|
|
|
1821
1968
|
if (attach.busy) {
|
|
1822
1969
|
const note = document.createElement("span");
|
|
1823
1970
|
note.className = "attach-note";
|
|
1824
|
-
note.textContent = "
|
|
1971
|
+
note.textContent = t("attach.uploading");
|
|
1825
1972
|
attach.tray.appendChild(note);
|
|
1826
1973
|
}
|
|
1827
1974
|
}
|
|
1828
1975
|
|
|
1829
1976
|
function addAttachments(files) {
|
|
1830
1977
|
if (attach.busy) {
|
|
1831
|
-
systemLine("
|
|
1978
|
+
systemLine(t("attach.busy"));
|
|
1832
1979
|
return;
|
|
1833
1980
|
}
|
|
1834
1981
|
const images = [...files].filter((f) => /^image\/(png|jpeg|gif|webp)$/.test(f.type));
|
|
1835
|
-
if (images.length < files.length) systemLine("
|
|
1982
|
+
if (images.length < files.length) systemLine(t("attach.types"), true);
|
|
1836
1983
|
for (const file of images) {
|
|
1837
1984
|
if (attach.items.length >= MAX_ATTACH) {
|
|
1838
|
-
systemLine(
|
|
1985
|
+
systemLine(t("attach.max", { max: MAX_ATTACH }), true);
|
|
1839
1986
|
break;
|
|
1840
1987
|
}
|
|
1841
1988
|
attach.items.push({ file, url: URL.createObjectURL(file) });
|
|
@@ -1863,12 +2010,12 @@ async function prepareImage(file) {
|
|
|
1863
2010
|
|
|
1864
2011
|
async function uploadImage(file) {
|
|
1865
2012
|
const image = await prepareImage(file);
|
|
1866
|
-
if (image.size > MAX_UPLOAD_BYTES) throw new Error("
|
|
2013
|
+
if (image.size > MAX_UPLOAD_BYTES) throw new Error(t("attach.tooLarge"));
|
|
1867
2014
|
const form = new FormData();
|
|
1868
2015
|
form.append("image", image, file.name || "image");
|
|
1869
2016
|
const res = await fetch("/api/upload", { method: "POST", body: form });
|
|
1870
2017
|
const body = await res.json().catch(() => ({}));
|
|
1871
|
-
if (res.status === 413) throw new Error("
|
|
2018
|
+
if (res.status === 413) throw new Error(t("attach.rejected413"));
|
|
1872
2019
|
if (!res.ok || !body.name) throw new Error(body.error ?? String(res.status));
|
|
1873
2020
|
return body.name;
|
|
1874
2021
|
}
|
|
@@ -1897,7 +2044,7 @@ async function sendWithImages(text) {
|
|
|
1897
2044
|
}
|
|
1898
2045
|
if (state.current === conv) scrollToBottom(true);
|
|
1899
2046
|
} catch (error) {
|
|
1900
|
-
systemLine(
|
|
2047
|
+
systemLine(t("attach.uploadFailed", { error: error.message }), true);
|
|
1901
2048
|
} finally {
|
|
1902
2049
|
attach.busy = false;
|
|
1903
2050
|
refs.state.locked = false;
|
|
@@ -1936,6 +2083,7 @@ async function deviceAction(id, path, method) {
|
|
|
1936
2083
|
}
|
|
1937
2084
|
|
|
1938
2085
|
const deviceBannerState = { devices: [], pairing: null };
|
|
2086
|
+
const DEVICE_STATUSES = ["pending", "approved", "denied"];
|
|
1939
2087
|
|
|
1940
2088
|
function renderDeviceRequests(devices = deviceBannerState.devices) {
|
|
1941
2089
|
deviceBannerState.devices = devices;
|
|
@@ -1945,11 +2093,11 @@ function renderDeviceRequests(devices = deviceBannerState.devices) {
|
|
|
1945
2093
|
const row = document.createElement("div");
|
|
1946
2094
|
row.className = "device-request";
|
|
1947
2095
|
const text = document.createElement("span");
|
|
1948
|
-
text.textContent =
|
|
2096
|
+
text.textContent = t("device.locked", { count: pairing.failures });
|
|
1949
2097
|
const unlock = document.createElement("button");
|
|
1950
2098
|
unlock.className = "btn ok";
|
|
1951
|
-
unlock.textContent = "
|
|
1952
|
-
unlock.onclick = () => deviceAction("unlock", "", "POST").catch((err) => systemLine(
|
|
2099
|
+
unlock.textContent = t("device.unlock");
|
|
2100
|
+
unlock.onclick = () => deviceAction("unlock", "", "POST").catch((err) => systemLine(t("device.unlockFailed", { error: err.message }), true));
|
|
1953
2101
|
row.append(text, unlock);
|
|
1954
2102
|
deviceBanner.append(row);
|
|
1955
2103
|
}
|
|
@@ -1957,36 +2105,37 @@ function renderDeviceRequests(devices = deviceBannerState.devices) {
|
|
|
1957
2105
|
const row = document.createElement("div");
|
|
1958
2106
|
row.className = "device-request";
|
|
1959
2107
|
const text = document.createElement("span");
|
|
1960
|
-
text.textContent =
|
|
2108
|
+
text.textContent = t("device.request", { name: d.name });
|
|
1961
2109
|
const code = document.createElement("b");
|
|
1962
2110
|
code.textContent = d.code;
|
|
1963
|
-
text.append(code, "
|
|
2111
|
+
text.append(code, t("device.autoDeny"));
|
|
1964
2112
|
const button = (label, cls, path) => {
|
|
1965
2113
|
const b = document.createElement("button");
|
|
1966
2114
|
b.className = `btn ${cls}`;
|
|
1967
2115
|
b.textContent = label;
|
|
1968
|
-
b.onclick = () => deviceAction(d.id, path, "POST").catch((err) => systemLine(
|
|
2116
|
+
b.onclick = () => deviceAction(d.id, path, "POST").catch((err) => systemLine(t("device.actionFailed", { action: label, error: err.message }), true));
|
|
1969
2117
|
return b;
|
|
1970
2118
|
};
|
|
1971
|
-
row.append(text, button("
|
|
2119
|
+
row.append(text, button(t("common.approve"), "ok", "/approve"), button(t("common.deny"), "danger", "/deny"));
|
|
1972
2120
|
deviceBanner.append(row);
|
|
1973
2121
|
}
|
|
1974
2122
|
}
|
|
1975
2123
|
|
|
1976
2124
|
async function listDevicesCommand(deleteIndex) {
|
|
1977
2125
|
const res = await fetch("/api/devices");
|
|
1978
|
-
if (!res.ok) throw new Error(res.status === 403 ? "
|
|
2126
|
+
if (!res.ok) throw new Error(res.status === 403 ? t("device.macOnly") : String(res.status));
|
|
1979
2127
|
const devices = await res.json();
|
|
1980
2128
|
if (deleteIndex !== undefined) {
|
|
1981
2129
|
const target = devices[deleteIndex - 1];
|
|
1982
|
-
if (!target) throw new Error(
|
|
2130
|
+
if (!target) throw new Error(t("device.missing", { index: deleteIndex }));
|
|
1983
2131
|
await deviceAction(target.id, "", "DELETE");
|
|
1984
|
-
systemLine(
|
|
2132
|
+
systemLine(t("device.deleted", { name: target.name }));
|
|
1985
2133
|
return;
|
|
1986
2134
|
}
|
|
1987
|
-
const status =
|
|
1988
|
-
const when = (ts) => (ts ? new Date(ts).toLocaleString(
|
|
1989
|
-
|
|
2135
|
+
const status = (value) => (DEVICE_STATUSES.includes(value) ? t(`device.status.${value}`) : value);
|
|
2136
|
+
const when = (ts) => (ts ? new Date(ts).toLocaleString(uiLocale(), { dateStyle: "medium", timeStyle: "medium", hourCycle: "h23" }) : t("device.neverSeen"));
|
|
2137
|
+
const list = devices.map((d, i) => t("device.listItem", { index: i + 1, name: d.name, status: status(d.status), when: when(d.lastSeen) })).join(" / ");
|
|
2138
|
+
systemLine(devices.length ? t("device.list", { list }) : t("device.none"));
|
|
1990
2139
|
}
|
|
1991
2140
|
|
|
1992
2141
|
async function applyOptionCommand(command) {
|
|
@@ -2000,7 +2149,7 @@ async function applyOptionCommand(command) {
|
|
|
2000
2149
|
return;
|
|
2001
2150
|
}
|
|
2002
2151
|
if (command.kind === "devices" || command.kind === "deviceDelete") {
|
|
2003
|
-
await listDevicesCommand(command.index).catch((err) => systemLine(
|
|
2152
|
+
await listDevicesCommand(command.index).catch((err) => systemLine(t("device.commandFailed", { error: err.message }), true));
|
|
2004
2153
|
scrollToBottom();
|
|
2005
2154
|
return;
|
|
2006
2155
|
}
|
|
@@ -2019,10 +2168,14 @@ els.composer.addEventListener("submit", (ev) => {
|
|
|
2019
2168
|
const slash = { el: $("slash-menu"), omo: null, loading: null, failed: false, items: [], index: 0 };
|
|
2020
2169
|
|
|
2021
2170
|
function loadSlashCommands() {
|
|
2022
|
-
|
|
2171
|
+
// The list is the open conversation's: an agent session's skills are its allowlist (the gateway filters them).
|
|
2172
|
+
const conv = state.current;
|
|
2173
|
+
slash.loading ??= fetch(`/api/commands?conv=${encodeURIComponent(conv)}`)
|
|
2023
2174
|
.then(async (res) => {
|
|
2024
2175
|
if (!res.ok) throw new Error(String(res.status));
|
|
2025
|
-
|
|
2176
|
+
const list = await res.json();
|
|
2177
|
+
if (conv !== state.current) return;
|
|
2178
|
+
slash.omo = list;
|
|
2026
2179
|
slash.failed = false;
|
|
2027
2180
|
})
|
|
2028
2181
|
.catch(() => {
|
|
@@ -2039,6 +2192,12 @@ function closeSlashMenu() {
|
|
|
2039
2192
|
slash.items = [];
|
|
2040
2193
|
}
|
|
2041
2194
|
|
|
2195
|
+
function resetSlashCommands() {
|
|
2196
|
+
slash.omo = null;
|
|
2197
|
+
slash.failed = false;
|
|
2198
|
+
closeSlashMenu();
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2042
2201
|
function renderSlashMenu() {
|
|
2043
2202
|
const query = slashQuery(els.input.value);
|
|
2044
2203
|
if (query === null) return closeSlashMenu();
|
|
@@ -2066,7 +2225,7 @@ function renderSlashMenu() {
|
|
|
2066
2225
|
});
|
|
2067
2226
|
return row;
|
|
2068
2227
|
});
|
|
2069
|
-
const note = slash.loading ? "
|
|
2228
|
+
const note = slash.loading ? t("slash.loading") : slash.failed ? t("slash.failed") : rows.length === 0 ? t("slash.empty") : "";
|
|
2070
2229
|
if (note) {
|
|
2071
2230
|
const div = document.createElement("div");
|
|
2072
2231
|
div.className = "slash-note";
|
|
@@ -2133,7 +2292,7 @@ els.input.addEventListener("keydown", (ev) => {
|
|
|
2133
2292
|
});
|
|
2134
2293
|
els.input.addEventListener("input", (ev) => {
|
|
2135
2294
|
if (ev.isTrusted) {
|
|
2136
|
-
cancelAutoSend("
|
|
2295
|
+
cancelAutoSend(t("voice.autoSendCancel.edited"));
|
|
2137
2296
|
resetRecall(recall.rec);
|
|
2138
2297
|
}
|
|
2139
2298
|
els.input.style.height = "auto";
|
|
@@ -2143,13 +2302,9 @@ els.input.addEventListener("input", (ev) => {
|
|
|
2143
2302
|
});
|
|
2144
2303
|
els.input.addEventListener("blur", closeSlashMenu);
|
|
2145
2304
|
slash.el.addEventListener("pointerdown", (ev) => ev.preventDefault());
|
|
2146
|
-
$("new-conv").onclick = () => {
|
|
2147
|
-
const title = prompt("새 대화 이름", "새 작업");
|
|
2148
|
-
if (title) send({ type: "conversation.create", data: { title } });
|
|
2149
|
-
};
|
|
2150
2305
|
$("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
|
|
2151
2306
|
$("rotate-session").onclick = () => {
|
|
2152
|
-
if (!confirm("
|
|
2307
|
+
if (!confirm(t("head.newSession.confirm"))) return;
|
|
2153
2308
|
send({ type: "conversation.rotate", conv: state.current });
|
|
2154
2309
|
};
|
|
2155
2310
|
function toggleDevPanel() {
|
|
@@ -2164,6 +2319,17 @@ $("clear-dev").onclick = () => {
|
|
|
2164
2319
|
|
|
2165
2320
|
let recorder = null;
|
|
2166
2321
|
let chunks = [];
|
|
2322
|
+
let micState = "idle";
|
|
2323
|
+
|
|
2324
|
+
/** The mic button reads its state: idle, recording (press to send) or recognizing the recording. */
|
|
2325
|
+
function setMicState(state) {
|
|
2326
|
+
micState = state;
|
|
2327
|
+
els.mic.classList.toggle("recording", state === "recording");
|
|
2328
|
+
els.mic.classList.toggle("busy", state === "recognizing");
|
|
2329
|
+
els.mic.textContent = t(state === "recording" ? "composer.mic.recording" : state === "recognizing" ? "composer.mic.recognizing" : "composer.mic");
|
|
2330
|
+
}
|
|
2331
|
+
setMicState("idle");
|
|
2332
|
+
|
|
2167
2333
|
els.mic.onclick = async () => {
|
|
2168
2334
|
if (recorder && recorder.state === "recording") {
|
|
2169
2335
|
recorder.stop();
|
|
@@ -2180,44 +2346,41 @@ els.mic.onclick = async () => {
|
|
|
2180
2346
|
recorder = new MediaRecorder(stream);
|
|
2181
2347
|
recorder.ondataavailable = (ev) => chunks.push(ev.data);
|
|
2182
2348
|
recorder.onstop = async () => {
|
|
2183
|
-
for (const
|
|
2184
|
-
els.mic.classList.remove("recording");
|
|
2349
|
+
for (const track of stream.getTracks()) track.stop();
|
|
2185
2350
|
if (!voice.enabled) {
|
|
2186
|
-
|
|
2351
|
+
setMicState("idle");
|
|
2187
2352
|
return;
|
|
2188
2353
|
}
|
|
2189
|
-
|
|
2190
|
-
els.mic.textContent = "인식 중";
|
|
2354
|
+
setMicState("recognizing");
|
|
2191
2355
|
const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
|
|
2192
2356
|
const form = new FormData();
|
|
2193
2357
|
form.append("audio", blob, "voice.webm");
|
|
2194
2358
|
try {
|
|
2195
2359
|
const res = await fetch(`/api/voice?conv=${encodeURIComponent(state.current)}`, { method: "POST", body: form });
|
|
2196
2360
|
const body = await res.json();
|
|
2197
|
-
if (!res.ok) systemLine(
|
|
2361
|
+
if (!res.ok) systemLine(t("voice.sttFailed", { error: body.error }), true);
|
|
2198
2362
|
} catch (err) {
|
|
2199
|
-
systemLine(
|
|
2363
|
+
systemLine(t("voice.sttFailed", { error: err }), true);
|
|
2200
2364
|
} finally {
|
|
2201
|
-
|
|
2202
|
-
els.mic.textContent = "마이크";
|
|
2365
|
+
setMicState("idle");
|
|
2203
2366
|
}
|
|
2204
2367
|
};
|
|
2205
2368
|
recorder.start();
|
|
2206
|
-
|
|
2207
|
-
els.mic.textContent = "녹음 중 (눌러서 전송)";
|
|
2369
|
+
setMicState("recording");
|
|
2208
2370
|
} catch (err) {
|
|
2209
|
-
systemLine(
|
|
2371
|
+
systemLine(t("voice.micUnavailable", { error: err }), true);
|
|
2210
2372
|
}
|
|
2211
2373
|
};
|
|
2212
2374
|
|
|
2213
2375
|
const WAKE_SAMPLE_RATE = 16000;
|
|
2214
2376
|
const WAKE_ARMED_MS = 8000;
|
|
2215
|
-
const wake = { vad: null, armedUntil: 0, queue: Promise.resolve() };
|
|
2377
|
+
const wake = { vad: null, armedUntil: 0, queue: Promise.resolve(), status: "off" };
|
|
2216
2378
|
const wakeButton = $("toggle-wake");
|
|
2379
|
+
const WAKE_LABELS = { off: "voice.wakeOff", loading: "voice.wakeLoading", listening: "voice.wakeListening", armed: "voice.wakeArmed" };
|
|
2217
2380
|
|
|
2218
2381
|
function setWakeStatus(status) {
|
|
2219
|
-
|
|
2220
|
-
wakeButton.textContent =
|
|
2382
|
+
wake.status = status;
|
|
2383
|
+
wakeButton.textContent = t(WAKE_LABELS[status]);
|
|
2221
2384
|
wakeButton.setAttribute("aria-pressed", String(status !== "off"));
|
|
2222
2385
|
wakeButton.classList.toggle("on", status === "listening" || status === "loading");
|
|
2223
2386
|
wakeButton.classList.toggle("armed", status === "armed");
|
|
@@ -2273,24 +2436,30 @@ function cancelAutoSend(reason) {
|
|
|
2273
2436
|
if (!autoSend.timer) return;
|
|
2274
2437
|
clearInterval(autoSend.timer);
|
|
2275
2438
|
autoSend.timer = null;
|
|
2276
|
-
|
|
2439
|
+
renderSendButton();
|
|
2277
2440
|
sendButton.classList.remove("counting");
|
|
2278
|
-
if (reason) systemLine(
|
|
2441
|
+
if (reason) systemLine(t("voice.autoSendCancelled", { reason }));
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/** "Send", or "Send 3" while a voice command counts down to its automatic send. */
|
|
2445
|
+
function renderSendButton() {
|
|
2446
|
+
sendButton.textContent = autoSend.timer ? t("composer.sendIn", { n: autoSend.left }) : t("composer.send");
|
|
2279
2447
|
}
|
|
2448
|
+
renderSendButton();
|
|
2280
2449
|
|
|
2281
2450
|
function scheduleAutoSend() {
|
|
2282
2451
|
cancelAutoSend();
|
|
2283
2452
|
autoSend.left = AUTO_SEND_SECONDS;
|
|
2284
|
-
sendButton.textContent = `전송 ${autoSend.left}`;
|
|
2285
2453
|
sendButton.classList.add("counting");
|
|
2286
2454
|
autoSend.timer = setInterval(() => {
|
|
2287
2455
|
autoSend.left -= 1;
|
|
2288
2456
|
if (autoSend.left > 0) {
|
|
2289
|
-
|
|
2457
|
+
renderSendButton();
|
|
2290
2458
|
return;
|
|
2291
2459
|
}
|
|
2292
2460
|
sendText();
|
|
2293
2461
|
}, 1000);
|
|
2462
|
+
renderSendButton();
|
|
2294
2463
|
}
|
|
2295
2464
|
|
|
2296
2465
|
function voiceCommand(text) {
|
|
@@ -2302,7 +2471,7 @@ function stopForTripleHey() {
|
|
|
2302
2471
|
stopSpeech();
|
|
2303
2472
|
cancelAutoSend();
|
|
2304
2473
|
send({ type: "input.interrupt", conv: state.current, data: {} });
|
|
2305
|
-
systemLine("
|
|
2474
|
+
systemLine(t("voice.tripleHey"));
|
|
2306
2475
|
chime();
|
|
2307
2476
|
arm();
|
|
2308
2477
|
}
|
|
@@ -2311,9 +2480,9 @@ async function onWakeSegment(samples, armed, duringSpeech) {
|
|
|
2311
2480
|
if (!voice.enabled) return;
|
|
2312
2481
|
let text;
|
|
2313
2482
|
try {
|
|
2314
|
-
text = await transcribeSamples(samples, armed ? null : "
|
|
2483
|
+
text = await transcribeSamples(samples, armed ? null : t("voice.wakePrompt"));
|
|
2315
2484
|
} catch (err) {
|
|
2316
|
-
systemLine(
|
|
2485
|
+
systemLine(t("voice.wakeSttFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2317
2486
|
return;
|
|
2318
2487
|
}
|
|
2319
2488
|
devLog("in", { type: "wake.heard", data: { text, armed, duringSpeech } });
|
|
@@ -2323,12 +2492,12 @@ async function onWakeSegment(samples, armed, duringSpeech) {
|
|
|
2323
2492
|
}
|
|
2324
2493
|
if (duringSpeech) return;
|
|
2325
2494
|
if (autoSend.timer && isCancelWord(text)) {
|
|
2326
|
-
cancelAutoSend("
|
|
2495
|
+
cancelAutoSend(t("voice.autoSendCancel.voice"));
|
|
2327
2496
|
return;
|
|
2328
2497
|
}
|
|
2329
2498
|
const hit = matchWake(text);
|
|
2330
|
-
if (!armed && !hit && text) els.input.placeholder =
|
|
2331
|
-
else els.input.placeholder = "
|
|
2499
|
+
if (!armed && !hit && text) els.input.placeholder = t("voice.heard", { text: text.slice(0, 40) });
|
|
2500
|
+
else els.input.placeholder = t("composer.placeholder");
|
|
2332
2501
|
if (armed) {
|
|
2333
2502
|
disarm();
|
|
2334
2503
|
const command = hit ? hit.command : text;
|
|
@@ -2380,7 +2549,7 @@ wakeButton.onclick = async () => {
|
|
|
2380
2549
|
} catch (err) {
|
|
2381
2550
|
wake.vad = null;
|
|
2382
2551
|
setWakeStatus("off");
|
|
2383
|
-
systemLine(
|
|
2552
|
+
systemLine(t("voice.wakeStartFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2384
2553
|
}
|
|
2385
2554
|
};
|
|
2386
2555
|
setWakeStatus("off");
|
|
@@ -2389,7 +2558,7 @@ const clap = { ctx: null, stream: null };
|
|
|
2389
2558
|
const clapButton = $("toggle-clap");
|
|
2390
2559
|
|
|
2391
2560
|
function renderClapToggle() {
|
|
2392
|
-
clapButton.textContent = !clap.ctx ? "
|
|
2561
|
+
clapButton.textContent = t(!clap.ctx ? "voice.clapOff" : clap.ctx.state === "suspended" ? "voice.clapSuspended" : "voice.clapOn");
|
|
2393
2562
|
clapButton.setAttribute("aria-pressed", String(Boolean(clap.ctx)));
|
|
2394
2563
|
clapButton.classList.toggle("on", Boolean(clap.ctx));
|
|
2395
2564
|
}
|
|
@@ -2436,7 +2605,7 @@ clapButton.onclick = async () => {
|
|
|
2436
2605
|
if (clap.ctx) await stopClap();
|
|
2437
2606
|
else await startClap();
|
|
2438
2607
|
} catch (err) {
|
|
2439
|
-
systemLine(
|
|
2608
|
+
systemLine(t("voice.clapStartFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2440
2609
|
}
|
|
2441
2610
|
// The stored choice is the user's own; a stop forced by the voice setting is not written.
|
|
2442
2611
|
if (voice.enabled) localStorage.setItem(CLAP_KEY, clap.ctx ? "1" : "0");
|
|
@@ -2456,7 +2625,7 @@ function autoStartClap() {
|
|
|
2456
2625
|
if (clap.ctx?.state !== "suspended") return;
|
|
2457
2626
|
document.addEventListener("pointerdown", () => clap.ctx?.resume().then(renderClapToggle), { once: true, capture: true });
|
|
2458
2627
|
})
|
|
2459
|
-
.catch((err) => systemLine(
|
|
2628
|
+
.catch((err) => systemLine(t("voice.clapEnableFailed", { error: err instanceof Error ? err.message : err }), true));
|
|
2460
2629
|
}
|
|
2461
2630
|
autoStartClap();
|
|
2462
2631
|
|
|
@@ -2510,7 +2679,7 @@ async function setVoiceEnabled(enabled) {
|
|
|
2510
2679
|
voice.reads += 1;
|
|
2511
2680
|
await applyVoiceSetting(voiceEnabledFrom(body));
|
|
2512
2681
|
} catch (err) {
|
|
2513
|
-
systemLine(
|
|
2682
|
+
systemLine(t("voice.settingFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2514
2683
|
} finally {
|
|
2515
2684
|
voice.busy = false;
|
|
2516
2685
|
}
|
|
@@ -2526,9 +2695,7 @@ const security = { enabled: true, busy: false, read: 0 };
|
|
|
2526
2695
|
const securityButton = $("toggle-security");
|
|
2527
2696
|
|
|
2528
2697
|
function renderSecurityToggle() {
|
|
2529
|
-
securityButton
|
|
2530
|
-
securityButton.setAttribute("aria-pressed", String(security.enabled));
|
|
2531
|
-
securityButton.classList.toggle("on", security.enabled);
|
|
2698
|
+
renderSwitch(securityButton, security.enabled);
|
|
2532
2699
|
}
|
|
2533
2700
|
|
|
2534
2701
|
function applySecurityBody(body) {
|
|
@@ -2557,12 +2724,176 @@ async function setSecurityEnabled(enabled) {
|
|
|
2557
2724
|
if (!res.ok) throw new Error(body.error ?? String(res.status));
|
|
2558
2725
|
applySecurityBody(body);
|
|
2559
2726
|
} catch (err) {
|
|
2560
|
-
systemLine(
|
|
2727
|
+
systemLine(t("settings.security.message.saveFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2561
2728
|
} finally {
|
|
2562
2729
|
security.busy = false;
|
|
2563
2730
|
}
|
|
2564
2731
|
}
|
|
2565
2732
|
|
|
2733
|
+
// ---- Improvement 250 and bug 259: the repository security check switch and the formal verification tool rows ----
|
|
2734
|
+
const repoSecurity = setupSwitch({ path: "/api/settings/repo-security-check", button: $("toggle-repo-security"), nameKey: "settings.security.repo.title" });
|
|
2735
|
+
|
|
2736
|
+
/** A role=switch setting with GET/PUT {enabled} at `path`; `nameKey` names it in the failure line. */
|
|
2737
|
+
function setupSwitch({ path, button, nameKey }) {
|
|
2738
|
+
const sw = { enabled: true, busy: false };
|
|
2739
|
+
const render = () => renderSwitch(button, sw.enabled);
|
|
2740
|
+
sw.render = render;
|
|
2741
|
+
sw.apply = (body) => { if (typeof body?.enabled === "boolean") sw.enabled = body.enabled; render(); };
|
|
2742
|
+
sw.read = async () => {
|
|
2743
|
+
try { const res = await fetch(path, { cache: "no-store" }); if (res.ok) sw.apply(await res.json()); } catch { /* Offline: the reconnect reads it again. */ }
|
|
2744
|
+
};
|
|
2745
|
+
button.onclick = async () => {
|
|
2746
|
+
if (sw.busy) return;
|
|
2747
|
+
sw.busy = true;
|
|
2748
|
+
try {
|
|
2749
|
+
const res = await fetch(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: !sw.enabled }) });
|
|
2750
|
+
const body = await res.json().catch(() => ({}));
|
|
2751
|
+
if (!res.ok) throw new Error(body.error ?? String(res.status));
|
|
2752
|
+
sw.apply(body);
|
|
2753
|
+
} catch (err) {
|
|
2754
|
+
systemLine(t("settings.switchFailed", { name: t(nameKey), error: err instanceof Error ? err.message : err }), true);
|
|
2755
|
+
} finally {
|
|
2756
|
+
sw.busy = false;
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
render();
|
|
2760
|
+
return sw;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
const FORMAL_PATH = "/api/settings/formal-verification";
|
|
2764
|
+
const FORMAL_STATUSES = ["off", "not_installed", "installing", "installed", "failed"];
|
|
2765
|
+
const formal = { state: null, busy: false };
|
|
2766
|
+
|
|
2767
|
+
/** A known install status reads in the UI language; an unknown one from a newer gateway shows as it is. */
|
|
2768
|
+
function formalStatusText(status) {
|
|
2769
|
+
if (!FORMAL_STATUSES.includes(status)) return status;
|
|
2770
|
+
return t(`settings.formal.status.${status}`);
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/** The tool's full description for its info icon: the dictionary's when it knows the tool, else the gateway's text. */
|
|
2774
|
+
function formalToolDetail(tool) {
|
|
2775
|
+
const key = `settings.formal.tool.${tool.id}.detail`;
|
|
2776
|
+
const text = t(key);
|
|
2777
|
+
return text === key ? tool.description : text;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
/** The 1-2 line summary under a tool row: the dictionary's when it knows the tool, else the gateway's description. */
|
|
2781
|
+
function formalToolSummary(tool) {
|
|
2782
|
+
const key = `settings.formal.toolSummary.${tool.id}`;
|
|
2783
|
+
const text = t(key);
|
|
2784
|
+
return text === key ? tool.description : text;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
/**
|
|
2788
|
+
* The install control of a tool row by its status (bug 259): "install" shows the Install button (not installed, or the
|
|
2789
|
+
* last install failed), "installing" hides it while the install runs, null shows nothing (installed, off, or a status
|
|
2790
|
+
* from a newer gateway).
|
|
2791
|
+
*/
|
|
2792
|
+
function formalInstallAction(status) {
|
|
2793
|
+
if (status === "not_installed" || status === "failed") return "install";
|
|
2794
|
+
if (status === "installing") return "installing";
|
|
2795
|
+
return null;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/**
|
|
2799
|
+
* One row per tool, shaped like the security check rows: "<label> · <status>", the info icon with the full description,
|
|
2800
|
+
* the Install button when the status asks for it, and the switch ("use this tool in checks once it is installed"; it
|
|
2801
|
+
* never installs). The summary and, after a failed install, its reason sit under the row. Every switch and Install
|
|
2802
|
+
* button is disabled while an install runs (state.busy).
|
|
2803
|
+
*/
|
|
2804
|
+
function renderFormal() {
|
|
2805
|
+
const s = formal.state;
|
|
2806
|
+
const busy = s?.busy === true;
|
|
2807
|
+
const list = $("formal-tools");
|
|
2808
|
+
list.replaceChildren(...(s?.tools ?? []).map((tool) => {
|
|
2809
|
+
const item = make("li", "formal-tool");
|
|
2810
|
+
const row = make("div", "switch-row");
|
|
2811
|
+
const label = make("span", "switch-label", `${tool.label} · ${formalStatusText(tool.status)}`);
|
|
2812
|
+
label.id = `formal-tool-${tool.id}-label`;
|
|
2813
|
+
const summary = make("p", "update-note switch-summary", formalToolSummary(tool));
|
|
2814
|
+
summary.id = `formal-tool-${tool.id}-summary`;
|
|
2815
|
+
row.append(label, infoTipButton(formalToolDetail(tool)));
|
|
2816
|
+
const action = formalInstallAction(tool.status);
|
|
2817
|
+
if (action === "install") {
|
|
2818
|
+
const install = make("button", "text-btn formal-install", t("settings.formal.install"));
|
|
2819
|
+
install.type = "button";
|
|
2820
|
+
install.setAttribute("aria-describedby", label.id);
|
|
2821
|
+
install.disabled = busy;
|
|
2822
|
+
install.onclick = () => installFormal(tool.id);
|
|
2823
|
+
row.append(install);
|
|
2824
|
+
}
|
|
2825
|
+
if (action === "installing") item.setAttribute("aria-busy", "true");
|
|
2826
|
+
const button = make("button", "switch");
|
|
2827
|
+
button.type = "button";
|
|
2828
|
+
button.setAttribute("role", "switch");
|
|
2829
|
+
button.setAttribute("aria-labelledby", label.id);
|
|
2830
|
+
button.setAttribute("aria-describedby", summary.id);
|
|
2831
|
+
renderSwitch(button, tool.enabled, busy);
|
|
2832
|
+
button.onclick = () => changeFormal(`${FORMAL_PATH}/tools/${encodeURIComponent(tool.id)}`, !tool.enabled);
|
|
2833
|
+
row.append(button);
|
|
2834
|
+
item.append(row, summary);
|
|
2835
|
+
if (tool.error) item.append(make("p", "update-note error", t("settings.formal.tool.error", { error: tool.error })));
|
|
2836
|
+
return item;
|
|
2837
|
+
}));
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Whether a state from the gateway replaces the shown one (bug 259): the answer to an Install request can arrive after the
|
|
2842
|
+
* events of an install that already ended, so a state with a lower revision than the shown one is dropped. A state without
|
|
2843
|
+
* a revision (an older gateway) always replaces it.
|
|
2844
|
+
*/
|
|
2845
|
+
function formalStateNewer(current, body) {
|
|
2846
|
+
if (!body || !Array.isArray(body.tools)) return false;
|
|
2847
|
+
if (typeof body.revision !== "number" || typeof current?.revision !== "number") return true;
|
|
2848
|
+
return body.revision >= current.revision;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
function applyFormal(body) {
|
|
2852
|
+
if (formalStateNewer(formal.state, body)) formal.state = body;
|
|
2853
|
+
renderFormal();
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
async function readFormal() {
|
|
2857
|
+
try { const res = await fetch(FORMAL_PATH, { cache: "no-store" }); if (res.ok) applyFormal(await res.json()); } catch { /* Offline: the reconnect reads it again. */ }
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
async function changeFormal(path, enabled) {
|
|
2861
|
+
if (formal.busy) return;
|
|
2862
|
+
formal.busy = true;
|
|
2863
|
+
try {
|
|
2864
|
+
const res = await fetch(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
|
|
2865
|
+
const body = await res.json().catch(() => ({}));
|
|
2866
|
+
if (!res.ok) { applyFormal(body.state); throw new Error(body.error ?? String(res.status)); }
|
|
2867
|
+
applyFormal(body);
|
|
2868
|
+
} catch (err) {
|
|
2869
|
+
systemLine(t("settings.formal.saveFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2870
|
+
} finally {
|
|
2871
|
+
formal.busy = false;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* The Install button of one tool: POST its install path (no body). The answer is the state with that tool "installing";
|
|
2877
|
+
* the progress and the result arrive as formal-verification.updated. A refusal (409 busy, 404 unknown tool) applies the
|
|
2878
|
+
* returned state and is reported in the chat.
|
|
2879
|
+
*/
|
|
2880
|
+
async function installFormal(id) {
|
|
2881
|
+
if (formal.busy) return;
|
|
2882
|
+
formal.busy = true;
|
|
2883
|
+
try {
|
|
2884
|
+
const res = await fetch(`${FORMAL_PATH}/tools/${encodeURIComponent(id)}/install`, { method: "POST" });
|
|
2885
|
+
const body = await res.json().catch(() => ({}));
|
|
2886
|
+
if (!res.ok) { applyFormal(body.state); throw new Error(body.error ?? String(res.status)); }
|
|
2887
|
+
applyFormal(body);
|
|
2888
|
+
} catch (err) {
|
|
2889
|
+
systemLine(t("settings.formal.installFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2890
|
+
} finally {
|
|
2891
|
+
formal.busy = false;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
renderFormal();
|
|
2896
|
+
|
|
2566
2897
|
/** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
|
|
2567
2898
|
function restoreDraft(conv, text) {
|
|
2568
2899
|
if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
|
|
@@ -2592,7 +2923,7 @@ function securityContents(id, surface) {
|
|
|
2592
2923
|
const entry = securityInbox.entries.get(id);
|
|
2593
2924
|
const view = securityInbox.view(id);
|
|
2594
2925
|
const held = entry.held;
|
|
2595
|
-
const nodes = [make("div", "security-title", held.source === "answer" ? "
|
|
2926
|
+
const nodes = [make("div", "security-title", t(held.source === "answer" ? "security.title.answer" : "security.title.message"))];
|
|
2596
2927
|
const list = make("ul", "security-list");
|
|
2597
2928
|
for (const occurrence of held.occurrences ?? []) {
|
|
2598
2929
|
const item = make("li");
|
|
@@ -2601,16 +2932,10 @@ function securityContents(id, surface) {
|
|
|
2601
2932
|
}
|
|
2602
2933
|
nodes.push(list);
|
|
2603
2934
|
if (held.status === "pending") nodes.push(make("p", "security-question",
|
|
2604
|
-
"
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
const
|
|
2608
|
-
const remembered = view.remembered === null ? "값 기억 결과 미확인" : view.remembered ? "정확한 값 기억됨" : "값 기억 안 됨";
|
|
2609
|
-
const reasons = { allowlist_persist_failed: "허용 값을 저장하지 못했습니다", state_persist_failed: "처리 기록을 확인하지 못했습니다",
|
|
2610
|
-
payload_lost: "보류 원문이 없어 보내지 못했습니다", dispatch_failed: "전송을 시작하지 못했습니다",
|
|
2611
|
-
dispatch_unknown: "전달 여부를 확인하지 못했습니다", original_decision_conflict: "원래 질문이 변경되어 이번 답을 보내지 않았습니다" };
|
|
2612
|
-
const reason = held.errorCode ? reasons[held.errorCode] ?? "상태를 다시 확인하세요" : "";
|
|
2613
|
-
const status = make("p", `security-status${view.status === "sent" ? " confirmed" : ""}`, `${labels[view.status]} · ${remembered}${reason ? ` · ${reason}` : ""}`);
|
|
2935
|
+
t("security.question") + t(held.source === "answer" ? "security.question.answer" : "security.question.message")));
|
|
2936
|
+
const remembered = t(view.remembered === null ? "security.remembered.unknown" : view.remembered ? "security.remembered.yes" : "security.remembered.no");
|
|
2937
|
+
const reason = held.errorCode ? t(SECURITY_REASONS.includes(held.errorCode) ? `security.reason.${held.errorCode}` : "security.reason.unknown") : "";
|
|
2938
|
+
const status = make("p", `security-status${view.status === "sent" ? " confirmed" : ""}`, `${t(`security.status.${view.status}`)} · ${remembered}${reason ? ` · ${reason}` : ""}`);
|
|
2614
2939
|
status.setAttribute("role", "status");
|
|
2615
2940
|
nodes.push(status);
|
|
2616
2941
|
const actions = make("div", "security-actions inbox-actions");
|
|
@@ -2619,25 +2944,25 @@ function securityContents(id, surface) {
|
|
|
2619
2944
|
b.type = "button"; b.disabled = !enabled; b.onclick = action; actions.append(b);
|
|
2620
2945
|
};
|
|
2621
2946
|
if (held.status === "pending" || view.status === "processing") {
|
|
2622
|
-
button("
|
|
2623
|
-
button("
|
|
2947
|
+
button(t("security.approve"), "security-approve", view.approve, () => void securityInbox.decide(id, true));
|
|
2948
|
+
button(t("security.cancel"), "security-cancel", view.cancel, () => void securityInbox.decide(id, false));
|
|
2624
2949
|
}
|
|
2625
2950
|
if (view.check || (held.status === "pending" && !completeDetail(held))) {
|
|
2626
|
-
button("
|
|
2951
|
+
button(t("security.refresh"), "security-refresh", inbox.online && !entry.busy, () => void securityInbox.detail(id));
|
|
2627
2952
|
}
|
|
2628
2953
|
if (held.status === "pending" && !view.approve && !entry.busy && !entry.uncertain) {
|
|
2629
|
-
nodes.push(make("p", "inbox-note", "
|
|
2954
|
+
nodes.push(make("p", "inbox-note", t("security.needsDetail")));
|
|
2630
2955
|
}
|
|
2631
2956
|
if (surface === "inbox" && (view.hide || view.cancel)) {
|
|
2632
|
-
button("
|
|
2957
|
+
button(t("common.delete"), "inbox-dismiss-open", true, () => {
|
|
2633
2958
|
if (view.hide) { securityInbox.hide(id); renderInboxList(); return; }
|
|
2634
2959
|
inbox.dismissing.add(id);
|
|
2635
2960
|
renderInboxItem(decisionById(inbox.state, id)).querySelector(".inbox-dismiss-keep")?.focus();
|
|
2636
2961
|
});
|
|
2637
2962
|
if (inbox.dismissing.has(id)) {
|
|
2638
2963
|
const box = make("div", "inbox-dismiss mode-cancel");
|
|
2639
|
-
box.append(make("p", "inbox-dismiss-text", "
|
|
2640
|
-
const confirm = make("button", "text-btn danger security-dismiss-confirm", "
|
|
2964
|
+
box.append(make("p", "inbox-dismiss-text", t("security.dismissText")));
|
|
2965
|
+
const confirm = make("button", "text-btn danger security-dismiss-confirm", t("security.dismissConfirm"));
|
|
2641
2966
|
confirm.disabled = !view.cancel;
|
|
2642
2967
|
confirm.onclick = async () => {
|
|
2643
2968
|
await securityInbox.decide(id, false);
|
|
@@ -2645,7 +2970,7 @@ function securityContents(id, surface) {
|
|
|
2645
2970
|
inbox.dismissing.delete(id);
|
|
2646
2971
|
renderInboxList();
|
|
2647
2972
|
};
|
|
2648
|
-
const keep = make("button", "text-btn inbox-dismiss-keep", "
|
|
2973
|
+
const keep = make("button", "text-btn inbox-dismiss-keep", t("common.keep"));
|
|
2649
2974
|
keep.onclick = () => { inbox.dismissing.delete(id); renderInboxItem(decisionById(inbox.state, id)); };
|
|
2650
2975
|
box.append(confirm, keep); nodes.push(box);
|
|
2651
2976
|
}
|
|
@@ -2654,6 +2979,8 @@ function securityContents(id, surface) {
|
|
|
2654
2979
|
return nodes;
|
|
2655
2980
|
}
|
|
2656
2981
|
|
|
2982
|
+
const SECURITY_REASONS = ["allowlist_persist_failed", "state_persist_failed", "payload_lost", "dispatch_failed", "dispatch_unknown", "original_decision_conflict"];
|
|
2983
|
+
|
|
2657
2984
|
function renderSecuritySurfaces() {
|
|
2658
2985
|
for (const [id, entry] of securityInbox.entries) {
|
|
2659
2986
|
const held = entry.held;
|
|
@@ -2670,7 +2997,7 @@ function renderSecuritySurfaces() {
|
|
|
2670
2997
|
card = make("div", "msg system security-card");
|
|
2671
2998
|
card.dataset.heldId = id;
|
|
2672
2999
|
card.setAttribute("role", "group");
|
|
2673
|
-
card.setAttribute("aria-label", "
|
|
3000
|
+
card.setAttribute("aria-label", t("security.cardLabel"));
|
|
2674
3001
|
heldCards.set(id, card);
|
|
2675
3002
|
}
|
|
2676
3003
|
card.setAttribute("aria-busy", String(entry.busy));
|
|
@@ -2717,19 +3044,32 @@ function renderAllowedValues() {
|
|
|
2717
3044
|
const model = allowedValues.state;
|
|
2718
3045
|
const list = $("security-allowed-list");
|
|
2719
3046
|
list.replaceChildren();
|
|
2720
|
-
$("security-allowed-status").textContent = !model.online ? "
|
|
3047
|
+
$("security-allowed-status").textContent = !model.online ? t("common.disconnected") : model.busy ? t("common.processing") : model.loading ? t("common.loading")
|
|
3048
|
+
: model.error || model.notice || (model.ready && model.values.length === 0 ? t("settings.security.allowed.empty") : "");
|
|
2721
3049
|
const locked = !model.online || model.loading || model.busy || !model.ready || Boolean(model.error);
|
|
2722
3050
|
$("security-allowed-refresh").disabled = !model.online || model.loading || model.busy;
|
|
2723
3051
|
$("security-allowed-clear").disabled = locked || model.values.length === 0;
|
|
3052
|
+
// Improvement 232 (todo 21): each value's repository toggle judges against the baseline block's automatic items.
|
|
3053
|
+
const auto = securityBaseline.state.auto;
|
|
2724
3054
|
for (const value of model.values) {
|
|
2725
3055
|
const row = make("li", "security-allowed-item");
|
|
2726
3056
|
row.dataset.allowedId = value.id;
|
|
2727
|
-
const client = value.approvedBy.client === "android" ? "
|
|
3057
|
+
const client = t(value.approvedBy.client === "android" ? "security.client.android" : "security.client.web");
|
|
2728
3058
|
row.append(make("code", "security-value", value.value), make("div", "inbox-note", `${value.label} · ${dateTimeLabel(value.approvedAt)} · ${client}`));
|
|
2729
|
-
const
|
|
3059
|
+
const repo = repoAllowed(value);
|
|
3060
|
+
const blocked = repoScopeBlock(value.value, auto);
|
|
3061
|
+
if (blocked) row.append(make("div", "inbox-note", blocked));
|
|
3062
|
+
const scope = make("button", "text-btn security-scope", t(repo ? "settings.security.allowed.repoOn" : "settings.security.allowed.repoOff"));
|
|
3063
|
+
scope.setAttribute("aria-pressed", String(repo));
|
|
3064
|
+
scope.classList.toggle("on", repo);
|
|
3065
|
+
scope.disabled = locked || blocked !== "";
|
|
3066
|
+
scope.onclick = async () => { allowedRemoval = undefined; await allowedValues.setScope(value.id, !repo); renderAllowedValues(); };
|
|
3067
|
+
const remove = make("button", "text-btn", t("common.remove"));
|
|
2730
3068
|
remove.disabled = locked;
|
|
2731
3069
|
remove.onclick = () => { allowedRemoval = value.id; renderAllowedValues(); $("security-allowed-keep").focus(); };
|
|
2732
|
-
|
|
3070
|
+
const actions = make("div", "update-buttons");
|
|
3071
|
+
actions.append(scope, remove);
|
|
3072
|
+
row.append(actions); list.append(row);
|
|
2733
3073
|
}
|
|
2734
3074
|
const confirm = $("security-allowed-confirm");
|
|
2735
3075
|
confirm.hidden = allowedRemoval === undefined;
|
|
@@ -2747,6 +3087,67 @@ $("security-allowed-remove").onclick = async () => {
|
|
|
2747
3087
|
renderAllowedValues();
|
|
2748
3088
|
};
|
|
2749
3089
|
|
|
3090
|
+
// ---- Improvement 232 (todo 20): the repository baseline terms, read-only automatic items and the exported file's state ----
|
|
3091
|
+
const securityBaseline = createSecurityBaseline(fetch, onSecurityBaselineChanged);
|
|
3092
|
+
let baselineRemoval = undefined;
|
|
3093
|
+
|
|
3094
|
+
function onSecurityBaselineChanged() {
|
|
3095
|
+
renderSecurityBaseline();
|
|
3096
|
+
// The remembered values' repository toggles judge against this block's automatic items (todo 21).
|
|
3097
|
+
renderAllowedValues();
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
function renderSecurityBaseline() {
|
|
3101
|
+
const model = securityBaseline.state;
|
|
3102
|
+
const list = $("security-baseline-list");
|
|
3103
|
+
list.replaceChildren();
|
|
3104
|
+
$("security-baseline-status").textContent = !model.online ? t("common.disconnected") : model.busy ? t("common.processing") : model.loading ? t("common.loading")
|
|
3105
|
+
: model.error || model.notice || (model.ready && model.terms.length === 0 ? t("settings.security.baseline.empty") : "");
|
|
3106
|
+
const locked = !model.online || model.loading || model.busy || !model.ready || Boolean(model.error);
|
|
3107
|
+
$("security-baseline-refresh").disabled = !model.online || model.loading || model.busy;
|
|
3108
|
+
$("security-baseline-add").disabled = locked;
|
|
3109
|
+
const exported = $("security-baseline-export");
|
|
3110
|
+
exported.textContent = !model.ready ? "" : t("settings.security.baseline.file", { revision: model.revision, state: model.exportError ? t("settings.security.baseline.exportFailed", { error: model.exportError })
|
|
3111
|
+
: model.exportedAt ? t("settings.security.baseline.exportedAt", { time: dateTimeLabel(model.exportedAt) }) : t("settings.security.baseline.notExported") });
|
|
3112
|
+
exported.classList.toggle("error", model.ready && model.exportError !== null);
|
|
3113
|
+
for (const [label, value] of model.ready && model.auto ? [[t("settings.security.baseline.home"), model.auto.home], [t("settings.security.baseline.user"), model.auto.user]] : []) {
|
|
3114
|
+
const row = make("li", "security-allowed-item security-baseline-auto");
|
|
3115
|
+
row.append(make("code", "security-value", value), make("div", "inbox-note", t("settings.security.baseline.autoNote", { label })));
|
|
3116
|
+
list.append(row);
|
|
3117
|
+
}
|
|
3118
|
+
for (const term of model.terms) {
|
|
3119
|
+
const row = make("li", "security-allowed-item");
|
|
3120
|
+
row.dataset.termId = term.id;
|
|
3121
|
+
row.append(make("code", "security-value", term.value), make("div", "inbox-note", t("settings.security.baseline.added", { time: dateTimeLabel(term.addedAt) })));
|
|
3122
|
+
const remove = make("button", "text-btn", t("common.delete"));
|
|
3123
|
+
remove.disabled = locked;
|
|
3124
|
+
remove.onclick = () => { baselineRemoval = term.id; renderSecurityBaseline(); $("security-baseline-keep").focus(); };
|
|
3125
|
+
row.append(remove); list.append(row);
|
|
3126
|
+
}
|
|
3127
|
+
$("security-baseline-confirm").hidden = baselineRemoval === undefined;
|
|
3128
|
+
$("security-baseline-remove").disabled = locked;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/** The typed value leaves the input only when the server applied it; a rejection keeps it there for correction. */
|
|
3132
|
+
async function submitSecurityBaseline(event) {
|
|
3133
|
+
event.preventDefault();
|
|
3134
|
+
const input = $("security-baseline-input");
|
|
3135
|
+
if (await securityBaseline.add(input.value)) input.value = "";
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
async function removeSecurityBaselineTerm() {
|
|
3139
|
+
if (baselineRemoval === undefined) return;
|
|
3140
|
+
const id = baselineRemoval;
|
|
3141
|
+
baselineRemoval = undefined;
|
|
3142
|
+
await securityBaseline.remove(id);
|
|
3143
|
+
renderSecurityBaseline();
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
$("security-baseline-form").onsubmit = submitSecurityBaseline;
|
|
3147
|
+
$("security-baseline-refresh").onclick = () => { baselineRemoval = undefined; void securityBaseline.refresh(); };
|
|
3148
|
+
$("security-baseline-keep").onclick = () => { baselineRemoval = undefined; renderSecurityBaseline(); };
|
|
3149
|
+
$("security-baseline-remove").onclick = removeSecurityBaselineTerm;
|
|
3150
|
+
|
|
2750
3151
|
securityButton.onclick = () => setSecurityEnabled(!security.enabled);
|
|
2751
3152
|
renderSecurityToggle();
|
|
2752
3153
|
|
|
@@ -2778,7 +3179,7 @@ async function startVoiceInstall() {
|
|
|
2778
3179
|
if (!res.ok) throw new Error(body.error ?? String(res.status));
|
|
2779
3180
|
voice.install = body;
|
|
2780
3181
|
} catch (err) {
|
|
2781
|
-
systemLine(
|
|
3182
|
+
systemLine(t("voice.installStartFailed", { error: err instanceof Error ? err.message : err }), true);
|
|
2782
3183
|
}
|
|
2783
3184
|
renderVoiceInstall();
|
|
2784
3185
|
}
|
|
@@ -2816,7 +3217,6 @@ const inbox = {
|
|
|
2816
3217
|
title: $("inbox-title"),
|
|
2817
3218
|
};
|
|
2818
3219
|
let decisionGeneration = 0;
|
|
2819
|
-
const PRIMARY_LABELS = { live: "답변 보내기", resume: "답변하고 이어가기", permission: "새 승인 요청", recheck: "현재 상황 확인" };
|
|
2820
3220
|
|
|
2821
3221
|
function make(tag, className, text) {
|
|
2822
3222
|
const node = document.createElement(tag);
|
|
@@ -2829,14 +3229,14 @@ function dateTimeLabel(value) {
|
|
|
2829
3229
|
const d = new Date(value);
|
|
2830
3230
|
if (Number.isNaN(d.getTime())) return "";
|
|
2831
3231
|
if (d.toDateString() === new Date().toDateString()) return timeLabel(d.toISOString());
|
|
2832
|
-
return d.toLocaleString(
|
|
3232
|
+
return d.toLocaleString(uiLocale(), { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
2833
3233
|
}
|
|
2834
3234
|
|
|
2835
3235
|
function describeAnswer(a) {
|
|
2836
3236
|
if (!a || typeof a !== "object") return "";
|
|
2837
|
-
if (a.cancelled === true) return "
|
|
2838
|
-
if (typeof a.value === "string") return
|
|
2839
|
-
if (typeof a.confirmed === "boolean") return a.confirmed ? "
|
|
3237
|
+
if (a.cancelled === true) return t("decision.later");
|
|
3238
|
+
if (typeof a.value === "string") return optionLabel(a.value);
|
|
3239
|
+
if (typeof a.confirmed === "boolean") return t(a.confirmed ? "common.yes" : "common.no");
|
|
2840
3240
|
if (a.answers && typeof a.answers === "object") {
|
|
2841
3241
|
const parts = Object.values(a.answers).map((x) => [...(x.selected ?? []), x.text].filter(Boolean).join(", "));
|
|
2842
3242
|
if (a.comment) parts.push(a.comment);
|
|
@@ -2869,12 +3269,12 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
|
2869
3269
|
const body = await res.json().catch(() => null);
|
|
2870
3270
|
if (!res.ok) {
|
|
2871
3271
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2872
|
-
for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note:
|
|
3272
|
+
for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: t("decision.detailFailedStatus", { status: res.status }), retry });
|
|
2873
3273
|
return;
|
|
2874
3274
|
}
|
|
2875
3275
|
const result = finishDetailLoad(inbox.state, id, generation, body);
|
|
2876
3276
|
if (!result.accepted) {
|
|
2877
|
-
if (result.reason === "mismatch") for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: "
|
|
3277
|
+
if (result.reason === "mismatch") for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: t("decision.idMismatch"), retry });
|
|
2878
3278
|
return;
|
|
2879
3279
|
}
|
|
2880
3280
|
const decision = decisionById(inbox.state, id);
|
|
@@ -2883,7 +3283,7 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
|
2883
3283
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2884
3284
|
for (const shown of detailCards(id, card)) setCardState(shown, {
|
|
2885
3285
|
disabled: true,
|
|
2886
|
-
note:
|
|
3286
|
+
note: t("decision.detailFailed", { error: err instanceof Error ? err.message : String(err) }),
|
|
2887
3287
|
retry,
|
|
2888
3288
|
});
|
|
2889
3289
|
}
|
|
@@ -2926,7 +3326,7 @@ async function syncInbox({ reset = false, cursor = null } = {}) {
|
|
|
2926
3326
|
// could arrive (for example during a disconnect), so the card asks for the current copy.
|
|
2927
3327
|
if (isUnresolved(decision) && !inbox.state.items.has(explicitId)) void loadDecisionDetail(explicitId, card, { refresh: true });
|
|
2928
3328
|
} else {
|
|
2929
|
-
if (!card.classList.contains("resolved")) setCardState(card, { disabled: true, note: "
|
|
3329
|
+
if (!card.classList.contains("resolved")) setCardState(card, { disabled: true, note: t("common.checking") });
|
|
2930
3330
|
void loadDecisionDetail(explicitId, card);
|
|
2931
3331
|
}
|
|
2932
3332
|
continue;
|
|
@@ -2938,7 +3338,7 @@ async function syncInbox({ reset = false, cursor = null } = {}) {
|
|
|
2938
3338
|
}
|
|
2939
3339
|
if (card.classList.contains("resolved") || card.dataset.decisionRevision !== undefined) continue;
|
|
2940
3340
|
const busy = inbox.state.inflight.has(submitKey(null, request.actionId, inbox.state));
|
|
2941
|
-
setCardState(card, { disabled: busy, note: busy ? "
|
|
3341
|
+
setCardState(card, { disabled: busy, note: busy ? t("common.sending") : null });
|
|
2942
3342
|
}
|
|
2943
3343
|
}
|
|
2944
3344
|
} catch (err) {
|
|
@@ -2959,8 +3359,8 @@ function renderInboxBadge() {
|
|
|
2959
3359
|
const n = badgeCount(c);
|
|
2960
3360
|
inbox.badge.hidden = n === 0;
|
|
2961
3361
|
inbox.badge.textContent = String(n);
|
|
2962
|
-
inbox.toggle.setAttribute("aria-label", n ?
|
|
2963
|
-
inbox.summary.textContent = c ?
|
|
3362
|
+
inbox.toggle.setAttribute("aria-label", n ? t("inbox.toggle.label", { count: n }) : t("inbox.title"));
|
|
3363
|
+
inbox.summary.textContent = c ? t("inbox.summary", { pending: c.pending + c.needsReview, submitted: c.submitted }) : "";
|
|
2964
3364
|
renderUpdateGate();
|
|
2965
3365
|
}
|
|
2966
3366
|
|
|
@@ -2968,11 +3368,11 @@ function renderInboxStatus() {
|
|
|
2968
3368
|
const s = inbox.status;
|
|
2969
3369
|
s.classList.toggle("error", Boolean(inbox.error) && inbox.online);
|
|
2970
3370
|
s.replaceChildren();
|
|
2971
|
-
if (!inbox.online) s.append("
|
|
2972
|
-
else if (inbox.loading) s.append("
|
|
3371
|
+
if (!inbox.online) s.append(t("inbox.offline"));
|
|
3372
|
+
else if (inbox.loading) s.append(t("common.loading"));
|
|
2973
3373
|
else if (inbox.error) {
|
|
2974
|
-
s.append(
|
|
2975
|
-
const retry = make("button", "text-btn", "
|
|
3374
|
+
s.append(t("inbox.loadFailed", { error: inbox.error }));
|
|
3375
|
+
const retry = make("button", "text-btn", t("common.retry"));
|
|
2976
3376
|
retry.type = "button";
|
|
2977
3377
|
retry.onclick = () => void syncInbox({ reset: !inbox.state.loaded });
|
|
2978
3378
|
s.append(retry);
|
|
@@ -3010,7 +3410,7 @@ function renderInboxList() {
|
|
|
3010
3410
|
empty = make("li", "inbox-empty");
|
|
3011
3411
|
inbox.list.append(empty);
|
|
3012
3412
|
}
|
|
3013
|
-
empty.textContent = inbox.state.loaded ? "
|
|
3413
|
+
empty.textContent = inbox.state.loaded ? t("inbox.empty") : "";
|
|
3014
3414
|
} else empty?.remove();
|
|
3015
3415
|
ordered.forEach((decision, index) => {
|
|
3016
3416
|
let node = inbox.nodes.get(decision.id);
|
|
@@ -3055,7 +3455,7 @@ function renderAnswerFields(decision, form, locked, onEdit) {
|
|
|
3055
3455
|
if (decision.method === "select") {
|
|
3056
3456
|
const opts = make("div", "approval-options");
|
|
3057
3457
|
for (const opt of Array.isArray(payload.options) ? payload.options : []) {
|
|
3058
|
-
const b = button(
|
|
3458
|
+
const b = button(optionLabel(opt), decision.permission ? (OPTION_STYLE[opt] ?? "neutral") : "neutral", () => {
|
|
3059
3459
|
form.value = form.value === opt ? undefined : opt;
|
|
3060
3460
|
pickOptions(opts, new Set(form.value === undefined ? [] : [form.value]));
|
|
3061
3461
|
onEdit();
|
|
@@ -3068,7 +3468,7 @@ function renderAnswerFields(decision, form, locked, onEdit) {
|
|
|
3068
3468
|
nodes.push(opts);
|
|
3069
3469
|
} else if (decision.method === "confirm") {
|
|
3070
3470
|
const opts = make("div", "approval-options");
|
|
3071
|
-
for (const [label, value, cls] of [["
|
|
3471
|
+
for (const [label, value, cls] of [[t("common.yes"), true, "ok"], [t("common.no"), false, "danger"]]) {
|
|
3072
3472
|
const b = button(label, cls, () => {
|
|
3073
3473
|
form.confirmed = form.confirmed === value ? undefined : value;
|
|
3074
3474
|
pickOptions(opts, new Set(form.confirmed === undefined ? [] : [String(form.confirmed)]));
|
|
@@ -3085,7 +3485,7 @@ function renderAnswerFields(decision, form, locked, onEdit) {
|
|
|
3085
3485
|
box.rows = decision.method === "editor" ? 6 : 2;
|
|
3086
3486
|
box.value = form.value ?? "";
|
|
3087
3487
|
box.disabled = locked;
|
|
3088
|
-
box.setAttribute("aria-label", "
|
|
3488
|
+
box.setAttribute("aria-label", t("approval.answerInput"));
|
|
3089
3489
|
box.oninput = () => {
|
|
3090
3490
|
form.value = box.value;
|
|
3091
3491
|
onEdit();
|
|
@@ -3116,10 +3516,10 @@ function renderAnswerFields(decision, form, locked, onEdit) {
|
|
|
3116
3516
|
opts.append(b);
|
|
3117
3517
|
}
|
|
3118
3518
|
const other = make("input", "approval-input");
|
|
3119
|
-
other.placeholder = "
|
|
3519
|
+
other.placeholder = t("approval.otherPlaceholder");
|
|
3120
3520
|
other.value = given.text;
|
|
3121
3521
|
other.disabled = locked;
|
|
3122
|
-
other.setAttribute("aria-label",
|
|
3522
|
+
other.setAttribute("aria-label", t("approval.otherFor", { question: q.question }));
|
|
3123
3523
|
other.oninput = () => {
|
|
3124
3524
|
given.text = other.value;
|
|
3125
3525
|
onEdit();
|
|
@@ -3129,10 +3529,10 @@ function renderAnswerFields(decision, form, locked, onEdit) {
|
|
|
3129
3529
|
}
|
|
3130
3530
|
const comment = make("textarea", "approval-input");
|
|
3131
3531
|
comment.rows = 2;
|
|
3132
|
-
comment.placeholder = "
|
|
3532
|
+
comment.placeholder = t("approval.commentPlaceholder");
|
|
3133
3533
|
comment.value = form.comment;
|
|
3134
3534
|
comment.disabled = locked;
|
|
3135
|
-
comment.setAttribute("aria-label", "
|
|
3535
|
+
comment.setAttribute("aria-label", t("approval.comment"));
|
|
3136
3536
|
comment.oninput = () => {
|
|
3137
3537
|
form.comment = comment.value;
|
|
3138
3538
|
onEdit();
|
|
@@ -3147,13 +3547,13 @@ function renderSavedChoices(decision) {
|
|
|
3147
3547
|
const saved = originalChoices(decision);
|
|
3148
3548
|
if (!saved.length) return null;
|
|
3149
3549
|
const box = make("div", "inbox-choices");
|
|
3150
|
-
box.append(make("div", "inbox-choices-label", "
|
|
3550
|
+
box.append(make("div", "inbox-choices-label", t("decision.originalChoices")));
|
|
3151
3551
|
for (const group of saved) {
|
|
3152
3552
|
const block = make("div", "approval-question");
|
|
3153
3553
|
if (group.title) block.append(make("div", "approval-qtitle", group.title));
|
|
3154
3554
|
const list = make("ul", "inbox-choice-list");
|
|
3155
3555
|
for (const o of group.options) {
|
|
3156
|
-
const item = make("li", null, decision.method === "select" ? (
|
|
3556
|
+
const item = make("li", null, decision.method === "select" ? optionLabel(o.label) : o.label);
|
|
3157
3557
|
if (o.description) item.append(make("small", null, o.description));
|
|
3158
3558
|
list.append(item);
|
|
3159
3559
|
}
|
|
@@ -3167,11 +3567,11 @@ function renderSavedChoices(decision) {
|
|
|
3167
3567
|
function renderReference(decision) {
|
|
3168
3568
|
const lost = reference(inbox.state, decision);
|
|
3169
3569
|
if (lost) {
|
|
3170
|
-
return make("div", "inbox-reference",
|
|
3570
|
+
return make("div", "inbox-reference", t("decision.lostAnswer", { answer: describeAnswer(lost.response) || t("common.none") }));
|
|
3171
3571
|
}
|
|
3172
3572
|
if (!isReviewRequired(inbox.state, decision.id)) return null;
|
|
3173
3573
|
const draft = inbox.state.drafts.get(decision.id);
|
|
3174
|
-
return draft ? make("div", "inbox-reference",
|
|
3574
|
+
return draft ? make("div", "inbox-reference", t("decision.unconfirmedAnswer", { answer: describeAnswer(draft.response) || t("common.none") })) : null;
|
|
3175
3575
|
}
|
|
3176
3576
|
|
|
3177
3577
|
/**
|
|
@@ -3187,24 +3587,24 @@ function renderDecisionActions(decision, p, { locked, busy, review, surface }) {
|
|
|
3187
3587
|
b.onclick = onClick;
|
|
3188
3588
|
return b;
|
|
3189
3589
|
};
|
|
3190
|
-
if (review) actions.append(button("
|
|
3590
|
+
if (review) actions.append(button(t("decision.reviewLatest"), "ok", () => reviewLatest(decision), !busy));
|
|
3191
3591
|
else {
|
|
3192
3592
|
if (p.mode) {
|
|
3193
3593
|
// The retry label appears once an earlier attempt is uncertain, never while that attempt is still in flight.
|
|
3194
|
-
const label = !busy && inbox.state.drafts.has(decision.id) ? "
|
|
3594
|
+
const label = !busy && inbox.state.drafts.has(decision.id) ? t("decision.resend") : t(`decision.primary.${p.kind}`);
|
|
3195
3595
|
actions.append(button(label, "ok", () => void submitDecisionForm(decision, p.mode)));
|
|
3196
3596
|
}
|
|
3197
|
-
if (p.kind === "live" && surface === "card") actions.append(button("
|
|
3198
|
-
if (p.recheck) actions.append(button(
|
|
3597
|
+
if (p.kind === "live" && surface === "card") actions.append(button(t("common.cancel"), "neutral", () => void submitDecision(decision.id, "answer", { cancelled: true })));
|
|
3598
|
+
if (p.recheck) actions.append(button(t("decision.primary.recheck"), "neutral", () => void submitDecisionForm(decision, "recheck")));
|
|
3199
3599
|
}
|
|
3200
|
-
if (busy && surface === "inbox") actions.append(make("span", "inbox-note", "
|
|
3600
|
+
if (busy && surface === "inbox") actions.append(make("span", "inbox-note", t("common.sending")));
|
|
3201
3601
|
if (surface === "inbox" && canDismiss(decision) && !inbox.dismissing.has(decision.id)) {
|
|
3202
|
-
const remove = button("
|
|
3203
|
-
remove.setAttribute("aria-label", "
|
|
3602
|
+
const remove = button(t("common.delete"), "neutral inbox-dismiss-open", () => openDismiss(decision.id), !busy && inbox.online && !inbox.dismissBusy.has(decision.id));
|
|
3603
|
+
remove.setAttribute("aria-label", t("inbox.deleteLabel"));
|
|
3204
3604
|
actions.append(remove);
|
|
3205
3605
|
}
|
|
3206
3606
|
if (surface === "inbox") {
|
|
3207
|
-
const original = make("button", "link-btn", "
|
|
3607
|
+
const original = make("button", "link-btn", t("inbox.original"));
|
|
3208
3608
|
original.type = "button";
|
|
3209
3609
|
original.onclick = () => showOriginal(decision);
|
|
3210
3610
|
actions.append(original);
|
|
@@ -3231,10 +3631,10 @@ function renderSourceContext(payload) {
|
|
|
3231
3631
|
const refs = sourceContext(payload);
|
|
3232
3632
|
if (!refs.length) return null;
|
|
3233
3633
|
const box = make("details", "inbox-context");
|
|
3234
|
-
box.append(make("summary", null,
|
|
3634
|
+
box.append(make("summary", null, t("inbox.context", { count: refs.length })));
|
|
3235
3635
|
for (const ref of refs) {
|
|
3236
3636
|
const quote = make("blockquote", `inbox-context-${ref.role}`);
|
|
3237
|
-
quote.append(make("div", "inbox-context-role", ref.role === "user" ? "
|
|
3637
|
+
quote.append(make("div", "inbox-context-role", ref.role === "user" ? t("profile.me") : "main"), make("div", "inbox-context-text", ref.text));
|
|
3238
3638
|
box.append(quote);
|
|
3239
3639
|
}
|
|
3240
3640
|
return box;
|
|
@@ -3254,17 +3654,17 @@ function renderInboxItem(decision) {
|
|
|
3254
3654
|
li.setAttribute("aria-busy", String(busy));
|
|
3255
3655
|
const head = make("div", "inbox-item-head");
|
|
3256
3656
|
head.append(
|
|
3257
|
-
make("span", "inbox-work", decision.work ? decision.work.title : decision.ownerConv === "main" ? "
|
|
3657
|
+
make("span", "inbox-work", decision.work ? decision.work.title : decision.ownerConv === "main" ? t("inbox.mainConversation") : decision.ownerConv),
|
|
3258
3658
|
make("span", `inbox-chip chip-${decision.status}`, STATUS_LABELS[decision.status] ?? decision.status),
|
|
3259
3659
|
);
|
|
3260
|
-
const question = make("div", "inbox-question", payload.title || (decision.permission ? "
|
|
3660
|
+
const question = make("div", "inbox-question", payload.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"));
|
|
3261
3661
|
question.id = `inbox-q-${decision.id}`;
|
|
3262
3662
|
li.setAttribute("aria-labelledby", question.id);
|
|
3263
3663
|
li.append(head, question);
|
|
3264
3664
|
if (payload.detail) li.append(make("div", "approval-detail", payload.detail));
|
|
3265
3665
|
const context = renderSourceContext(payload);
|
|
3266
3666
|
if (context) li.append(context);
|
|
3267
|
-
if (decision.answer) li.append(make("div", "inbox-note",
|
|
3667
|
+
if (decision.answer) li.append(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
|
|
3268
3668
|
if (p.form) li.append(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, li)));
|
|
3269
3669
|
else {
|
|
3270
3670
|
// No answer is taken now, but the question and its choices still read here, not only on the original card.
|
|
@@ -3278,10 +3678,10 @@ function renderInboxItem(decision) {
|
|
|
3278
3678
|
if (!value) return;
|
|
3279
3679
|
meta.append(make("dt", null, label), make("dd", null, value));
|
|
3280
3680
|
};
|
|
3281
|
-
row("
|
|
3282
|
-
row("
|
|
3283
|
-
row("
|
|
3284
|
-
row("
|
|
3681
|
+
row(t("inbox.meta.created"), dateTimeLabel(decision.createdAt));
|
|
3682
|
+
row(t("inbox.meta.deadline"), decision.deadlineAt === null ? (decision.bindingState === "live" ? t("inbox.meta.none") : "") : `${dateTimeLabel(decision.deadlineAt)}${decision.bindingState === "expired" ? t("inbox.meta.expired") : ""}`);
|
|
3683
|
+
row(t("inbox.meta.state"), `${bindingLabel(decision)}${decision.required ? t("inbox.meta.required") : ""}`);
|
|
3684
|
+
row(t("inbox.meta.waitReason"), waitReason(decision));
|
|
3285
3685
|
li.append(meta);
|
|
3286
3686
|
const note = inbox.notes.get(decision.id);
|
|
3287
3687
|
const noteEl = make("div", `inbox-note${note?.error ? " error" : ""}`, note?.text ?? "");
|
|
@@ -3318,14 +3718,14 @@ function renderDismissConfirm(decision) {
|
|
|
3318
3718
|
const busy = inbox.dismissBusy.has(decision.id);
|
|
3319
3719
|
const box = make("div", `inbox-dismiss mode-${mode}`);
|
|
3320
3720
|
box.setAttribute("role", "group");
|
|
3321
|
-
box.setAttribute("aria-label", "
|
|
3721
|
+
box.setAttribute("aria-label", t("inbox.dismiss.label"));
|
|
3322
3722
|
box.append(make("p", "inbox-dismiss-text", text.prompt));
|
|
3323
3723
|
const row = make("div", "inbox-actions");
|
|
3324
|
-
const confirm = make("button", "btn danger inbox-dismiss-confirm", busy ? "
|
|
3724
|
+
const confirm = make("button", "btn danger inbox-dismiss-confirm", busy ? t("inbox.dismiss.busy") : text.confirm);
|
|
3325
3725
|
confirm.type = "button";
|
|
3326
3726
|
confirm.disabled = busy || !inbox.online;
|
|
3327
3727
|
confirm.onclick = () => void dismissDecision(decision.id);
|
|
3328
|
-
const keep = make("button", "btn neutral inbox-dismiss-keep", "
|
|
3728
|
+
const keep = make("button", "btn neutral inbox-dismiss-keep", t("common.keep"));
|
|
3329
3729
|
keep.type = "button";
|
|
3330
3730
|
keep.disabled = busy;
|
|
3331
3731
|
keep.onclick = () => closeDismiss(decision.id);
|
|
@@ -3362,17 +3762,17 @@ async function dismissDecision(id) {
|
|
|
3362
3762
|
break;
|
|
3363
3763
|
case "conflict":
|
|
3364
3764
|
if (result.decision) applyDecision(inbox.state, result.decision);
|
|
3365
|
-
noteFor(id, "
|
|
3765
|
+
noteFor(id, t("inbox.dismiss.conflict"), true);
|
|
3366
3766
|
break;
|
|
3367
3767
|
case "gone":
|
|
3368
3768
|
forgetDecision(inbox.state, id);
|
|
3369
3769
|
break;
|
|
3370
3770
|
case "unavailable":
|
|
3371
3771
|
case "rejected":
|
|
3372
|
-
noteFor(id,
|
|
3772
|
+
noteFor(id, t("inbox.dismiss.rejected", { status: result.status ?? 400, error: result.error ? ` ${result.error}` : "" }), true);
|
|
3373
3773
|
break;
|
|
3374
3774
|
default:
|
|
3375
|
-
noteFor(id, "
|
|
3775
|
+
noteFor(id, t("inbox.dismiss.offline"), true);
|
|
3376
3776
|
}
|
|
3377
3777
|
const current = decisionById(inbox.state, id);
|
|
3378
3778
|
if (current) {
|
|
@@ -3387,7 +3787,7 @@ async function dismissDecision(id) {
|
|
|
3387
3787
|
async function submitDecisionForm(decision, mode) {
|
|
3388
3788
|
const id = decision.id;
|
|
3389
3789
|
if (!inbox.online) {
|
|
3390
|
-
noteFor(id, "
|
|
3790
|
+
noteFor(id, t("decision.offline.send"), true);
|
|
3391
3791
|
syncDecisionSurfaces(decision);
|
|
3392
3792
|
return;
|
|
3393
3793
|
}
|
|
@@ -3395,7 +3795,7 @@ async function submitDecisionForm(decision, mode) {
|
|
|
3395
3795
|
if (mode === "answer" || mode === "resume") {
|
|
3396
3796
|
const composed = composeResponse(decision, formFor(decision));
|
|
3397
3797
|
if (!composed.ok) {
|
|
3398
|
-
noteFor(id, composed.reason === "incomplete" ? "
|
|
3798
|
+
noteFor(id, t(composed.reason === "incomplete" ? "decision.incomplete" : "decision.unsupportedRecheck"), true);
|
|
3399
3799
|
syncDecisionSurfaces(decision);
|
|
3400
3800
|
return;
|
|
3401
3801
|
}
|
|
@@ -3408,13 +3808,13 @@ async function submitDecision(id, mode, response) {
|
|
|
3408
3808
|
const decision = decisionById(inbox.state, id);
|
|
3409
3809
|
if (!decision) return;
|
|
3410
3810
|
if (!inbox.online) {
|
|
3411
|
-
noteFor(id, "
|
|
3811
|
+
noteFor(id, t("decision.offline.send"), true);
|
|
3412
3812
|
syncDecisionSurfaces(decision);
|
|
3413
3813
|
return;
|
|
3414
3814
|
}
|
|
3415
3815
|
const body = buildRequest(inbox.state, decision, mode, response);
|
|
3416
3816
|
if (!body) {
|
|
3417
|
-
noteFor(id, "
|
|
3817
|
+
noteFor(id, t("decision.staleRequest"), true);
|
|
3418
3818
|
syncDecisionSurfaces(decision);
|
|
3419
3819
|
return;
|
|
3420
3820
|
}
|
|
@@ -3450,32 +3850,32 @@ function settleSubmit(decision, result) {
|
|
|
3450
3850
|
case "conflict":
|
|
3451
3851
|
inbox.forms.delete(id);
|
|
3452
3852
|
noteConflict(inbox.state, decision, result.decision);
|
|
3453
|
-
noteFor(id, "
|
|
3853
|
+
noteFor(id, t("decision.conflict"), true);
|
|
3454
3854
|
break;
|
|
3455
3855
|
case "blocked":
|
|
3456
3856
|
// Improvement 165: the answer is held; the card decides. The typed answer stays in the form for a fix.
|
|
3457
3857
|
inbox.state.drafts.delete(id);
|
|
3458
|
-
noteFor(id,
|
|
3858
|
+
noteFor(id, t("decision.blocked", { message: result.message }), true);
|
|
3459
3859
|
if (result.held) showHeldCard(result.held);
|
|
3460
3860
|
break;
|
|
3461
3861
|
case "rejected":
|
|
3462
3862
|
inbox.state.drafts.delete(id);
|
|
3463
3863
|
if (result.decision) applyDecision(inbox.state, result.decision);
|
|
3464
|
-
noteFor(id,
|
|
3864
|
+
noteFor(id, t("decision.rejected", { error: result.error ?? "invalid" }), true);
|
|
3465
3865
|
break;
|
|
3466
3866
|
case "gone": {
|
|
3467
3867
|
forgetDecision(inbox.state, id);
|
|
3468
3868
|
for (const card of els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)) {
|
|
3469
3869
|
card.removeAttribute("aria-busy");
|
|
3470
|
-
setCardState(card, { disabled: true, note: "
|
|
3870
|
+
setCardState(card, { disabled: true, note: t("decision.gone") });
|
|
3471
3871
|
}
|
|
3472
3872
|
break;
|
|
3473
3873
|
}
|
|
3474
3874
|
case "unavailable":
|
|
3475
|
-
noteFor(id,
|
|
3875
|
+
noteFor(id, t("decision.unavailable", { status: result.status, error: result.error ? ` ${result.error}` : "" }), true);
|
|
3476
3876
|
break;
|
|
3477
3877
|
default:
|
|
3478
|
-
noteFor(id, "
|
|
3878
|
+
noteFor(id, t("decision.offline.kept"), true);
|
|
3479
3879
|
}
|
|
3480
3880
|
const current = decisionById(inbox.state, id);
|
|
3481
3881
|
if (current) syncDecisionSurfaces(current);
|
|
@@ -3509,7 +3909,7 @@ function onActionError(data) {
|
|
|
3509
3909
|
setCardBusy(data.actionId, false);
|
|
3510
3910
|
if (data.decision && decision) {
|
|
3511
3911
|
noteConflict(inbox.state, decision, data.decision);
|
|
3512
|
-
noteFor(decision.id, "
|
|
3912
|
+
noteFor(decision.id, t("decision.conflict"), true);
|
|
3513
3913
|
} else if (data.decision) applyDecision(inbox.state, data.decision);
|
|
3514
3914
|
const currentId = decision?.id ?? data.decision?.id;
|
|
3515
3915
|
const current = currentId ? decisionById(inbox.state, currentId) : null;
|
|
@@ -3574,7 +3974,7 @@ function locateOriginal() {
|
|
|
3574
3974
|
return;
|
|
3575
3975
|
}
|
|
3576
3976
|
inbox.locate = null;
|
|
3577
|
-
systemLine("
|
|
3977
|
+
systemLine(t("inbox.originalMissing"), true);
|
|
3578
3978
|
}
|
|
3579
3979
|
|
|
3580
3980
|
const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
@@ -3636,7 +4036,7 @@ function deviceRow(row, armedId) {
|
|
|
3636
4036
|
revoke.className = `text-btn device-revoke${armedId === row.id ? " armed" : ""}`;
|
|
3637
4037
|
revoke.textContent = armedId === row.id ? REVOKE_ARMED_LABEL : REVOKE_LABEL;
|
|
3638
4038
|
revoke.setAttribute("aria-label", `${row.name} ${REVOKE_LABEL}`);
|
|
3639
|
-
revoke.onclick = () => enrollment.revoke(row.id).catch((err) => systemLine(
|
|
4039
|
+
revoke.onclick = () => enrollment.revoke(row.id).catch((err) => systemLine(t("settings.devices.revokeFailed", { error: err.message }), true));
|
|
3640
4040
|
li.append(main, revoke);
|
|
3641
4041
|
return li;
|
|
3642
4042
|
}
|
|
@@ -3666,7 +4066,11 @@ function renderVersionNotice(notice) {
|
|
|
3666
4066
|
$("update-notice").hidden = line === null;
|
|
3667
4067
|
}
|
|
3668
4068
|
|
|
4069
|
+
// The last GET /api/update/status answer (null before the first), drawn again in a new language.
|
|
4070
|
+
let updateStatus = null;
|
|
4071
|
+
|
|
3669
4072
|
function renderUpdate(status) {
|
|
4073
|
+
updateStatus = status;
|
|
3670
4074
|
const view = updateView(status);
|
|
3671
4075
|
$("update-current").textContent = view.current;
|
|
3672
4076
|
$("update-latest").textContent = view.latest;
|
|
@@ -3700,11 +4104,11 @@ function showUpdateError(line) {
|
|
|
3700
4104
|
async function checkUpdateNow() {
|
|
3701
4105
|
const button = $("update-check");
|
|
3702
4106
|
button.disabled = true;
|
|
3703
|
-
button.textContent = "
|
|
4107
|
+
button.textContent = t("settings.update.checking");
|
|
3704
4108
|
const res = await fetch(UPDATE_CHECK_PATH, { method: "POST" }).catch(() => null);
|
|
3705
4109
|
const body = res?.ok ? await res.json().catch(() => null) : null;
|
|
3706
4110
|
button.disabled = false;
|
|
3707
|
-
button.textContent = "
|
|
4111
|
+
button.textContent = t("settings.update.checkNow");
|
|
3708
4112
|
if (body !== null) renderUpdate(body);
|
|
3709
4113
|
else showUpdateError(checkFailedLine(res?.status ?? 0));
|
|
3710
4114
|
}
|
|
@@ -3760,21 +4164,21 @@ async function requestUpdate() {
|
|
|
3760
4164
|
updateFlow.confirmSchema = false;
|
|
3761
4165
|
showUpdateProgress(step.line);
|
|
3762
4166
|
} else if (step.kind === "consent") {
|
|
3763
|
-
askUpdate(step.message, { text: step.notice.text, input: true, go: "
|
|
4167
|
+
askUpdate(step.message, { text: step.notice.text, input: true, go: t("settings.update.consentGo"), onGo: async () => {
|
|
3764
4168
|
const answer = $("update-ask-input").value;
|
|
3765
4169
|
const sent = await fetch(UPDATE_CONSENT_PATH, { method: "POST", headers: { "content-type": "application/json" },
|
|
3766
4170
|
body: JSON.stringify({ version, noticeVersion: step.notice.version, answer }) }).catch(() => null);
|
|
3767
4171
|
const body = sent === null ? null : await sent.json().catch(() => null);
|
|
3768
4172
|
if (sent?.status === 200) return requestUpdate();
|
|
3769
|
-
$("update-ask-message").textContent = body?.message ?? "
|
|
4173
|
+
$("update-ask-message").textContent = body?.message ?? t("settings.update.consentFailed");
|
|
3770
4174
|
} });
|
|
3771
4175
|
} else if (step.kind === "schema") {
|
|
3772
|
-
askUpdate(step.message, { text: step.lines.join("\n"), go: "
|
|
4176
|
+
askUpdate(step.message, { text: step.lines.join("\n"), go: t("common.continue"), onGo: () => {
|
|
3773
4177
|
updateFlow.confirmSchema = true;
|
|
3774
4178
|
return requestUpdate();
|
|
3775
4179
|
} });
|
|
3776
4180
|
} else if (step.kind === "busy") {
|
|
3777
|
-
askUpdate(step.message, { go: "
|
|
4181
|
+
askUpdate(step.message, { go: t("settings.update.force"), onGo: () => {
|
|
3778
4182
|
updateFlow.force = true;
|
|
3779
4183
|
return requestUpdate();
|
|
3780
4184
|
} });
|
|
@@ -3805,29 +4209,37 @@ const enrollment = createEnrollment({
|
|
|
3805
4209
|
|
|
3806
4210
|
// 외부 접속 (plan Todo 27): pick again while the gateway runs. The server resolves and checks the address; turning the
|
|
3807
4211
|
// app port on stays with `zigmini setup --access`, so the form is disabled while the port is off.
|
|
3808
|
-
const accessEls = { current: $("access-current"), form: $("access-form"), choice: $("access-choice"), domain: $("access-domain"), save: $("access-save"), message: $("access-message") };
|
|
3809
|
-
const
|
|
4212
|
+
const accessEls = { current: $("access-current"), form: $("access-form"), choice: $("access-choice"), domain: $("access-domain"), save: $("access-save"), message: $("access-message"), body: null };
|
|
4213
|
+
const ACCESS_CHOICES = ["lan", "tailscale", "domain"];
|
|
3810
4214
|
|
|
3811
4215
|
function showAccessMessage(text, error) {
|
|
3812
4216
|
accessEls.message.textContent = text;
|
|
3813
4217
|
accessEls.message.classList.toggle("error", error);
|
|
3814
4218
|
}
|
|
3815
4219
|
|
|
4220
|
+
/** The last GET /api/access answer, drawn again in a new language. */
|
|
4221
|
+
function renderAccess(body) {
|
|
4222
|
+
accessEls.body = body;
|
|
4223
|
+
const off = !(body.appPort > 0);
|
|
4224
|
+
const choice = body.access && ACCESS_CHOICES.includes(body.access.choice) ? t(body.access.choice === "lan" ? "settings.access.homeWifi" : `settings.access.${body.access.choice}`) : body.access?.choice;
|
|
4225
|
+
accessEls.current.textContent = off
|
|
4226
|
+
? t("settings.access.portOff")
|
|
4227
|
+
: body.access ? t("settings.access.current", { choice, url: body.access.url }) : body.invalid ? t("settings.access.invalid") : t("settings.access.unset");
|
|
4228
|
+
for (const el of [accessEls.choice, accessEls.domain, accessEls.save]) el.disabled = off;
|
|
4229
|
+
if (body.access) accessEls.choice.value = body.access.choice;
|
|
4230
|
+
accessEls.domain.hidden = accessEls.choice.value !== "domain";
|
|
4231
|
+
}
|
|
4232
|
+
|
|
3816
4233
|
async function loadAccess() {
|
|
3817
4234
|
let body;
|
|
3818
4235
|
try {
|
|
3819
4236
|
body = await (await fetch("/api/access")).json();
|
|
3820
4237
|
} catch (err) {
|
|
3821
|
-
accessEls.
|
|
4238
|
+
accessEls.body = null;
|
|
4239
|
+
accessEls.current.textContent = t("settings.access.readFailed", { error: err.message });
|
|
3822
4240
|
return;
|
|
3823
4241
|
}
|
|
3824
|
-
|
|
3825
|
-
accessEls.current.textContent = off
|
|
3826
|
-
? "앱 포트가 꺼져 있습니다. 맥 터미널에서 `zigmini setup --access <선택>`을 먼저 실행하세요."
|
|
3827
|
-
: body.access ? `지금: ${ACCESS_LABELS[body.access.choice]} · ${body.access.url}` : body.invalid ? "access.json을 읽지 못했습니다. 다시 고르세요." : "지금: 고르지 않음";
|
|
3828
|
-
for (const el of [accessEls.choice, accessEls.domain, accessEls.save]) el.disabled = off;
|
|
3829
|
-
if (body.access) accessEls.choice.value = body.access.choice;
|
|
3830
|
-
accessEls.domain.hidden = accessEls.choice.value !== "domain";
|
|
4242
|
+
renderAccess(body);
|
|
3831
4243
|
}
|
|
3832
4244
|
|
|
3833
4245
|
accessEls.choice.onchange = () => { accessEls.domain.hidden = accessEls.choice.value !== "domain"; };
|
|
@@ -3835,19 +4247,19 @@ accessEls.form.onsubmit = async (ev) => {
|
|
|
3835
4247
|
ev.preventDefault();
|
|
3836
4248
|
const payload = { choice: accessEls.choice.value, ...(accessEls.choice.value === "domain" ? { domain: accessEls.domain.value.trim() } : {}) };
|
|
3837
4249
|
accessEls.save.disabled = true;
|
|
3838
|
-
showAccessMessage("
|
|
4250
|
+
showAccessMessage(t("settings.access.checking"), false);
|
|
3839
4251
|
try {
|
|
3840
4252
|
const res = await fetch("/api/access", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
|
3841
4253
|
const body = await res.json();
|
|
3842
4254
|
if (!res.ok) {
|
|
3843
|
-
showAccessMessage(body.message ??
|
|
4255
|
+
showAccessMessage(body.message ?? t("settings.access.failedHttp", { status: res.status }), true);
|
|
3844
4256
|
return;
|
|
3845
4257
|
}
|
|
3846
|
-
showAccessMessage("
|
|
4258
|
+
showAccessMessage(t("settings.access.changed"), false);
|
|
3847
4259
|
await loadAccess();
|
|
3848
4260
|
void enrollment.renew();
|
|
3849
4261
|
} catch (err) {
|
|
3850
|
-
showAccessMessage(
|
|
4262
|
+
showAccessMessage(t("settings.access.failed", { error: err.message }), true);
|
|
3851
4263
|
} finally {
|
|
3852
4264
|
accessEls.save.disabled = false;
|
|
3853
4265
|
}
|
|
@@ -3929,6 +4341,16 @@ function gcRow(row) {
|
|
|
3929
4341
|
return li;
|
|
3930
4342
|
}
|
|
3931
4343
|
|
|
4344
|
+
/** The last GET /api/gc answer (null when the gateway did not answer), drawn again in a new language. */
|
|
4345
|
+
function renderGc(body) {
|
|
4346
|
+
gcEls.body = body;
|
|
4347
|
+
const view = gcView(body);
|
|
4348
|
+
gcEls.summary.textContent = view.summary;
|
|
4349
|
+
gcEls.summary.classList.toggle("error", view.error);
|
|
4350
|
+
gcEls.list.replaceChildren(...view.rows.map(gcRow));
|
|
4351
|
+
gcSync();
|
|
4352
|
+
}
|
|
4353
|
+
|
|
3932
4354
|
async function loadGc() {
|
|
3933
4355
|
gcEls.clean.disabled = true;
|
|
3934
4356
|
let body = null;
|
|
@@ -3938,11 +4360,7 @@ async function loadGc() {
|
|
|
3938
4360
|
} catch {
|
|
3939
4361
|
body = null;
|
|
3940
4362
|
}
|
|
3941
|
-
|
|
3942
|
-
gcEls.summary.textContent = view.summary;
|
|
3943
|
-
gcEls.summary.classList.toggle("error", view.error);
|
|
3944
|
-
gcEls.list.replaceChildren(...view.rows.map(gcRow));
|
|
3945
|
-
gcSync();
|
|
4363
|
+
renderGc(body);
|
|
3946
4364
|
}
|
|
3947
4365
|
|
|
3948
4366
|
gcEls.clean.onclick = async () => {
|
|
@@ -3951,10 +4369,10 @@ gcEls.clean.onclick = async () => {
|
|
|
3951
4369
|
gcEls.clean.disabled = true;
|
|
3952
4370
|
try {
|
|
3953
4371
|
const res = await fetch(GC_CLEAN_PATH, cleanRequest(ids));
|
|
3954
|
-
gcEls.message.textContent = res.ok ? cleanResultLine(await res.json()) :
|
|
4372
|
+
gcEls.message.textContent = res.ok ? cleanResultLine(await res.json()) : t("settings.gc.failedHttp", { status: res.status });
|
|
3955
4373
|
gcEls.message.classList.toggle("error", !res.ok);
|
|
3956
4374
|
} catch (err) {
|
|
3957
|
-
gcEls.message.textContent =
|
|
4375
|
+
gcEls.message.textContent = t("settings.gc.failed", { error: err.message });
|
|
3958
4376
|
gcEls.message.classList.add("error");
|
|
3959
4377
|
}
|
|
3960
4378
|
await loadGc();
|
|
@@ -4014,10 +4432,10 @@ settings.toggle.onclick = () => (settings.open ? closeSettings() : openSettings(
|
|
|
4014
4432
|
settings.close.onclick = closeSettings;
|
|
4015
4433
|
enrollEls.renew.onclick = () => void enrollment.renew();
|
|
4016
4434
|
|
|
4017
|
-
// 설정 "매뉴얼": GET /api/manual
|
|
4018
|
-
// and the header button switch to the other language in place.
|
|
4435
|
+
// 설정 "매뉴얼": GET /api/manual in the UI language, rendered with the chat Markdown renderer; the first-line language
|
|
4436
|
+
// links and the header button switch to the other language in place.
|
|
4019
4437
|
const manualEls = { el: $("manual"), open: $("manual-open"), close: $("manual-close"), title: $("manual-title"), lang: $("manual-lang"), status: $("manual-status"), body: $("manual-body") };
|
|
4020
|
-
const manualView = { open: false, lang:
|
|
4438
|
+
const manualView = { open: false, lang: getLanguage(), load: 0 };
|
|
4021
4439
|
|
|
4022
4440
|
function showManualStatus(text, error = false) {
|
|
4023
4441
|
manualEls.status.textContent = text;
|
|
@@ -4028,9 +4446,9 @@ function showManualStatus(text, error = false) {
|
|
|
4028
4446
|
async function loadManual(lang) {
|
|
4029
4447
|
const load = ++manualView.load;
|
|
4030
4448
|
manualView.lang = lang;
|
|
4031
|
-
manualEls.lang.textContent = otherLang(lang)
|
|
4449
|
+
manualEls.lang.textContent = t(`language.name.${otherLang(lang)}`);
|
|
4032
4450
|
manualEls.el.lang = lang;
|
|
4033
|
-
showManualStatus("
|
|
4451
|
+
showManualStatus(t("manual.loading"));
|
|
4034
4452
|
let status = 0;
|
|
4035
4453
|
let text = "";
|
|
4036
4454
|
try {
|
|
@@ -4057,7 +4475,7 @@ function openManual() {
|
|
|
4057
4475
|
manualEls.el.hidden = false;
|
|
4058
4476
|
manualEls.open.setAttribute("aria-expanded", "true");
|
|
4059
4477
|
manualEls.title.focus();
|
|
4060
|
-
void loadManual(
|
|
4478
|
+
void loadManual(getLanguage());
|
|
4061
4479
|
}
|
|
4062
4480
|
|
|
4063
4481
|
function closeManual() {
|
|
@@ -4103,4 +4521,715 @@ manualEls.el.addEventListener("keydown", (ev) => {
|
|
|
4103
4521
|
}
|
|
4104
4522
|
});
|
|
4105
4523
|
|
|
4524
|
+
// 에이전트 (agent-sessions todo 10, design D5): web/agents.js decides; this block draws the sidebar group above 대화, the
|
|
4525
|
+
// header's name and stale badge, and the 에이전트 dialog (목록, editor, 공통 기본값, 보관함) over /api/agents.
|
|
4526
|
+
const agentState = { list: [], loaded: false };
|
|
4527
|
+
const agentEls = {
|
|
4528
|
+
list: $("agent-list"), manage: $("agent-manage"),
|
|
4529
|
+
head: $("agent-head"), headIcon: $("agent-head-icon"), headName: $("agent-head-name"), stale: $("agent-stale"),
|
|
4530
|
+
toggle: $("toggle-agents"), el: $("agents"), close: $("agents-close"), title: $("agents-title"), status: $("agents-status"), body: $("agents-body"),
|
|
4531
|
+
nav: { list: $("agents-nav-list"), defaults: $("agents-nav-defaults"), trash: $("agents-nav-trash") },
|
|
4532
|
+
};
|
|
4533
|
+
const agentView = { open: false, view: "list", editor: null, files: [], returnFocus: null, load: 0 };
|
|
4534
|
+
|
|
4535
|
+
async function agentApi(request) {
|
|
4536
|
+
const res = await fetch(request.path, { method: request.method, headers: request.headers, body: request.body });
|
|
4537
|
+
const body = await res.json().catch(() => null);
|
|
4538
|
+
if (!res.ok) throw new Error(errorLine(body, res.status));
|
|
4539
|
+
return body;
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
async function loadAgents() {
|
|
4543
|
+
try {
|
|
4544
|
+
const list = await agentApi({ method: "GET", path: AGENTS_PATH });
|
|
4545
|
+
if (!Array.isArray(list)) return;
|
|
4546
|
+
agentState.list = list;
|
|
4547
|
+
agentState.loaded = true;
|
|
4548
|
+
renderAgentGroup();
|
|
4549
|
+
renderAgentHeader();
|
|
4550
|
+
if (agentView.open && agentView.view === "list") renderAgentList();
|
|
4551
|
+
} catch (error) {
|
|
4552
|
+
console.warn("agents load:", error);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
function agentSidebarView() {
|
|
4557
|
+
return { current: state.current, running: state.running, busy: (id) => isBusy(activity, id), pending: state.pendingByConv };
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
function currentConversation() {
|
|
4561
|
+
return state.conversations.find((c) => c.id === state.current) ?? null;
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
function peSpan(className, text) {
|
|
4565
|
+
const el = document.createElement("span");
|
|
4566
|
+
el.className = className;
|
|
4567
|
+
el.textContent = text;
|
|
4568
|
+
return el;
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
function peButton(text, onClick, id, className = "text-btn", act) {
|
|
4572
|
+
const button = document.createElement("button");
|
|
4573
|
+
button.type = "button";
|
|
4574
|
+
button.className = className;
|
|
4575
|
+
button.textContent = text;
|
|
4576
|
+
if (id) button.id = id;
|
|
4577
|
+
if (act) button.dataset.act = act;
|
|
4578
|
+
button.onclick = onClick;
|
|
4579
|
+
return button;
|
|
4580
|
+
}
|
|
4581
|
+
|
|
4582
|
+
function peSection(title, note) {
|
|
4583
|
+
const el = document.createElement("section");
|
|
4584
|
+
el.className = "pe-section";
|
|
4585
|
+
const label = document.createElement("div");
|
|
4586
|
+
label.className = "section-label";
|
|
4587
|
+
label.append(peSpan("", title));
|
|
4588
|
+
el.append(label);
|
|
4589
|
+
if (note) {
|
|
4590
|
+
const p = document.createElement("p");
|
|
4591
|
+
p.className = "pe-note";
|
|
4592
|
+
p.textContent = note;
|
|
4593
|
+
el.append(p);
|
|
4594
|
+
}
|
|
4595
|
+
return el;
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
function renderAgentGroup() {
|
|
4599
|
+
const items = sidebarItems(state.conversations, agentState.list, agentSidebarView());
|
|
4600
|
+
agentEls.list.replaceChildren();
|
|
4601
|
+
if (items.length === 0) {
|
|
4602
|
+
const empty = document.createElement("li");
|
|
4603
|
+
empty.className = "agent-empty";
|
|
4604
|
+
empty.textContent = EMPTY_AGENTS_LINE;
|
|
4605
|
+
agentEls.list.append(empty);
|
|
4606
|
+
return;
|
|
4607
|
+
}
|
|
4608
|
+
for (const item of items) {
|
|
4609
|
+
const li = document.createElement("li");
|
|
4610
|
+
li.className = `${item.active ? "active" : ""}${item.pending ? " pending" : ""}${item.running ? " running" : ""}${item.stale ? " stale" : ""}`;
|
|
4611
|
+
li.title = item.description ? `${item.name} · ${item.description}` : item.name;
|
|
4612
|
+
li.dataset.conv = item.convId;
|
|
4613
|
+
const run = peSpan("agent-run", "");
|
|
4614
|
+
run.title = t("agent.answering");
|
|
4615
|
+
li.append(peSpan("agent-icon", item.icon), peSpan("agent-conv-name", item.name), peSpan("agent-stale-dot", t("agent.stale")), run, peSpan("dot", ""));
|
|
4616
|
+
li.onclick = () => selectConversation(item.convId);
|
|
4617
|
+
agentEls.list.appendChild(li);
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
function renderAgentHeader() {
|
|
4622
|
+
const view = headerView(currentConversation(), agentState.list);
|
|
4623
|
+
agentEls.head.hidden = !view.agent;
|
|
4624
|
+
if (!view.agent) return;
|
|
4625
|
+
agentEls.headIcon.textContent = view.icon;
|
|
4626
|
+
agentEls.headName.textContent = view.name;
|
|
4627
|
+
agentEls.headName.title = view.name;
|
|
4628
|
+
agentEls.stale.hidden = !view.stale;
|
|
4629
|
+
agentEls.stale.title = STALE_TITLE;
|
|
4630
|
+
}
|
|
4631
|
+
|
|
4632
|
+
function receiveAgentUpdate(data) {
|
|
4633
|
+
const result = applyAgentUpdate(agentState.list, state.conversations, data);
|
|
4634
|
+
agentState.list = result.agents;
|
|
4635
|
+
state.conversations = result.conversations;
|
|
4636
|
+
if (result.removedConv && result.removedConv === state.current) selectConversation("main");
|
|
4637
|
+
else if (result.restoredConv) selectConversation(result.restoredConv);
|
|
4638
|
+
else {
|
|
4639
|
+
renderConversations();
|
|
4640
|
+
renderAgentHeader();
|
|
4641
|
+
}
|
|
4642
|
+
if (agentView.open && agentView.view === "list") renderAgentList();
|
|
4643
|
+
}
|
|
4644
|
+
|
|
4645
|
+
function showAgentStatus(text, kind = "") {
|
|
4646
|
+
agentEls.status.textContent = text;
|
|
4647
|
+
agentEls.status.className = `agents-status${kind ? ` ${kind}` : ""}`;
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
function openAgents(view = "list", target = null) {
|
|
4651
|
+
if (!agentView.open) {
|
|
4652
|
+
agentView.open = true;
|
|
4653
|
+
agentView.returnFocus = document.activeElement;
|
|
4654
|
+
agentEls.el.hidden = false;
|
|
4655
|
+
for (const button of [agentEls.toggle, agentEls.manage]) button.setAttribute("aria-expanded", "true");
|
|
4656
|
+
}
|
|
4657
|
+
showAgentStatus("");
|
|
4658
|
+
void showAgentView(view, target);
|
|
4659
|
+
agentEls.title.focus();
|
|
4660
|
+
}
|
|
4661
|
+
|
|
4662
|
+
function closeAgents() {
|
|
4663
|
+
if (!agentView.open) return;
|
|
4664
|
+
agentView.open = false;
|
|
4665
|
+
agentView.load += 1;
|
|
4666
|
+
agentView.editor = null;
|
|
4667
|
+
agentEls.el.hidden = true;
|
|
4668
|
+
agentEls.body.replaceChildren();
|
|
4669
|
+
for (const button of [agentEls.toggle, agentEls.manage]) button.setAttribute("aria-expanded", "false");
|
|
4670
|
+
const back = agentView.returnFocus;
|
|
4671
|
+
agentView.returnFocus = null;
|
|
4672
|
+
(back instanceof HTMLElement && back !== document.body && document.contains(back) ? back : agentEls.toggle).focus();
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
async function showAgentView(view, target = null) {
|
|
4676
|
+
agentView.view = view;
|
|
4677
|
+
for (const [name, button] of Object.entries(agentEls.nav)) button.setAttribute("aria-current", String(name === view));
|
|
4678
|
+
const load = ++agentView.load;
|
|
4679
|
+
agentEls.body.replaceChildren();
|
|
4680
|
+
if (view === "list") {
|
|
4681
|
+
renderAgentList();
|
|
4682
|
+
return;
|
|
4683
|
+
}
|
|
4684
|
+
if (view === "trash") {
|
|
4685
|
+
await renderAgentTrash(load);
|
|
4686
|
+
return;
|
|
4687
|
+
}
|
|
4688
|
+
try {
|
|
4689
|
+
if (view === "defaults") {
|
|
4690
|
+
const body = await agentApi({ method: "GET", path: DEFAULTS_PATH });
|
|
4691
|
+
if (load !== agentView.load) return;
|
|
4692
|
+
agentView.editor = editorState(body, "defaults");
|
|
4693
|
+
} else if (target === null) {
|
|
4694
|
+
// The field registry rides on the defaults answer; a new agent has no detail to take it from yet.
|
|
4695
|
+
const defaults = await agentApi({ method: "GET", path: DEFAULTS_PATH });
|
|
4696
|
+
if (load !== agentView.load) return;
|
|
4697
|
+
agentView.editor = editorState({ registry: defaults.registry }, "new");
|
|
4698
|
+
agentView.files = [];
|
|
4699
|
+
} else {
|
|
4700
|
+
const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(target)}` });
|
|
4701
|
+
const files = await agentApi({ method: "GET", path: filesPath(target) }).catch(() => []);
|
|
4702
|
+
if (load !== agentView.load) return;
|
|
4703
|
+
agentView.editor = editorState(detail, "agent");
|
|
4704
|
+
agentView.files = fileRows(files);
|
|
4705
|
+
}
|
|
4706
|
+
} catch (error) {
|
|
4707
|
+
if (load === agentView.load) showAgentStatus(error.message, "error");
|
|
4708
|
+
return;
|
|
4709
|
+
}
|
|
4710
|
+
renderAgentEditor();
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
function renderAgentList() {
|
|
4714
|
+
const items = sidebarItems(state.conversations, agentState.list, agentSidebarView());
|
|
4715
|
+
const section = peSection(t("agents.list"), t("agents.list.note"));
|
|
4716
|
+
const actions = document.createElement("div");
|
|
4717
|
+
actions.className = "pe-actions";
|
|
4718
|
+
actions.append(peButton(t("side.newAgent"), () => openAgents("editor", null), "pe-new", "text-btn primary"));
|
|
4719
|
+
const list = document.createElement("ul");
|
|
4720
|
+
list.className = "pe-list";
|
|
4721
|
+
list.id = "pe-agent-list";
|
|
4722
|
+
if (items.length === 0) {
|
|
4723
|
+
const empty = document.createElement("li");
|
|
4724
|
+
empty.className = "pe-empty";
|
|
4725
|
+
empty.textContent = EMPTY_AGENTS_LINE;
|
|
4726
|
+
list.append(empty);
|
|
4727
|
+
}
|
|
4728
|
+
for (const item of items) {
|
|
4729
|
+
const li = document.createElement("li");
|
|
4730
|
+
li.className = "pe-list-row";
|
|
4731
|
+
li.dataset.agent = item.agentId;
|
|
4732
|
+
const main = document.createElement("div");
|
|
4733
|
+
main.className = "pe-list-main";
|
|
4734
|
+
const name = document.createElement("div");
|
|
4735
|
+
name.className = "pe-list-name";
|
|
4736
|
+
name.append(peSpan("", item.name));
|
|
4737
|
+
if (item.stale) name.append(peSpan("agent-stale", t("agent.stale")));
|
|
4738
|
+
const sub = document.createElement("div");
|
|
4739
|
+
sub.className = "pe-list-sub";
|
|
4740
|
+
sub.textContent = item.description;
|
|
4741
|
+
sub.title = item.description;
|
|
4742
|
+
main.append(name, sub);
|
|
4743
|
+
const rowActions = document.createElement("div");
|
|
4744
|
+
rowActions.className = "pe-list-actions";
|
|
4745
|
+
rowActions.append(
|
|
4746
|
+
peButton(t("agents.openConversation"), () => {
|
|
4747
|
+
selectConversation(item.convId);
|
|
4748
|
+
closeAgents();
|
|
4749
|
+
}, undefined, "text-btn", "open"),
|
|
4750
|
+
peButton(t("common.edit"), () => openAgents("editor", item.agentId), undefined, "text-btn", "edit"),
|
|
4751
|
+
);
|
|
4752
|
+
li.append(peSpan("agent-icon", item.icon), main, rowActions);
|
|
4753
|
+
list.append(li);
|
|
4754
|
+
}
|
|
4755
|
+
section.append(actions, list);
|
|
4756
|
+
agentEls.body.replaceChildren(section);
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
function renderEditorRow(row) {
|
|
4760
|
+
const wrap = document.createElement("div");
|
|
4761
|
+
wrap.className = "pe-row";
|
|
4762
|
+
wrap.dataset.field = row.name;
|
|
4763
|
+
const id = `pe-field-${row.name.replaceAll(".", "-")}`;
|
|
4764
|
+
const label = document.createElement("label");
|
|
4765
|
+
label.className = "pe-label";
|
|
4766
|
+
label.htmlFor = id;
|
|
4767
|
+
label.textContent = row.label;
|
|
4768
|
+
if (row.help) {
|
|
4769
|
+
const help = document.createElement("small");
|
|
4770
|
+
help.textContent = row.help;
|
|
4771
|
+
label.append(help);
|
|
4772
|
+
}
|
|
4773
|
+
const control = document.createElement("div");
|
|
4774
|
+
control.className = "pe-control";
|
|
4775
|
+
const line = document.createElement("div");
|
|
4776
|
+
line.className = "pe-control-line";
|
|
4777
|
+
const error = document.createElement("p");
|
|
4778
|
+
error.className = "pe-error";
|
|
4779
|
+
let input;
|
|
4780
|
+
// A change updates the state and this row's origin tag only: redrawing the form here would replace the field the
|
|
4781
|
+
// user is moving to (its change fires while focus travels) and lose what they type next.
|
|
4782
|
+
const apply = (raw) => {
|
|
4783
|
+
const result = widgetValue(row, raw);
|
|
4784
|
+
if (result.error) {
|
|
4785
|
+
error.textContent = result.error;
|
|
4786
|
+
input.setAttribute("aria-invalid", "true");
|
|
4787
|
+
return;
|
|
4788
|
+
}
|
|
4789
|
+
error.textContent = "";
|
|
4790
|
+
input.removeAttribute("aria-invalid");
|
|
4791
|
+
agentView.editor = setField(agentView.editor, row.name, result.value);
|
|
4792
|
+
refreshEditorRow(wrap, row.name);
|
|
4793
|
+
};
|
|
4794
|
+
if (row.widget === "toggle") {
|
|
4795
|
+
const toggle = document.createElement("label");
|
|
4796
|
+
toggle.className = "pe-toggle";
|
|
4797
|
+
input = document.createElement("input");
|
|
4798
|
+
input.type = "checkbox";
|
|
4799
|
+
input.id = id;
|
|
4800
|
+
input.checked = row.value === true;
|
|
4801
|
+
toggle.append(input, peSpan("", t(row.value === true ? "common.onShort" : "common.offShort")));
|
|
4802
|
+
input.addEventListener("change", () => apply(input.checked));
|
|
4803
|
+
line.append(toggle);
|
|
4804
|
+
} else if (row.widget === "select") {
|
|
4805
|
+
input = document.createElement("select");
|
|
4806
|
+
input.className = "pe-input pe-select";
|
|
4807
|
+
input.id = id;
|
|
4808
|
+
input.append(new Option(t(row.own ? "agents.clearOption" : "agents.inheritOption"), ""));
|
|
4809
|
+
for (const option of row.options ?? []) input.append(new Option(option, option));
|
|
4810
|
+
input.value = typeof row.value === "string" ? row.value : "";
|
|
4811
|
+
input.addEventListener("change", () => apply(input.value));
|
|
4812
|
+
line.append(input);
|
|
4813
|
+
} else if (row.widget === "textarea" || row.widget === "skills") {
|
|
4814
|
+
input = document.createElement("textarea");
|
|
4815
|
+
input.className = "pe-textarea";
|
|
4816
|
+
input.id = id;
|
|
4817
|
+
input.rows = 2;
|
|
4818
|
+
input.value = Array.isArray(row.value) ? row.value.join(", ") : typeof row.value === "string" ? row.value : "";
|
|
4819
|
+
input.addEventListener("change", () => apply(input.value));
|
|
4820
|
+
line.append(input);
|
|
4821
|
+
} else {
|
|
4822
|
+
input = document.createElement("input");
|
|
4823
|
+
input.className = `pe-input${row.widget === "number" ? " pe-number" : ""}`;
|
|
4824
|
+
input.id = id;
|
|
4825
|
+
input.type = row.widget === "number" ? "number" : "text";
|
|
4826
|
+
if (row.min !== undefined) input.min = String(row.min);
|
|
4827
|
+
if (row.max !== undefined) input.max = String(row.max);
|
|
4828
|
+
input.value = row.value === null || row.value === undefined ? "" : String(row.value);
|
|
4829
|
+
input.addEventListener("change", () => apply(input.value));
|
|
4830
|
+
line.append(input);
|
|
4831
|
+
}
|
|
4832
|
+
if (row.required) input.required = true;
|
|
4833
|
+
const origin = peSpan(`pe-origin ${row.origin}`, row.originLabel);
|
|
4834
|
+
origin.title = t("agents.origin.tip", { origin: row.originLabel });
|
|
4835
|
+
line.append(origin);
|
|
4836
|
+
if (row.clearable) line.append(peClearButton(wrap, row.name, id));
|
|
4837
|
+
if (row.unparsed) {
|
|
4838
|
+
const note = peSpan("pe-origin pe-unparsed", t("agents.unparsed"));
|
|
4839
|
+
note.title = t("agents.unparsed.tip");
|
|
4840
|
+
line.append(note);
|
|
4841
|
+
}
|
|
4842
|
+
control.append(line, error);
|
|
4843
|
+
wrap.append(label, control);
|
|
4844
|
+
return wrap;
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
function peClearButton(wrap, name, id) {
|
|
4848
|
+
const clear = peButton(t("agents.clearOverride"), () => {
|
|
4849
|
+
agentView.editor = clearField(agentView.editor, name);
|
|
4850
|
+
const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === name);
|
|
4851
|
+
if (row) wrap.replaceWith(renderEditorRow(row));
|
|
4852
|
+
}, `${id}-clear`, "pe-clear");
|
|
4853
|
+
clear.title = t("agents.clearOverride.tip");
|
|
4854
|
+
return clear;
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
function refreshEditorRow(wrap, name) {
|
|
4858
|
+
const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === name);
|
|
4859
|
+
if (!row) return;
|
|
4860
|
+
const origin = wrap.querySelector(".pe-origin:not(.pe-unparsed)");
|
|
4861
|
+
if (origin) {
|
|
4862
|
+
origin.className = `pe-origin ${row.origin}`;
|
|
4863
|
+
origin.textContent = row.originLabel;
|
|
4864
|
+
origin.title = t("agents.origin.tip", { origin: row.originLabel });
|
|
4865
|
+
}
|
|
4866
|
+
wrap.querySelector(".pe-unparsed")?.remove();
|
|
4867
|
+
const clear = wrap.querySelector(".pe-clear");
|
|
4868
|
+
if (row.clearable && !clear && origin) origin.after(peClearButton(wrap, name, `pe-field-${name.replaceAll(".", "-")}`));
|
|
4869
|
+
else if (!row.clearable && clear) clear.remove();
|
|
4870
|
+
const toggleText = wrap.querySelector(".pe-toggle span");
|
|
4871
|
+
if (toggleText) toggleText.textContent = t(row.value === true ? "common.onShort" : "common.offShort");
|
|
4872
|
+
const select = wrap.querySelector("select.pe-select");
|
|
4873
|
+
if (select?.options[0]) select.options[0].textContent = t(row.own ? "agents.clearOption" : "agents.inheritOption");
|
|
4874
|
+
}
|
|
4875
|
+
|
|
4876
|
+
function renderAgentFiles(id) {
|
|
4877
|
+
const section = peSection(t("agents.files"), t("agents.files.note"));
|
|
4878
|
+
const list = document.createElement("ul");
|
|
4879
|
+
list.className = "pe-files";
|
|
4880
|
+
list.id = "pe-files";
|
|
4881
|
+
if (agentView.files.length === 0) {
|
|
4882
|
+
const empty = document.createElement("li");
|
|
4883
|
+
empty.className = "pe-empty";
|
|
4884
|
+
empty.textContent = EMPTY_FILES_LINE;
|
|
4885
|
+
list.append(empty);
|
|
4886
|
+
}
|
|
4887
|
+
for (const file of agentView.files) {
|
|
4888
|
+
const li = document.createElement("li");
|
|
4889
|
+
li.className = "pe-file";
|
|
4890
|
+
const name = peSpan(`pe-file-name ${file.type}`, `${"\u00a0\u00a0".repeat(file.depth)}${file.type === "directory" ? `${file.name}/` : file.name}`);
|
|
4891
|
+
name.title = file.path;
|
|
4892
|
+
li.append(name, peSpan("pe-file-size", file.label));
|
|
4893
|
+
list.append(li);
|
|
4894
|
+
}
|
|
4895
|
+
const upload = document.createElement("div");
|
|
4896
|
+
upload.className = "pe-upload";
|
|
4897
|
+
const input = document.createElement("input");
|
|
4898
|
+
input.type = "file";
|
|
4899
|
+
input.id = "pe-source-file";
|
|
4900
|
+
input.setAttribute("aria-label", t("agents.sourceFile.label"));
|
|
4901
|
+
upload.append(input, peButton(t("agents.sourceUpload"), () => void uploadAgentSource(id, input), "pe-source-upload"));
|
|
4902
|
+
section.append(list, upload);
|
|
4903
|
+
return section;
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
function renderAgentEditor() {
|
|
4907
|
+
const editor = agentView.editor;
|
|
4908
|
+
if (!editor) return;
|
|
4909
|
+
const focused = document.activeElement?.id || null;
|
|
4910
|
+
const form = document.createElement("form");
|
|
4911
|
+
form.className = "pe-form";
|
|
4912
|
+
form.id = "pe-form";
|
|
4913
|
+
form.noValidate = true;
|
|
4914
|
+
form.addEventListener("submit", (ev) => {
|
|
4915
|
+
ev.preventDefault();
|
|
4916
|
+
void saveAgentEditor();
|
|
4917
|
+
});
|
|
4918
|
+
const isDefaults = editor.kind === "defaults";
|
|
4919
|
+
const fields = peSection(isDefaults ? t("agents.defaultsSection") : editor.kind === "new" ? t("agents.new") : t("agents.settingsFor", { name: editor.name ?? "" }),
|
|
4920
|
+
t(isDefaults ? "agents.defaults.note" : "agents.editor.note"));
|
|
4921
|
+
for (const row of editorRows(editor)) {
|
|
4922
|
+
if (row.kind === "group") {
|
|
4923
|
+
const group = document.createElement("div");
|
|
4924
|
+
group.className = "section-label pe-group";
|
|
4925
|
+
group.append(peSpan("", row.label));
|
|
4926
|
+
if (row.help) group.append(peSpan("pe-group-help", row.help));
|
|
4927
|
+
fields.append(group);
|
|
4928
|
+
continue;
|
|
4929
|
+
}
|
|
4930
|
+
fields.append(renderEditorRow(row));
|
|
4931
|
+
}
|
|
4932
|
+
const instructionsKey = isDefaults ? "agents.sharedInstructions" : "agents.roleInstructions";
|
|
4933
|
+
const bodySection = peSection(t(instructionsKey), t(`${instructionsKey}.note`));
|
|
4934
|
+
const bodyArea = document.createElement("textarea");
|
|
4935
|
+
bodyArea.className = "pe-textarea pe-body";
|
|
4936
|
+
bodyArea.id = "pe-body";
|
|
4937
|
+
bodyArea.value = editor.body;
|
|
4938
|
+
bodyArea.setAttribute("aria-label", t(instructionsKey));
|
|
4939
|
+
bodyArea.addEventListener("change", () => {
|
|
4940
|
+
agentView.editor = setBody(agentView.editor, bodyArea.value);
|
|
4941
|
+
});
|
|
4942
|
+
bodySection.append(bodyArea);
|
|
4943
|
+
const rawSection = peSection(t("agents.rawSection"), t("agents.raw.note"));
|
|
4944
|
+
const rawArea = document.createElement("textarea");
|
|
4945
|
+
rawArea.className = "pe-textarea pe-mono";
|
|
4946
|
+
rawArea.id = "pe-unknown";
|
|
4947
|
+
rawArea.value = editor.unknownRaw;
|
|
4948
|
+
rawArea.spellcheck = false;
|
|
4949
|
+
rawArea.setAttribute("aria-label", t("agents.raw.label"));
|
|
4950
|
+
rawArea.addEventListener("change", () => {
|
|
4951
|
+
agentView.editor = setUnknownRaw(agentView.editor, rawArea.value);
|
|
4952
|
+
});
|
|
4953
|
+
rawSection.append(rawArea);
|
|
4954
|
+
form.append(fields, bodySection, rawSection);
|
|
4955
|
+
const existing = editor.kind === "agent" && editor.id;
|
|
4956
|
+
if (existing) form.append(renderAgentFiles(editor.id));
|
|
4957
|
+
let deleteBox = null;
|
|
4958
|
+
let deleteInput = null;
|
|
4959
|
+
const error = document.createElement("p");
|
|
4960
|
+
error.className = "pe-error";
|
|
4961
|
+
error.id = "pe-form-error";
|
|
4962
|
+
const actions = document.createElement("div");
|
|
4963
|
+
actions.className = "pe-actions";
|
|
4964
|
+
const save = document.createElement("button");
|
|
4965
|
+
save.type = "submit";
|
|
4966
|
+
save.className = "text-btn primary";
|
|
4967
|
+
save.id = "pe-save";
|
|
4968
|
+
save.textContent = t(editor.kind === "new" ? "common.create" : "common.save");
|
|
4969
|
+
actions.append(save);
|
|
4970
|
+
if (existing) {
|
|
4971
|
+
actions.append(peButton(t("agents.clone"), () => void cloneAgent(editor.id), "pe-clone"));
|
|
4972
|
+
actions.append(peSpan("pe-spacer", ""));
|
|
4973
|
+
actions.append(peButton(t("common.delete"), () => {
|
|
4974
|
+
deleteBox.hidden = !deleteBox.hidden;
|
|
4975
|
+
if (!deleteBox.hidden) deleteInput.focus();
|
|
4976
|
+
}, "pe-delete-open", "text-btn danger"));
|
|
4977
|
+
}
|
|
4978
|
+
form.append(error, actions);
|
|
4979
|
+
if (existing) {
|
|
4980
|
+
deleteBox = document.createElement("div");
|
|
4981
|
+
deleteBox.className = "inbox-dismiss mode-cancel pe-delete";
|
|
4982
|
+
deleteBox.id = "pe-delete";
|
|
4983
|
+
deleteBox.hidden = true;
|
|
4984
|
+
const text = document.createElement("p");
|
|
4985
|
+
text.className = "inbox-dismiss-text";
|
|
4986
|
+
text.textContent = DELETE_HINT;
|
|
4987
|
+
deleteInput = document.createElement("input");
|
|
4988
|
+
deleteInput.className = "pe-input";
|
|
4989
|
+
deleteInput.id = "pe-delete-name";
|
|
4990
|
+
deleteInput.type = "text";
|
|
4991
|
+
deleteInput.autocomplete = "off";
|
|
4992
|
+
deleteInput.placeholder = editor.name ?? "";
|
|
4993
|
+
deleteInput.setAttribute("aria-label", t("agents.deleteName.label"));
|
|
4994
|
+
const confirm = peButton(t("common.delete"), () => void deleteAgent(editor.id, deleteInput.value), "pe-delete-confirm", "text-btn danger");
|
|
4995
|
+
confirm.disabled = true;
|
|
4996
|
+
deleteInput.addEventListener("input", () => {
|
|
4997
|
+
confirm.disabled = !deleteEnabled(deleteInput.value, editor.name);
|
|
4998
|
+
});
|
|
4999
|
+
const keep = peButton(t("common.keep"), () => {
|
|
5000
|
+
deleteBox.hidden = true;
|
|
5001
|
+
deleteInput.value = "";
|
|
5002
|
+
confirm.disabled = true;
|
|
5003
|
+
}, "pe-delete-keep");
|
|
5004
|
+
const buttons = document.createElement("div");
|
|
5005
|
+
buttons.className = "pe-actions";
|
|
5006
|
+
buttons.append(confirm, keep);
|
|
5007
|
+
deleteBox.append(text, deleteInput, buttons);
|
|
5008
|
+
form.append(deleteBox);
|
|
5009
|
+
}
|
|
5010
|
+
agentEls.body.replaceChildren(form);
|
|
5011
|
+
if (focused) document.getElementById(focused)?.focus();
|
|
5012
|
+
}
|
|
5013
|
+
|
|
5014
|
+
async function saveAgentEditor() {
|
|
5015
|
+
const editor = agentView.editor;
|
|
5016
|
+
if (!editor) return;
|
|
5017
|
+
const errors = validateEditor(editor);
|
|
5018
|
+
const errorEl = document.getElementById("pe-form-error");
|
|
5019
|
+
if (errors.length) {
|
|
5020
|
+
if (errorEl) errorEl.textContent = errors.map((e) => e.message).join(" · ");
|
|
5021
|
+
showAgentStatus(t("agents.notSaved"), "error");
|
|
5022
|
+
return;
|
|
5023
|
+
}
|
|
5024
|
+
if (errorEl) errorEl.textContent = "";
|
|
5025
|
+
const save = document.getElementById("pe-save");
|
|
5026
|
+
if (save) save.disabled = true;
|
|
5027
|
+
try {
|
|
5028
|
+
const body = await agentApi(saveRequest(editor));
|
|
5029
|
+
if (editor.kind === "defaults") {
|
|
5030
|
+
agentView.editor = editorState(body, "defaults");
|
|
5031
|
+
showAgentStatus(t("agents.defaultsSaved"), "ok");
|
|
5032
|
+
renderAgentEditor();
|
|
5033
|
+
return;
|
|
5034
|
+
}
|
|
5035
|
+
receiveAgentUpdate(body);
|
|
5036
|
+
if (editor.kind === "new") {
|
|
5037
|
+
closeAgents();
|
|
5038
|
+
systemLine(t("agents.created.line", { name: body.name }));
|
|
5039
|
+
return;
|
|
5040
|
+
}
|
|
5041
|
+
agentView.editor = editorState(body, "agent");
|
|
5042
|
+
showAgentStatus(t("agents.saved"), "ok");
|
|
5043
|
+
renderAgentEditor();
|
|
5044
|
+
} catch (error) {
|
|
5045
|
+
showAgentStatus(error.message, "error");
|
|
5046
|
+
} finally {
|
|
5047
|
+
const button = document.getElementById("pe-save");
|
|
5048
|
+
if (button) button.disabled = false;
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
|
|
5052
|
+
async function cloneAgent(id) {
|
|
5053
|
+
try {
|
|
5054
|
+
const body = await agentApi(cloneRequest(id));
|
|
5055
|
+
receiveAgentUpdate(body);
|
|
5056
|
+
showAgentStatus(t("agents.created", { name: body.name }), "ok");
|
|
5057
|
+
await showAgentView("editor", body.id);
|
|
5058
|
+
} catch (error) {
|
|
5059
|
+
showAgentStatus(error.message, "error");
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
|
|
5063
|
+
async function deleteAgent(id, typed) {
|
|
5064
|
+
const editor = agentView.editor;
|
|
5065
|
+
if (!editor || !deleteEnabled(typed, editor.name)) return;
|
|
5066
|
+
try {
|
|
5067
|
+
await agentApi(deleteRequest(id, typed));
|
|
5068
|
+
showAgentStatus(t("agents.archived", { name: editor.name }), "ok");
|
|
5069
|
+
await showAgentView("list");
|
|
5070
|
+
} catch (error) {
|
|
5071
|
+
showAgentStatus(error.message, "error");
|
|
5072
|
+
}
|
|
5073
|
+
}
|
|
5074
|
+
|
|
5075
|
+
async function uploadAgentSource(id, input) {
|
|
5076
|
+
const file = input.files?.[0];
|
|
5077
|
+
if (!file) {
|
|
5078
|
+
showAgentStatus(t("agents.pickFile"), "error");
|
|
5079
|
+
return;
|
|
5080
|
+
}
|
|
5081
|
+
const form = new FormData();
|
|
5082
|
+
form.append("file", file, file.name);
|
|
5083
|
+
try {
|
|
5084
|
+
const result = await agentApi({ method: "POST", path: sourceUploadPath(id), body: form });
|
|
5085
|
+
agentView.files = fileRows(await agentApi({ method: "GET", path: filesPath(id) }));
|
|
5086
|
+
showAgentStatus(t("agents.uploaded", { name: result.name, size: formatBytes(result.size) }), "ok");
|
|
5087
|
+
renderAgentEditor();
|
|
5088
|
+
} catch (error) {
|
|
5089
|
+
showAgentStatus(error.message, "error");
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
|
|
5093
|
+
async function renderAgentTrash(load) {
|
|
5094
|
+
let rows;
|
|
5095
|
+
try {
|
|
5096
|
+
rows = trashRows(await agentApi({ method: "GET", path: TRASH_PATH }));
|
|
5097
|
+
} catch (error) {
|
|
5098
|
+
if (load === agentView.load) showAgentStatus(error.message, "error");
|
|
5099
|
+
return;
|
|
5100
|
+
}
|
|
5101
|
+
if (load !== agentView.load) return;
|
|
5102
|
+
const section = peSection(t("agents.trash"), t("agents.trash.note"));
|
|
5103
|
+
const list = document.createElement("ul");
|
|
5104
|
+
list.className = "pe-list";
|
|
5105
|
+
list.id = "pe-trash-list";
|
|
5106
|
+
if (rows.length === 0) {
|
|
5107
|
+
const empty = document.createElement("li");
|
|
5108
|
+
empty.className = "pe-empty";
|
|
5109
|
+
empty.textContent = EMPTY_TRASH_LINE;
|
|
5110
|
+
list.append(empty);
|
|
5111
|
+
}
|
|
5112
|
+
for (const row of rows) {
|
|
5113
|
+
const li = document.createElement("li");
|
|
5114
|
+
li.className = "pe-list-row";
|
|
5115
|
+
li.dataset.entry = row.entry;
|
|
5116
|
+
const main = document.createElement("div");
|
|
5117
|
+
main.className = "pe-list-main";
|
|
5118
|
+
const name = document.createElement("div");
|
|
5119
|
+
name.className = "pe-list-name";
|
|
5120
|
+
name.textContent = row.name;
|
|
5121
|
+
const sub = document.createElement("div");
|
|
5122
|
+
sub.className = "pe-list-sub";
|
|
5123
|
+
sub.textContent = `${row.description}${row.deletedAt ? t("agents.deletedAt", { time: new Date(row.deletedAt).toLocaleString(uiLocale()) }) : ""}`;
|
|
5124
|
+
main.append(name, sub);
|
|
5125
|
+
const rowActions = document.createElement("div");
|
|
5126
|
+
rowActions.className = "pe-list-actions";
|
|
5127
|
+
rowActions.append(peButton(t("agents.restore"), () => void restoreAgent(row.entry), undefined, "text-btn primary", "restore"));
|
|
5128
|
+
li.append(peSpan("agent-icon", row.icon), main, rowActions);
|
|
5129
|
+
list.append(li);
|
|
5130
|
+
}
|
|
5131
|
+
section.append(list);
|
|
5132
|
+
agentEls.body.replaceChildren(section);
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
async function restoreAgent(entry) {
|
|
5136
|
+
try {
|
|
5137
|
+
const body = await agentApi(restoreRequest(entry));
|
|
5138
|
+
receiveAgentUpdate(body);
|
|
5139
|
+
showAgentStatus(t("agents.restored", { name: body.name }), "ok");
|
|
5140
|
+
await showAgentView("list");
|
|
5141
|
+
} catch (error) {
|
|
5142
|
+
showAgentStatus(error.message, "error");
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
|
|
5146
|
+
agentEls.manage.onclick = () => (agentView.open ? closeAgents() : openAgents("list"));
|
|
5147
|
+
agentEls.toggle.onclick = () => (agentView.open ? closeAgents() : openAgents("list"));
|
|
5148
|
+
agentEls.stale.onclick = () => send({ type: "conversation.rotate", conv: state.current });
|
|
5149
|
+
agentEls.close.onclick = closeAgents;
|
|
5150
|
+
for (const [view, button] of Object.entries(agentEls.nav)) button.onclick = () => openAgents(view);
|
|
5151
|
+
agentEls.el.addEventListener("click", (ev) => {
|
|
5152
|
+
if (ev.target === agentEls.el) closeAgents();
|
|
5153
|
+
});
|
|
5154
|
+
agentEls.el.addEventListener("keydown", (ev) => {
|
|
5155
|
+
if (ev.isComposing) return;
|
|
5156
|
+
if (ev.key === "Escape") {
|
|
5157
|
+
ev.preventDefault();
|
|
5158
|
+
closeAgents();
|
|
5159
|
+
return;
|
|
5160
|
+
}
|
|
5161
|
+
if (ev.key !== "Tab") return;
|
|
5162
|
+
const focusable = [...agentEls.el.querySelectorAll(FOCUSABLE)].filter((node) => node.offsetParent !== null);
|
|
5163
|
+
if (focusable.length === 0) return;
|
|
5164
|
+
const first = focusable[0];
|
|
5165
|
+
const last = focusable.at(-1);
|
|
5166
|
+
if (ev.shiftKey && (document.activeElement === first || document.activeElement === agentEls.title)) {
|
|
5167
|
+
ev.preventDefault();
|
|
5168
|
+
last.focus();
|
|
5169
|
+
} else if (!ev.shiftKey && document.activeElement === last) {
|
|
5170
|
+
ev.preventDefault();
|
|
5171
|
+
first.focus();
|
|
5172
|
+
}
|
|
5173
|
+
});
|
|
5174
|
+
void loadAgents();
|
|
5175
|
+
|
|
5176
|
+
/** The open agent dialog redrawn from its state: the list, the archive (fetched again) or the editor (unsaved edits kept). */
|
|
5177
|
+
function rerenderAgentDialog() {
|
|
5178
|
+
if (!agentView.open) return;
|
|
5179
|
+
if (agentView.view === "list") renderAgentList();
|
|
5180
|
+
else if (agentView.view === "trash") void showAgentView("trash");
|
|
5181
|
+
else if (agentView.editor) renderAgentEditor();
|
|
5182
|
+
}
|
|
5183
|
+
|
|
5184
|
+
/**
|
|
5185
|
+
* A language change: applyI18n (web/i18n.js) has already translated the static page; everything app.js draws from state
|
|
5186
|
+
* is drawn again here. Lines already in the transcript stay as they were written; their labels (read marker, tags,
|
|
5187
|
+
* thinking) carry data-i18n, so applyI18n has redrawn them already (bug 260).
|
|
5188
|
+
*/
|
|
5189
|
+
function rerenderLanguage() {
|
|
5190
|
+
renderLanguageSelect();
|
|
5191
|
+
renderConn();
|
|
5192
|
+
renderOmo();
|
|
5193
|
+
if (modelInfo) renderModels(modelInfo);
|
|
5194
|
+
renderTtsToggle();
|
|
5195
|
+
renderClapToggle();
|
|
5196
|
+
setWakeStatus(wake.status);
|
|
5197
|
+
renderVoiceToggle();
|
|
5198
|
+
renderVoiceInstall();
|
|
5199
|
+
setMicState(micState);
|
|
5200
|
+
renderSendButton();
|
|
5201
|
+
els.input.placeholder = t("composer.placeholder");
|
|
5202
|
+
lastSent.el.dataset.label = t("chat.lastSent.prefix");
|
|
5203
|
+
updateScrollNav();
|
|
5204
|
+
renderStatusToggle();
|
|
5205
|
+
syncStatusFold();
|
|
5206
|
+
renderWorkStatusAll();
|
|
5207
|
+
renderTray();
|
|
5208
|
+
renderReferences();
|
|
5209
|
+
renderDeviceRequests();
|
|
5210
|
+
if (!slash.el.hidden) renderSlashMenu();
|
|
5211
|
+
renderInboxBadge();
|
|
5212
|
+
renderInboxOnline();
|
|
5213
|
+
renderInboxList();
|
|
5214
|
+
renderSecuritySurfaces();
|
|
5215
|
+
renderSecurityToggle();
|
|
5216
|
+
repoSecurity.render();
|
|
5217
|
+
renderFormal();
|
|
5218
|
+
renderAllowedValues();
|
|
5219
|
+
renderSecurityBaseline();
|
|
5220
|
+
renderUpdate(updateStatus);
|
|
5221
|
+
if (accessEls.body) renderAccess(accessEls.body);
|
|
5222
|
+
if (settings.open) {
|
|
5223
|
+
enrollment.paint();
|
|
5224
|
+
appDownload.paint();
|
|
5225
|
+
if ("body" in gcEls) renderGc(gcEls.body);
|
|
5226
|
+
}
|
|
5227
|
+
renderAgentGroup();
|
|
5228
|
+
renderAgentHeader();
|
|
5229
|
+
rerenderAgentDialog();
|
|
5230
|
+
if (manualView.open) void loadManual(getLanguage());
|
|
5231
|
+
}
|
|
5232
|
+
|
|
5233
|
+
onLanguageChange(rerenderLanguage);
|
|
5234
|
+
|
|
4106
5235
|
connect();
|