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.
Files changed (141) hide show
  1. package/README.ko.md +24 -4
  2. package/README.md +24 -4
  3. package/assets/agent/gateway-rules.md +1 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +51 -11
  8. package/docs/manual.md +52 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +14 -5
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/main.ts +33 -0
  31. package/src/cli/messages.ts +10 -2
  32. package/src/cli/rotate-main.ts +2 -1
  33. package/src/cli/scan.ts +414 -0
  34. package/src/cli/security-import.ts +155 -0
  35. package/src/compact.ts +22 -0
  36. package/src/decisions.ts +6 -4
  37. package/src/engine/compat.json +3 -3
  38. package/src/engine/index.ts +1 -0
  39. package/src/engine/schema-guard.ts +12 -4
  40. package/src/engine/sdk.ts +8 -0
  41. package/src/formal-verification.ts +366 -0
  42. package/src/gc.ts +37 -6
  43. package/src/hub.ts +339 -79
  44. package/src/main.ts +95 -9
  45. package/src/maintenance.ts +1 -1
  46. package/src/manual.ts +9 -2
  47. package/src/profile-prompt.ts +73 -0
  48. package/src/profile.ts +33 -10
  49. package/src/protocol.ts +11 -2
  50. package/src/question-notifications.ts +4 -1
  51. package/src/reply-notifications.ts +7 -1
  52. package/src/repo-security-setting.ts +33 -0
  53. package/src/runtime/paths.ts +16 -0
  54. package/src/security/baseline.ts +170 -0
  55. package/src/security/dex-strings.ts +242 -0
  56. package/src/security/repo-scan.ts +369 -0
  57. package/src/security/revision-probe.ts +144 -0
  58. package/src/security/terms.ts +97 -0
  59. package/src/security-allow.ts +1 -0
  60. package/src/security-baseline.ts +113 -0
  61. package/src/security-check.ts +91 -16
  62. package/src/server-text.ts +38 -0
  63. package/src/store.ts +142 -16
  64. package/src/transcript.ts +7 -1
  65. package/src/ui-language.ts +33 -0
  66. package/src/update/check.ts +7 -3
  67. package/src/update/handoff-system.ts +24 -16
  68. package/src/update/handoff.ts +23 -12
  69. package/src/update/helper.ts +72 -36
  70. package/src/update/request.ts +46 -30
  71. package/src/update/state.ts +21 -11
  72. package/src/update/strategy.ts +12 -5
  73. package/src/update/target.ts +32 -15
  74. package/src/usage.ts +43 -20
  75. package/src/version.ts +39 -16
  76. package/src/voice/agents.ts +8 -3
  77. package/src/voice/install.ts +29 -18
  78. package/src/work-watch.ts +40 -18
  79. package/web/agent-icon.js +15 -9
  80. package/web/agents.d.ts +201 -0
  81. package/web/agents.js +813 -0
  82. package/web/app-download.js +32 -19
  83. package/web/app.js +1407 -278
  84. package/web/autocopy.js +6 -1
  85. package/web/boot.js +27 -8
  86. package/web/commands.js +20 -11
  87. package/web/decisions.js +70 -49
  88. package/web/devlog.js +25 -19
  89. package/web/diagram.js +8 -3
  90. package/web/enroll.js +43 -22
  91. package/web/gc.js +32 -19
  92. package/web/i18n/agent-icon.js +24 -0
  93. package/web/i18n/agents.js +48 -0
  94. package/web/i18n/app-download.js +26 -0
  95. package/web/i18n/autocopy.js +5 -0
  96. package/web/i18n/boot.js +24 -0
  97. package/web/i18n/commands.js +34 -0
  98. package/web/i18n/decisions.js +24 -0
  99. package/web/i18n/devlog.js +60 -0
  100. package/web/i18n/diagram.js +12 -0
  101. package/web/i18n/enroll.js +22 -0
  102. package/web/i18n/gc.js +38 -0
  103. package/web/i18n/lastsent.js +10 -0
  104. package/web/i18n/main.d.ts +4 -0
  105. package/web/i18n/main.js +527 -0
  106. package/web/i18n/manual.d.ts +4 -0
  107. package/web/i18n/manual.js +14 -0
  108. package/web/i18n/memo.js +30 -0
  109. package/web/i18n/notifications.js +16 -0
  110. package/web/i18n/profile.js +13 -0
  111. package/web/i18n/reference.js +26 -0
  112. package/web/i18n/security-inbox.js +40 -0
  113. package/web/i18n/slash.js +38 -0
  114. package/web/i18n/turn-stats.js +5 -0
  115. package/web/i18n/update.js +42 -0
  116. package/web/i18n/voice.js +28 -0
  117. package/web/i18n/wake.js +16 -0
  118. package/web/i18n/workid.js +10 -0
  119. package/web/i18n/workstatus.js +20 -0
  120. package/web/i18n.d.ts +20 -0
  121. package/web/i18n.js +63 -0
  122. package/web/index.html +156 -96
  123. package/web/lastsent.js +9 -4
  124. package/web/manual.d.ts +1 -1
  125. package/web/manual.js +11 -5
  126. package/web/memo.js +18 -13
  127. package/web/notifications.js +12 -7
  128. package/web/profile.js +42 -17
  129. package/web/reference.js +20 -15
  130. package/web/security-inbox.d.ts +18 -1
  131. package/web/security-inbox.js +173 -4
  132. package/web/slash.js +23 -13
  133. package/web/style.css +132 -3
  134. package/web/tooltip.js +5 -2
  135. package/web/turn-stats.js +6 -2
  136. package/web/update.js +31 -23
  137. package/web/voice.js +18 -8
  138. package/web/wake.d.ts +4 -0
  139. package/web/wake.js +7 -3
  140. package/web/workid.js +12 -2
  141. 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: "이미지가 1MB보다 큽니다.", icon_type: "PNG, JPEG, WebP 이미지만 쓸 수 있습니다.", icon_empty: "빈 파일입니다." }[data?.error];
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(`에이전트 이름이 너무 깁니다. 한글은 ${AGENT_NAME_WIDTH / 2}자, 영문은 ${AGENT_NAME_WIDTH}자까지 쓸 수 있습니다. 이름을 바꾸지 않았습니다.`, true),
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
- const label = open ? "채팅으로 돌아가기" : "작업 상태 보기";
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("ko-KR", { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
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
- const t = document.createElement("span");
510
- t.className = "msg-tag";
511
- t.textContent = tag;
512
- head.append(t);
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
- readReceipt.el ??= Object.assign(document.createElement("span"), { className: "msg-read", textContent: "읽음" });
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.textContent = "thinking";
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 = { bash: "명령", eval: "코드 실행", write: "파일 쓰기", edit: "파일 수정", read: "파일 읽기" }[d.kind] ?? d.kind;
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 OPTION_LABELS = {
611
- "Allow once": "승인",
612
- "Allow always": "항상 승인",
613
- Deny: "거절",
614
- "Deny with feedback": "이유와 함께 거절",
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("연결이 끊겨 답변을 보낼 수 없습니다. 다시 연결되면 보내 주세요.", true);
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 ? "보내는 중…" : null });
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", `내 답변: ${describeAnswer(decision.answer)}`));
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 ? "권한" : decision.method === "question" ? "질문" : decision.method;
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", `${decision.deadlineAt ? `기한 ${timeLabel(decision.deadlineAt)}` : "기한 없음"} · ${kind}${binding}`));
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 = `${req.deadlineAt ? `기한 ${timeLabel(req.deadlineAt)}` : "기한 없음"} · ${req.permission ? "권한" : req.method === "question" ? "질문" : req.method}`;
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("보내기", "ok", () => {
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("취소", "neutral", () => answer(req, { cancel: true })),
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(OPTION_LABELS[opt] ?? opt, req.permission ? (OPTION_STYLE[opt] ?? "neutral") : "neutral", () => answer(req, { value: opt })));
998
+ actions.append(button(optionLabel(opt), req.permission ? (OPTION_STYLE[opt] ?? "neutral") : "neutral", () => answer(req, { value: opt })));
899
999
  }
900
- actions.append(button("취소", "neutral", () => answer(req, { cancel: true })));
1000
+ actions.append(button(t("common.cancel"), "neutral", () => answer(req, { cancel: true })));
901
1001
  } else if (req.method === "confirm") {
902
- actions.append(button("예", "ok", () => answer(req, { confirmed: true })), button("아니오", "danger", () => answer(req, { confirmed: false })));
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("보내기", "ok", () => answer(req, { value: box.value })), button("취소", "neutral", () => answer(req, { cancel: true })));
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 = `선택: ${OPTION_LABELS[a.value] ?? a.value}`;
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 = `답변: ${parts.join(" / ") || "(없음)"}`;
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 = { user: "", timeout: " (기한 만료)", cancelled: " (자동 취소)" }[res.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" ? "음성" : undefined;
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("zigmini가 작업을 시작했습니다.");
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(`오류: ${e.data.message}`, true);
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(`알림: ${e.data.message}`, e.data.level === "error");
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
- for (const c of state.conversations) {
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(id, resume = false) {
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
- selectConversation(state.conversations.some((c) => c.id === state.current) ? state.current : "main", true);
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) && e.conv === state.current) renderActivity();
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
- state.conversations.push(e.data);
1305
- selectConversation(e.data.id);
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(`음성 설치 실패: ${e.data.message}`, true);
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(`음성 재생 실패: ${err}`, true);
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(`음성 답변 실패: ${err instanceof Error ? err.message : err}`, true);
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(`음성 요약 실패(${err instanceof Error ? err.message : err}), 앞부분만 읽습니다.`, true);
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 ? "작업 상태 확인 중" : ready ? "엔진 연결됨" : omoStatus === "connecting" ? "엔진 연결 중" : "엔진 끊김";
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 ? `엔진 모델: ${info.current.name}` : "엔진 모델";
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 = `→ ${pending.name} 대기`;
1621
- modelPending.title = `${pending.name}(으)로 바꾸려면 컨텍스트를 줄여야 합니다. 다음 메시지를 보낼 때 압축한 뒤 적용됩니다. 그때까지 ${info.current?.name ?? "현재 모델"}을(를) 씁니다.`;
1622
- modelSelect.title += ` · ${pending.name}: 다음 메시지 때 압축 뒤 적용`;
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 = `모델 목록을 불러오지 못했습니다: ${error.message}`;
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 = `모델을 바꾸지 못했습니다: ${error.message}`;
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
- els.conn.textContent = "게이트웨이 연결됨";
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
- els.conn.textContent = "재연결 대기";
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("PNG, JPG, GIF, WebP 이미지만 보낼 수 있습니다.", true);
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(`이미지는 한 번에 ${MAX_ATTACH}장까지 보낼 수 있습니다.`, true);
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("이미지는 한 장당 10 MiB까지 업로드할 수 있습니다. 더 작은 이미지를 선택하세요.");
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("서버 또는 프록시가 요청 크기를 거부했습니다(HTTP 413). 더 작은 이미지를 선택하세요. 초안은 유지했습니다.");
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(`이미지를 올리지 못했습니다: ${error.message}`, true);
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 = `기기 등록 요청이 ${pairing.failures}번 거절·만료되어 잠겼습니다. 새 기기를 등록하려면 잠금을 푸세요.`;
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(`잠금 해제 실패: ${err.message}`, true));
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 = `새 기기 접속 요청: ${d.name} · 확인 번호 `;
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, " · 30초 안에 승인하지 않으면 자동 거절");
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(`기기 ${label} 실패: ${err.message}`, true));
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("승인", "ok", "/approve"), button("거절", "danger", "/deny"));
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 ? "기기 관리는 맥에서만 할 수 있습니다" : String(res.status));
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(`${deleteIndex}번 기기가 없습니다`);
2130
+ if (!target) throw new Error(t("device.missing", { index: deleteIndex }));
1983
2131
  await deviceAction(target.id, "", "DELETE");
1984
- systemLine(`기기 삭제: ${target.name} (이 기기는 다시 등록해야 접속할 수 있습니다)`);
2132
+ systemLine(t("device.deleted", { name: target.name }));
1985
2133
  return;
1986
2134
  }
1987
- const status = { pending: "승인 대기", approved: "승인됨", denied: "거절됨" };
1988
- const when = (ts) => (ts ? new Date(ts).toLocaleString("ko-KR", { dateStyle: "medium", timeStyle: "medium", hourCycle: "h23" }) : "접속 기록 없음");
1989
- systemLine(devices.length ? `등록된 기기: ${devices.map((d, i) => `${i + 1}. ${d.name} · ${status[d.status] ?? d.status} · 마지막 접속 ${when(d.lastSeen)}`).join(" / ")}` : "등록된 기기가 없습니다.");
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(`기기 명령 실패: ${err.message}`, true));
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
- slash.loading ??= fetch(`/api/commands?conv=${encodeURIComponent(state.current)}`)
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
- slash.omo = await res.json();
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 ? "엔진 명령과 스킬을 불러오는 중…" : slash.failed ? "엔진 명령과 스킬을 불러오지 못했습니다. 웹 지그 명령만 보입니다." : rows.length === 0 ? "맞는 명령이나 스킬이 없습니다." : "";
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("이 대화의 엔진 세션을 새로 시작할까요?\n화면의 기록은 그대로 남고, 최근 대화 일부를 새 세션에 넘겨 줍니다.")) return;
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 t of stream.getTracks()) t.stop();
2184
- els.mic.classList.remove("recording");
2349
+ for (const track of stream.getTracks()) track.stop();
2185
2350
  if (!voice.enabled) {
2186
- els.mic.textContent = "마이크";
2351
+ setMicState("idle");
2187
2352
  return;
2188
2353
  }
2189
- els.mic.classList.add("busy");
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(`음성 인식 실패: ${body.error}`, true);
2361
+ if (!res.ok) systemLine(t("voice.sttFailed", { error: body.error }), true);
2198
2362
  } catch (err) {
2199
- systemLine(`음성 인식 실패: ${err}`, true);
2363
+ systemLine(t("voice.sttFailed", { error: err }), true);
2200
2364
  } finally {
2201
- els.mic.classList.remove("busy");
2202
- els.mic.textContent = "마이크";
2365
+ setMicState("idle");
2203
2366
  }
2204
2367
  };
2205
2368
  recorder.start();
2206
- els.mic.classList.add("recording");
2207
- els.mic.textContent = "녹음 중 (눌러서 전송)";
2369
+ setMicState("recording");
2208
2370
  } catch (err) {
2209
- systemLine(`마이크를 쓸 수 없습니다: ${err}`, true);
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
- const labels = { off: "호출어 꺼짐", loading: "호출어 준비 중", listening: "헤이 지그 대기", armed: "말씀하세요" };
2220
- wakeButton.textContent = labels[status];
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
- sendButton.textContent = "전송";
2439
+ renderSendButton();
2277
2440
  sendButton.classList.remove("counting");
2278
- if (reason) systemLine(`자동 전송 취소 (${reason})`);
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
- sendButton.textContent = `전송 ${autoSend.left}`;
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(`호출어 인식 실패: ${err instanceof Error ? err.message : err}`, true);
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 = `들은 말: "${text.slice(0, 40)}" (호출어 아님)`;
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(`호출어 대기를 시작하지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
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 ? "박수 감지 꺼짐" : clap.ctx.state === "suspended" ? "박수 감지 (화면 클릭하면 시작)" : "박수 감지 켜짐";
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(`박수 감지를 시작하지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
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(`박수 감지를 켜지 못했습니다: ${err instanceof Error ? err.message : err}`, true));
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(`음성 설정을 바꾸지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
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.textContent = security.enabled ? "보안검사 켜짐" : "보안검사 꺼짐";
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(`보안검사 설정을 바꾸지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
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
- (held.source === "answer" ? "이번 답은 제출하지 않으며 원래 질문과 답변 작성 내용은 그대로 둡니다."
2606
- : "메시지는 같은 대화의 빈 입력창에만 복구하며 새 초안·첨부·참조는 바꾸지 않습니다.")));
2607
- const labels = { pending: "선택 대기", processing: "처리 중", sent: "전송 확인", cancelled: "이번 전송 취소", failed: "전송 실패", unconfirmed: "전송 결과 미확인 · 자동 재전송하지 않습니다" };
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("원본 전송하고 값 기억", "security-approve", view.approve, () => void securityInbox.decide(id, true));
2623
- button("이번 전송 취소", "security-cancel", view.cancel, () => void securityInbox.decide(id, false));
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("상태 확인", "security-refresh", inbox.online && !entry.busy, () => void securityInbox.detail(id));
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("삭제", "inbox-dismiss-open", true, () => {
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 ? "연결이 끊겼습니다." : model.busy ? "삭제 처리 중…" : model.loading ? "불러오는 중…" : model.error || (model.ready && model.values.length === 0 ? "기억한 값이 없습니다." : "");
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" ? "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 remove = make("button", "text-btn", "제거");
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
- row.append(remove); list.append(row);
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(`음성 설치를 시작하지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
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("ko-KR", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
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 OPTION_LABELS[a.value] ?? a.value;
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: `결정 상태를 불러오지 못했습니다 (${res.status}).`, retry });
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: "결정 식별자가 일치하지 않습니다.", retry });
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: `결정 상태를 불러오지 못했습니다: ${err instanceof Error ? err.message : String(err)}`,
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 ? "보내는 중…" : null });
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 ? `확인함, 확인 필요 ${n}건` : "확인함");
2963
- inbox.summary.textContent = c ? `확인 필요 ${c.pending + c.needsReview} · 전달 중 ${c.submitted}` : "";
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(`목록을 불러오지 못했습니다: ${inbox.error} `);
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(OPTION_LABELS[opt] ?? opt, decision.permission ? (OPTION_STYLE[opt] ?? "neutral") : "neutral", () => {
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 [["예", true, "ok"], ["아니오", false, "danger"]]) {
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", `${q.question} 직접 입력`);
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" ? (OPTION_LABELS[o.label] ?? o.label) : o.label);
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", `이전에 고른 답 (참고용, 최신 내용을 확인한 뒤 다시 골라야 보낼 수 있습니다): ${describeAnswer(lost.response) || "(없음)"}`);
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", `보낸 답 (전달 확인 안 됨, 참고용): ${describeAnswer(draft.response) || "(없음)"}`) : null;
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("최신 내용 확인", "ok", () => reviewLatest(decision), !busy));
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) ? "다시 보내기" : PRIMARY_LABELS[p.kind];
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("취소", "neutral", () => void submitDecision(decision.id, "answer", { cancelled: true })));
3198
- if (p.recheck) actions.append(button(PRIMARY_LABELS.recheck, "neutral", () => void submitDecisionForm(decision, "recheck")));
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("삭제", "neutral inbox-dismiss-open", () => openDismiss(decision.id), !busy && inbox.online && !inbox.dismissBusy.has(decision.id));
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, `대화 맥락 (${refs.length})`));
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" ? "나" : "main"), make("div", "inbox-context-text", ref.text));
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" ? "메인 대화" : decision.ownerConv),
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", `내 답변: ${describeAnswer(decision.answer)}`));
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("생성", dateTimeLabel(decision.createdAt));
3282
- row("기한", decision.deadlineAt === null ? (decision.bindingState === "live" ? "없음" : "") : `${dateTimeLabel(decision.deadlineAt)}${decision.bindingState === "expired" ? " (지남)" : ""}`);
3283
- row("상태", `${bindingLabel(decision)}${decision.required ? " · 필수" : ""}`);
3284
- row("대기 이유", waitReason(decision));
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 ? "지우는 중…" : text.confirm);
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, "다른 곳에서 먼저 처리됐거나 상태가 바뀌어 지우지 못했습니다. 최신 내용을 확인한 뒤 다시 지워 주세요.", true);
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, `서버가 삭제를 받지 않았습니다 (${result.status ?? 400}${result.error ? ` ${result.error}` : ""}).`, true);
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, "서버에 연결할 수 없어 지우지 못했습니다. 연결된 뒤 다시 지워 주세요.", true);
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, "연결이 끊겨 보낼 수 없습니다. 연결된 뒤 다시 시도하세요.", true);
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" ? "필수 항목을 모두 고르거나 적어야 보낼 수 있습니다." : "이 질문 형식은 웹에서 답할 수 없습니다. 현재 상황 확인을 이용하세요.", true);
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, "연결이 끊겨 보낼 수 없습니다. 연결된 뒤 다시 시도하세요.", true);
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, "최신 질문 내용을 확인한 뒤 다시 답해 주세요.", true);
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, "다른 곳에서 먼저 처리됐거나 질문이 바뀌었습니다. 최신 내용을 확인하고 다시 골라 주세요.", true);
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, `${result.message} 채팅의 보안검사 결과 카드에서 보내기 또는 보내지 않기를 고르세요.`, true);
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, `서버가 답변을 받지 않았습니다 (${result.error ?? "invalid"}).`, true);
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, `서버 오류 (${result.status}${result.error ? ` ${result.error}` : ""}). 작성 내용은 남아 있으니 잠시 후 다시 보내 주세요.`, true);
3875
+ noteFor(id, t("decision.unavailable", { status: result.status, error: result.error ? ` ${result.error}` : "" }), true);
3476
3876
  break;
3477
3877
  default:
3478
- noteFor(id, "서버에 연결할 수 없습니다. 작성 내용은 남아 있으니 연결된 뒤 다시 보내 주세요.", true);
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, "다른 곳에서 먼저 처리됐거나 질문이 바뀌었습니다. 최신 내용을 확인하고 다시 골라 주세요.", true);
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("원문 카드가 불러온 기록에 없습니다.", true);
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(`기기 등록 취소 실패: ${err.message}`, true));
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: "동의하고 업데이트", onGo: async () => {
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: "계속", onGo: () => {
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: "그래도 업데이트", onGo: () => {
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 ACCESS_LABELS = { lan: "집 Wi-Fi만", tailscale: "Tailscale", domain: "자기 도메인" };
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.current.textContent = `외부 접속 상태를 읽지 못했습니다: ${err.message}`;
4238
+ accessEls.body = null;
4239
+ accessEls.current.textContent = t("settings.access.readFailed", { error: err.message });
3822
4240
  return;
3823
4241
  }
3824
- const off = !(body.appPort > 0);
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("확인하는 중…", false);
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 ?? `실패 (HTTP ${res.status})`, true);
4255
+ showAccessMessage(body.message ?? t("settings.access.failedHttp", { status: res.status }), true);
3844
4256
  return;
3845
4257
  }
3846
- showAccessMessage("바꿨습니다. 새 주소로 등록 QR을 다시 만듭니다.", false);
4258
+ showAccessMessage(t("settings.access.changed"), false);
3847
4259
  await loadAccess();
3848
4260
  void enrollment.renew();
3849
4261
  } catch (err) {
3850
- showAccessMessage(`실패: ${err.message}`, true);
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
- const view = gcView(body);
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()) : `정리 실패: HTTP ${res.status}`;
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 = `정리 실패: ${err.message}`;
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 (Korean first) rendered with the chat Markdown renderer; the first-line language links
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: "ko", load: 0 };
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) === "en" ? "English" : "한국어";
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(manualView.lang);
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();