zigmini 0.8.0-beta.5 → 0.8.0-beta.7

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 (57) hide show
  1. package/README.ko.md +78 -19
  2. package/README.md +79 -19
  3. package/assets/agent/gateway-request-rules.md +1 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +25 -2
  9. package/docs/manual.md +25 -2
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +171 -8
  17. package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
  18. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  19. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  20. package/package.json +4 -1
  21. package/scripts/secret-rules.ts +101 -0
  22. package/scripts/secret-scan.allow.json +155 -0
  23. package/scripts/secret-scan.rules.json +30 -0
  24. package/src/agent-request-rules.ts +82 -0
  25. package/src/cli/access.ts +31 -34
  26. package/src/cli/launchd.ts +30 -4
  27. package/src/cli/main.ts +13 -39
  28. package/src/cli/messages.ts +160 -0
  29. package/src/cli/pair.ts +32 -25
  30. package/src/cli/rotate-main.ts +411 -0
  31. package/src/cli/setup.ts +164 -75
  32. package/src/decisions.ts +20 -1
  33. package/src/hub.ts +140 -30
  34. package/src/main.ts +45 -4
  35. package/src/omo/client.ts +28 -0
  36. package/src/protocol.ts +33 -1
  37. package/src/reply-notifications.ts +144 -0
  38. package/src/runtime/paths.ts +2 -0
  39. package/src/security-check.ts +241 -0
  40. package/src/store.ts +71 -1
  41. package/src/turn-stats.ts +49 -0
  42. package/src/work-watch.ts +36 -10
  43. package/src/work.ts +29 -4
  44. package/web/app.js +197 -10
  45. package/web/decisions.js +3 -0
  46. package/web/gc.d.ts +2 -0
  47. package/web/gc.js +11 -0
  48. package/web/index.html +26 -10
  49. package/web/notifications.d.ts +25 -0
  50. package/web/notifications.js +118 -0
  51. package/web/style.css +16 -0
  52. package/web/tooltip.d.ts +0 -6
  53. package/web/tooltip.js +8 -11
  54. package/web/turn-stats.d.ts +26 -0
  55. package/web/turn-stats.js +58 -0
  56. package/web/update.d.ts +1 -1
  57. package/web/update.js +10 -3
package/src/work.ts CHANGED
@@ -3,6 +3,7 @@ import { isAbsolute, join } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { OmoCommandError, type OmoClient, type OmoEvent, type OmoStatus } from "./omo/client.ts";
5
5
  import { DecisionStoreError, Store, WorkStoreError, type LedgerState, type MainInput, type StoredEvent, type WorkCommand, type WorkItem, type WorkReport, type WorkUpdateKind } from "./store.ts";
6
+ import { nativeReplyEntry, type NativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
6
7
 
7
8
  const ACK_TIMEOUT_MS = 30_000;
8
9
  const PREFIX = "ZGW_WORK_COMMAND ";
@@ -15,7 +16,7 @@ type State = {
15
16
  ordered?: { text: string; mode?: string; clientInputId?: string }[];
16
17
  pendingQuestions?: OmoEvent[]; atomicQueuedResume?: boolean; queueEpoch?: string;
17
18
  };
18
- type Entry = { id: string; type: string; customType?: string; data?: unknown; message?: { role: string; content: unknown } };
19
+ type Entry = NativeReplyEntry & { id: string; type: string; customType?: string; data?: unknown };
19
20
  export interface WorkSessionOwner {
20
21
  readonly conv: string;
21
22
  readonly scope: `work:${string}`;
@@ -30,6 +31,7 @@ export interface WorkRequest {
30
31
  payload: RecordValue;
31
32
  }
32
33
  export interface WorkRouterOptions {
34
+ replies?: NativeReplyObserver;
33
35
  sessionDir: string;
34
36
  permissionPreset: string;
35
37
  /** Invoked only for newly committed events. Task 6 also uses this boundary for report-turn metadata. */
@@ -121,6 +123,16 @@ export class WorkRouter {
121
123
  }
122
124
 
123
125
  private get ready(): boolean { return !this.stopped && this.status === "ready" && this.omo.currentStatus === "ready"; }
126
+ /** Includes finished, normally lazy sources so enabling never backfills their old replies. */
127
+ async notificationBaseline(): Promise<void> {
128
+ for (const work of this.allWorks()) {
129
+ const binding = await this.ensure(work);
130
+ const revision = this.options.replies?.revision();
131
+ const entries = await this.wholeSession(binding.sessionId, () => this.current(work.id, binding));
132
+ if (!entries || !this.current(work.id, binding)) throw new Error("reply_baseline_discarded");
133
+ if (revision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId: work.id, title: work.title }, entries, revision);
134
+ }
135
+ }
124
136
  private allWorks(): WorkItem[] { return this.store.listConversations().flatMap((conv) => this.store.listWorks(conv.id)); }
125
137
  private work(id: string): WorkItem { const work = this.store.getWork(id); if (!work) throw new RoutingError("work_not_found", 404); return work; }
126
138
  private current(workId: string, binding: Binding): boolean { return this.ready && binding.generation === this.generation && this.bindings.get(workId) === binding; }
@@ -463,7 +475,7 @@ export class WorkRouter {
463
475
  const saved = this.store.getSetting("omo.model");
464
476
  const model: { provider: string; modelId: string } | null = saved ? JSON.parse(saved) : null;
465
477
  const promise = this.omo.command<{ sessionId: string; attached?: boolean; state?: State }>("open_session", {
466
- sessionPath: work.sessionPath, cwd: work.cwd, permissionPreset: this.options.permissionPreset, kind: "worker", retain_on_disconnect: true, auto_title: false,
478
+ sessionPath: work.sessionPath, cwd: work.cwd, permissionPreset: this.options.permissionPreset, kind: "worker", promptSurface: "app", retain_on_disconnect: true, auto_title: false,
467
479
  // Native applies these only on first creation, never on a persisted resume.
468
480
  ...(model ? { provider: model.provider, modelId: model.modelId } : {}),
469
481
  context: { zgw_role: "work", zgw_work: work.id, zgw_conv: work.ownerConv },
@@ -580,11 +592,16 @@ export class WorkRouter {
580
592
  private async readEvidence(workId: string): Promise<void> {
581
593
  const work = this.work(workId);
582
594
  const binding = await this.ensure(work);
595
+ let replyRevision = this.options.replies?.revision();
583
596
  const [entries, state] = await Promise.all([
584
- this.entriesAfter(binding.sessionId, this.store.workEntryCursor(workId), () => this.current(workId, binding)),
597
+ this.entriesAfter(binding.sessionId, this.store.workEntryCursor(workId), () => {
598
+ replyRevision = this.options.replies?.revision();
599
+ return this.current(workId, binding);
600
+ }),
585
601
  this.omo.command<State>("get_state", { sessionId: binding.sessionId }),
586
602
  ]);
587
603
  if (!entries || !this.current(workId, binding)) return;
604
+ if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId, title: work.title }, entries, replyRevision);
588
605
  if (typeof state.isStreaming === "boolean") this.options.onRuntime?.(workId, state.isStreaming);
589
606
  for (const entry of entries) {
590
607
  if (entry.type === "custom" && (entry.customType === "zgw.native_permission_asked" || entry.customType === "zgw.native_permission_replied")) {
@@ -644,6 +661,12 @@ export class WorkRouter {
644
661
  const [workId, binding] = found;
645
662
  if (!this.current(workId, binding)) return false;
646
663
  this.options.onWorkEvent?.(workId, event);
664
+ if (this.options.replies && event.type === "entry_appended") {
665
+ const entry = nativeReplyEntry(event.entry);
666
+ const work = this.work(workId);
667
+ if (entry) this.options.replies.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId, title: work.title },
668
+ [entry], this.options.replies.revision());
669
+ }
647
670
  if (event.type === "extension_ui_request" && event.method === "notify" && this.store.isOmoCheckWork(workId)) {
648
671
  // Preserve attribution before Hub turns the frame into an unattributed main-chat notice.
649
672
  const sourceKey = `omo-check-notice:${typeof event.id === "string" ? event.id : crypto.randomUUID()}`;
@@ -725,8 +748,10 @@ export class WorkRouter {
725
748
  // the native per-connection queue and the bridge frontend budget, dropping this connection.
726
749
  for (const work of works) {
727
750
  if (!this.ready || generation !== this.generation) return;
728
- // Finished sessions are opened on demand by owner(), reconcile() or dispatch().
751
+ // A completed report may precede the final assistant reply. Enabled notifications
752
+ // must recover that tail even when command delivery already reached its terminal state.
729
753
  if (["completed", "failed", "cancelled"].includes(work.status) &&
754
+ this.options.replies?.settings().mode !== "all-replies" &&
730
755
  !this.store.listWorkCommands(work.id).some((command) => ["pending", "sending", "unknown"].includes(command.delivery))) continue;
731
756
  try {
732
757
  await this.reconcile(work.id);
package/web/app.js CHANGED
@@ -28,6 +28,7 @@ import { hasTextSelection, panelShortcut } from "./shortcuts.js";
28
28
  import { decisionCommand, lastSentLabel, lastSentTooltip, nextLastSent } from "./lastsent.js";
29
29
  import { installTooltips } from "./tooltip.js";
30
30
  import { placeRow, unseenEvents } from "./chatorder.js";
31
+ import { createTurnStats } from "./turn-stats.js";
31
32
  import { cardsOfDecision } from "./cardbind.js";
32
33
  import { createRecall, recallKey, resetRecall } from "./recall.js";
33
34
  import {
@@ -42,7 +43,8 @@ import {
42
43
  import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } from "./enroll.js";
43
44
  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";
44
45
  import { createAppDownload } from "./app-download.js";
45
- import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
46
+ import { setupNotificationSettings } from "./notifications.js";
47
+ import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
46
48
  import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
47
49
 
48
50
  installTooltips(document, window);
@@ -60,6 +62,7 @@ const state = {
60
62
  // Work activity the gateway reports per conversation (snapshot + activity.updated). It drives the avatar only;
61
63
  // state.running stays the sole input of #interrupt, and history pages never write here.
62
64
  const activity = createActivity();
65
+ const turnStats = createTurnStats();
63
66
  const HISTORY_PAGE = 100;
64
67
  // Loaded window of the current conversation's stored events (by seq), for resuming after a reconnect and paging back.
65
68
  let hist = { conv: null, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
@@ -984,8 +987,12 @@ function renderEvent(e, fromHistory = false) {
984
987
  }
985
988
  const source = referenceSource(e);
986
989
  if (source) markReferenceable(body.parentElement, source);
990
+ turnStats.accept(e, body);
987
991
  break;
988
992
  }
993
+ case "message.stats":
994
+ turnStats.accept(e);
995
+ break;
989
996
  case "progress.update":
990
997
  renderProgress(e);
991
998
  break;
@@ -1096,6 +1103,7 @@ function rememberSent(e) {
1096
1103
 
1097
1104
  function clearMessages(conv) {
1098
1105
  els.messages.innerHTML = "";
1106
+ turnStats.reset(conv);
1099
1107
  setLastSent(null);
1100
1108
  recall.sent.length = 0;
1101
1109
  resetRecall(recall.rec);
@@ -1318,6 +1326,18 @@ function onServerEvent(e, generation, statusGeneration) {
1318
1326
  showUpdateProgress(resultLine(e.data));
1319
1327
  loadUpdateStatus();
1320
1328
  return;
1329
+ case "security-check.updated":
1330
+ applySecurityBody(e.data);
1331
+ return;
1332
+ case "notifications.updated":
1333
+ notificationSettings.updated(e.data);
1334
+ return;
1335
+ case "security-check.held":
1336
+ showHeldCard(e.data);
1337
+ return;
1338
+ case "security-check.resolved":
1339
+ markHeld(e.data.id, e.data.outcome);
1340
+ return;
1321
1341
  case "voice.updated":
1322
1342
  voice.reads += 1;
1323
1343
  void applyVoiceSetting(voiceEnabledFrom(e.data));
@@ -1648,6 +1668,9 @@ function connect() {
1648
1668
  sendWebPresence();
1649
1669
  resendPendingReportSpeech();
1650
1670
  void readVoiceSetting();
1671
+ void readSecuritySetting();
1672
+ void notificationSettings.connect();
1673
+ void readHeld();
1651
1674
  void readVoiceInstall();
1652
1675
  // Also after an update restarted the gateway: the new version and the update result show without a reload.
1653
1676
  loadUpdateStatus();
@@ -1666,6 +1689,7 @@ function connect() {
1666
1689
  state.sessionTransitions.clear();
1667
1690
  renderSessionTransition();
1668
1691
  inbox.online = false;
1692
+ notificationSettings.disconnect();
1669
1693
  historyRequests.length = 0;
1670
1694
  renderInboxOnline();
1671
1695
  setTimeout(connect, 1500);
@@ -2482,6 +2506,134 @@ async function setVoiceEnabled(enabled) {
2482
2506
  voiceButton.onclick = () => setVoiceEnabled(!voice.enabled);
2483
2507
  renderVoiceToggle();
2484
2508
 
2509
+ // ---- Improvement 165: the security check of every user message (settings toggle, blocked draft back in the composer) ----
2510
+ const SECURITY_CHECK_PATH = "/api/settings/security-check";
2511
+ const notificationSettings = setupNotificationSettings({ choice: $("notification-mode"), status: $("notification-status") });
2512
+ const security = { enabled: true, busy: false };
2513
+ const securityButton = $("toggle-security");
2514
+
2515
+ function renderSecurityToggle() {
2516
+ securityButton.textContent = security.enabled ? "보안검사 켜짐" : "보안검사 꺼짐";
2517
+ securityButton.setAttribute("aria-pressed", String(security.enabled));
2518
+ securityButton.classList.toggle("on", security.enabled);
2519
+ }
2520
+
2521
+ function applySecurityBody(body) {
2522
+ if (typeof body?.enabled === "boolean") security.enabled = body.enabled;
2523
+ renderSecurityToggle();
2524
+ }
2525
+
2526
+ async function readSecuritySetting() {
2527
+ try {
2528
+ const res = await fetch(SECURITY_CHECK_PATH, { cache: "no-store" });
2529
+ if (res.ok) applySecurityBody(await res.json());
2530
+ } catch {
2531
+ // Offline: the reconnect reads it again.
2532
+ }
2533
+ }
2534
+
2535
+ async function setSecurityEnabled(enabled) {
2536
+ if (security.busy) return;
2537
+ security.busy = true;
2538
+ try {
2539
+ const res = await fetch(SECURITY_CHECK_PATH, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
2540
+ const body = await res.json().catch(() => ({}));
2541
+ if (!res.ok) throw new Error(body.error ?? String(res.status));
2542
+ applySecurityBody(body);
2543
+ } catch (err) {
2544
+ systemLine(`보안검사 설정을 바꾸지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
2545
+ } finally {
2546
+ security.busy = false;
2547
+ }
2548
+ }
2549
+
2550
+ /** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
2551
+ function restoreDraft(conv, text) {
2552
+ if (typeof text !== "string" || state.current !== conv || els.input.value !== "") return;
2553
+ els.input.value = text;
2554
+ els.input.focus();
2555
+ }
2556
+
2557
+ // The card is built here from the gateway's held entry and never passes through the engine; values are shown as text.
2558
+ const heldCards = new Map();
2559
+
2560
+ function showHeldCard(held) {
2561
+ if (!held || typeof held.id !== "string" || heldCards.has(held.id)) return;
2562
+ const card = document.createElement("div");
2563
+ card.className = "msg system security-card";
2564
+ card.dataset.heldId = held.id;
2565
+ card.setAttribute("role", "group");
2566
+ const title = make("div", "security-title", held.source === "answer" ? "보안검사 결과 (확인함 답)" : "보안검사 결과");
2567
+ const list = document.createElement("ul");
2568
+ list.className = "security-list";
2569
+ for (const o of held.occurrences ?? []) {
2570
+ const item = document.createElement("li");
2571
+ item.append(make("span", "security-kind", o.label), " ", make("code", "security-value", o.value), " ", make("span", "security-place", `${o.line}줄 ${o.column}칸`));
2572
+ list.append(item);
2573
+ }
2574
+ const question = make("p", "security-question", "이 값들을 노출해도 되겠습니까? 보내기를 누르면 원래 메시지를 그대로 엔진에 보냅니다.");
2575
+ const actions = make("div", "security-actions");
2576
+ const sendButton = make("button", "btn", "보내기");
2577
+ const dropButton = make("button", "btn neutral", "보내지 않기");
2578
+ sendButton.type = dropButton.type = "button";
2579
+ sendButton.onclick = () => void decideHeld(held, true);
2580
+ dropButton.onclick = () => void decideHeld(held, false);
2581
+ actions.append(sendButton, dropButton);
2582
+ const status = make("p", "security-status");
2583
+ status.setAttribute("role", "status");
2584
+ card.append(title, list, question, actions, status);
2585
+ heldCards.set(held.id, { card, held });
2586
+ if (held.conv === state.current || held.source === "answer") {
2587
+ els.messages.append(card);
2588
+ scrollToBottom(true);
2589
+ }
2590
+ }
2591
+
2592
+ function markHeld(id, outcome, message) {
2593
+ const entry = heldCards.get(id);
2594
+ if (!entry) return;
2595
+ for (const button of entry.card.querySelectorAll("button")) button.disabled = true;
2596
+ const status = entry.card.querySelector(".security-status");
2597
+ status.textContent = message ?? (outcome === "sent" ? "보냈습니다." : outcome === "lost" ? "게이트웨이가 다시 시작해 보류 메시지가 사라졌습니다. 엔진에는 보내지 않았습니다." : "보내지 않았습니다. 메시지를 버렸습니다.");
2598
+ entry.card.classList.add("done");
2599
+ if (outcome !== "sent" && entry.held.source === "message") restoreDraft(entry.held.conv, entry.held.draft);
2600
+ }
2601
+
2602
+ async function decideHeld(held, send) {
2603
+ const entry = heldCards.get(held.id);
2604
+ if (!entry || entry.card.classList.contains("done")) return;
2605
+ for (const button of entry.card.querySelectorAll("button")) button.disabled = true;
2606
+ try {
2607
+ const res = await fetch(`/api/security-check/held/${encodeURIComponent(held.id)}`, {
2608
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ send }),
2609
+ });
2610
+ const body = await res.json().catch(() => ({}));
2611
+ if (res.status === 404) markHeld(held.id, "lost");
2612
+ else if (!res.ok) markHeld(held.id, "failed", `보내지 못했습니다: ${body.message ?? body.error ?? res.status}`);
2613
+ else markHeld(held.id, body.outcome);
2614
+ } catch (err) {
2615
+ for (const button of entry.card.querySelectorAll("button")) button.disabled = false;
2616
+ entry.card.querySelector(".security-status").textContent = `연결 오류: ${err instanceof Error ? err.message : err}`;
2617
+ }
2618
+ }
2619
+
2620
+ /** On every connect: cards still held come back; a card whose entry is gone (gateway restart) is marked lost. */
2621
+ async function readHeld() {
2622
+ try {
2623
+ const res = await fetch("/api/security-check/held", { cache: "no-store" });
2624
+ if (!res.ok) return;
2625
+ const { held } = await res.json();
2626
+ const ids = new Set(held.map((h) => h.id));
2627
+ for (const [id, entry] of heldCards) if (!ids.has(id) && !entry.card.classList.contains("done")) markHeld(id, "lost");
2628
+ for (const h of held) showHeldCard(h);
2629
+ } catch {
2630
+ // Offline: the next connect reads again.
2631
+ }
2632
+ }
2633
+
2634
+ securityButton.onclick = () => setSecurityEnabled(!security.enabled);
2635
+ renderSecurityToggle();
2636
+
2485
2637
  // ---- Todo 26: an npm install installs the voice servers on demand; the button is hidden everywhere else ----
2486
2638
  const voiceInstallButton = $("voice-install");
2487
2639
 
@@ -3175,6 +3327,12 @@ function settleSubmit(decision, result) {
3175
3327
  noteConflict(inbox.state, decision, result.decision);
3176
3328
  noteFor(id, "다른 곳에서 먼저 처리됐거나 질문이 바뀌었습니다. 최신 내용을 확인하고 다시 골라 주세요.", true);
3177
3329
  break;
3330
+ case "blocked":
3331
+ // Improvement 165: the answer is held; the card decides. The typed answer stays in the form for a fix.
3332
+ inbox.state.drafts.delete(id);
3333
+ noteFor(id, `${result.message} 채팅의 보안검사 결과 카드에서 보내기 또는 보내지 않기를 고르세요.`, true);
3334
+ if (result.held) showHeldCard(result.held);
3335
+ break;
3178
3336
  case "rejected":
3179
3337
  inbox.state.drafts.delete(id);
3180
3338
  if (result.decision) applyDecision(inbox.state, result.decision);
@@ -3422,14 +3580,23 @@ async function checkUpdateNow() {
3422
3580
  else showUpdateError(checkFailedLine(res?.status ?? 0));
3423
3581
  }
3424
3582
 
3583
+ // The version this page first loaded on: a stored ok result that ended there is stale after a reload (live update.result is not filtered).
3584
+ let pageVersion = null;
3585
+
3425
3586
  function loadUpdateStatus() {
3426
3587
  fetch(UPDATE_STATUS_PATH)
3427
3588
  .then((res) => (res.ok ? res.json() : null))
3428
- .then(renderUpdate)
3429
- .catch((error) => console.warn("update status load:", error));
3430
- fetch(UPDATE_STATE_PATH)
3589
+ .catch((error) => {
3590
+ console.warn("update status load:", error);
3591
+ return null;
3592
+ })
3593
+ .then((status) => {
3594
+ renderUpdate(status);
3595
+ if (pageVersion === null && typeof status?.current === "string") pageVersion = status.current;
3596
+ return fetch(UPDATE_STATE_PATH);
3597
+ })
3431
3598
  .then((res) => (res.ok ? res.json() : null))
3432
- .then((state) => showUpdateProgress(progressLine(state)))
3599
+ .then((state) => showUpdateProgress(progressLine(state, pageVersion)))
3433
3600
  .catch((error) => console.warn("update state load:", error));
3434
3601
  }
3435
3602
 
@@ -3583,7 +3750,28 @@ const appDownload = createAppDownload({
3583
3750
  apkEls.renew.onclick = () => void appDownload.renew();
3584
3751
 
3585
3752
  // 정리 (improvement 66): nothing is cleaned until the user ticks rows and presses the button.
3586
- const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean") };
3753
+ const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label") };
3754
+
3755
+ function gcBoxes() {
3756
+ return [...gcEls.list.querySelectorAll("input[type=checkbox]")];
3757
+ }
3758
+
3759
+ // Improvement 142: the 전체 선택 box and the clean button follow the ticked rows.
3760
+ function gcSync() {
3761
+ const ticked = gcTicked().length;
3762
+ const state = selectAllState(ticked, gcBoxes().length);
3763
+ gcEls.allLabel.hidden = state.hidden;
3764
+ gcEls.all.checked = state.checked;
3765
+ gcEls.all.indeterminate = state.indeterminate;
3766
+ gcEls.clean.disabled = ticked === 0;
3767
+ }
3768
+
3769
+ gcEls.all.onchange = () => {
3770
+ const boxes = gcBoxes();
3771
+ const tick = selectAllTarget(gcTicked().length, boxes.length);
3772
+ for (const box of boxes) box.checked = tick;
3773
+ gcSync();
3774
+ };
3587
3775
 
3588
3776
  function gcTicked() {
3589
3777
  return [...gcEls.list.querySelectorAll("input[type=checkbox]:checked")].map((box) => box.value);
@@ -3598,16 +3786,14 @@ function gcRow(row) {
3598
3786
  const box = document.createElement("input");
3599
3787
  box.type = "checkbox";
3600
3788
  box.value = row.id;
3601
- box.onchange = () => { gcEls.clean.disabled = gcTicked().length === 0; };
3789
+ box.onchange = gcSync;
3602
3790
  const name = document.createElement("span");
3603
3791
  name.className = "gc-name";
3604
3792
  name.textContent = row.label;
3605
- name.dataset.tip = row.title;
3606
- name.dataset.tipOverflow = "";
3793
+ name.title = row.title;
3607
3794
  const sub = document.createElement("span");
3608
3795
  sub.className = "gc-sub";
3609
3796
  sub.textContent = row.sub;
3610
- sub.dataset.tipOverflow = "";
3611
3797
  main.append(name, sub);
3612
3798
  label.append(box, main);
3613
3799
  li.append(label);
@@ -3627,6 +3813,7 @@ async function loadGc() {
3627
3813
  gcEls.summary.textContent = view.summary;
3628
3814
  gcEls.summary.classList.toggle("error", view.error);
3629
3815
  gcEls.list.replaceChildren(...view.rows.map(gcRow));
3816
+ gcSync();
3630
3817
  }
3631
3818
 
3632
3819
  gcEls.clean.onclick = async () => {
package/web/decisions.js CHANGED
@@ -473,6 +473,9 @@ export function interpretAnswerResponse(status, body) {
473
473
  if (status === 409) return { kind: "conflict", decision, error };
474
474
  if (status === 404) return { kind: "gone", error };
475
475
  if (status === 400) return { kind: "rejected", decision, error };
476
+ if (status === 422 && error === "security_check_blocked") {
477
+ return { kind: "blocked", error, held: record(body.held) ? body.held : null, message: typeof body.message === "string" ? body.message : "보안검사에 걸려 보내지 않고 보류했습니다." };
478
+ }
476
479
  return { kind: "unavailable", status, error };
477
480
  }
478
481
 
package/web/gc.d.ts CHANGED
@@ -9,5 +9,7 @@ export const GC_FAILED_LINE: string;
9
9
  export function formatBytes(bytes: unknown): string;
10
10
  export function formatAge(ms: number): string;
11
11
  export function gcView(report: unknown): { summary: string; rows: GcRow[]; error: boolean };
12
+ export function selectAllState(ticked: number, total: number): { hidden: boolean; checked: boolean; indeterminate: boolean };
13
+ export function selectAllTarget(ticked: number, total: number): boolean;
12
14
  export function cleanRequest(ids: Iterable<string>): { method: "POST"; headers: { "content-type": "application/json" }; body: string };
13
15
  export function cleanResultLine(result: unknown): string;
package/web/gc.js CHANGED
@@ -39,6 +39,17 @@ export function gcView(report) {
39
39
  return { summary, rows, error: false };
40
40
  }
41
41
 
42
+ // Improvement 142: the 전체 선택 box beside the summary. It is hidden with no rows, checked when every row is ticked and
43
+ // indeterminate when only some are.
44
+ export function selectAllState(ticked, total) {
45
+ return { hidden: total === 0, checked: total > 0 && ticked === total, indeterminate: ticked > 0 && ticked < total };
46
+ }
47
+
48
+ // A click on 전체 선택 ticks every row unless every row was already ticked; then it clears them all.
49
+ export function selectAllTarget(ticked, total) {
50
+ return ticked < total;
51
+ }
52
+
42
53
  export function cleanRequest(ids) {
43
54
  return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids: [...ids] }) };
44
55
  }
package/web/index.html CHANGED
@@ -62,14 +62,14 @@
62
62
  <div class="chat-actions">
63
63
  <select class="model-select" id="model-select" title="엔진이 쓸 모델" disabled><option>모델 불러오는 중</option></select>
64
64
  <span class="model-pending" id="model-pending" hidden></span>
65
- <button class="text-btn" id="rotate-session" title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
66
- <button class="text-btn" id="toggle-voice" title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
67
- <button class="text-btn" id="voice-install" title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
68
- <button class="text-btn" id="toggle-clap" title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
69
- <button class="text-btn" id="toggle-wake" title="&quot;헤이 지그&quot;라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
70
- <button class="text-btn" id="toggle-tts" title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
71
- <button type="button" class="text-btn inbox-toggle" id="toggle-inbox" title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
72
- <button type="button" class="text-btn" id="toggle-dev" title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
65
+ <button class="text-btn" id="rotate-session" data-tip-keep title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
66
+ <button class="text-btn" id="toggle-voice" data-tip-keep title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
67
+ <button class="text-btn" id="voice-install" data-tip-keep title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
68
+ <button class="text-btn" id="toggle-clap" data-tip-keep title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
69
+ <button class="text-btn" id="toggle-wake" data-tip-keep title="&quot;헤이 지그&quot;라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
70
+ <button class="text-btn" id="toggle-tts" data-tip-keep title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
71
+ <button type="button" class="text-btn inbox-toggle" id="toggle-inbox" data-tip-keep title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
72
+ <button type="button" class="text-btn" id="toggle-dev" data-tip-keep title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
73
73
  <button type="button" class="text-btn" id="toggle-settings" title="설정: 폰 기기 등록 QR과 등록된 기기" aria-haspopup="dialog" aria-expanded="false" aria-controls="settings">설정</button>
74
74
  </div>
75
75
  </header>
@@ -100,7 +100,7 @@
100
100
  <button type="button" class="mic" id="toggle-memo" title="쪽지 열기/닫기" aria-pressed="false">쪽지</button>
101
101
  <button type="button" class="mic" id="attach" title="이미지 첨부 (최대 4장 · 붙여넣기나 끌어놓기도 됩니다)">이미지</button>
102
102
  <input type="file" id="attach-input" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
103
- <button type="button" class="mic" id="mic" title="누르고 말하기 (다시 누르면 전송)">마이크</button>
103
+ <button type="button" class="mic" id="mic" data-tip-keep title="누르고 말하기 (다시 누르면 전송)">마이크</button>
104
104
  <textarea id="input" rows="1" placeholder="메시지 보내기" title="Enter 전송, Shift+Enter 줄바꿈"></textarea>
105
105
  <button type="submit" class="send" id="send">전송</button>
106
106
  <button type="button" class="stop" id="interrupt" title="진행 중인 작업 중단" hidden>중단</button>
@@ -177,8 +177,24 @@
177
177
  <p class="enroll-message" id="apk-message" role="status" aria-live="polite"></p>
178
178
  <div class="enroll-actions"><button type="button" class="text-btn" id="apk-renew" hidden>새로 만들기</button></div>
179
179
  </section>
180
+ <section class="settings-section security-check" id="security-check" aria-labelledby="security-check-title">
181
+ <div class="section-label"><span id="security-check-title">보안검사</span></div>
182
+ <p class="update-line">보낸 메시지에 비밀키, 토큰, 비밀번호, 홈 폴더 경로, 내부 IP 같은 값이 있으면 엔진에 보내지 않고 보류한 뒤, 값을 보여 주고 보낼지 묻습니다.</p>
183
+ <div class="update-buttons"><button type="button" class="text-btn" id="toggle-security" aria-pressed="true">보안검사 켜짐</button></div>
184
+ </section>
185
+ <section class="settings-section" id="notification-settings" aria-labelledby="notification-title">
186
+ <div class="section-label"><label id="notification-title" for="notification-mode">알림</label></div>
187
+ <div class="access-form">
188
+ <select class="access-input" id="notification-mode" aria-describedby="notification-description notification-status" disabled>
189
+ <option value="inbox-only" selected>확인함 알림만</option>
190
+ <option value="all-replies">모든 답장 알림</option>
191
+ </select>
192
+ </div>
193
+ <p class="update-note" id="notification-description">모든 답장 알림은 확인함 알림과 메인·작업의 완료된 답장 알림을 포함합니다. 이 게이트웨이에 연결된 모든 기기에 적용됩니다.</p>
194
+ <p class="enroll-message" id="notification-status" role="status" aria-live="polite"></p>
195
+ </section>
180
196
  <section class="settings-section gc" id="gc" aria-labelledby="gc-title">
181
- <div class="section-label"><span id="gc-title">정리</span><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span></div>
197
+ <div class="section-label"><span id="gc-title">정리</span><span class="gc-head"><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span><label class="gc-all" id="gc-all-label" hidden><input type="checkbox" id="gc-all">전체 선택</label></span></div>
182
198
  <ul class="gc-list" id="gc-list" aria-label="정리 후보"></ul>
183
199
  <p class="gc-message" id="gc-message" role="status" aria-live="polite"></p>
184
200
  <div class="gc-actions"><button type="button" class="text-btn" id="gc-clean" disabled>선택한 항목 정리</button></div>
@@ -0,0 +1,25 @@
1
+ export type NotificationMode = "inbox-only" | "all-replies";
2
+ export const NOTIFICATION_SETTINGS_PATH: string;
3
+ export interface NotificationSettingsView {
4
+ readonly mode: NotificationMode;
5
+ readonly ready: boolean;
6
+ readonly online: boolean;
7
+ readonly busy: boolean;
8
+ readonly error: string;
9
+ }
10
+ export interface NotificationSettingsController {
11
+ readonly view: () => NotificationSettingsView;
12
+ readonly read: () => Promise<void>;
13
+ readonly set: (mode: NotificationMode) => Promise<void>;
14
+ readonly connect: () => Promise<void>;
15
+ readonly disconnect: () => void;
16
+ readonly updated: (body: unknown) => void;
17
+ }
18
+ export type SettingsFetch = (path: string, init: RequestInit) => Promise<Pick<Response, "ok" | "json">>;
19
+ export function notificationMode(body: unknown): NotificationMode | null;
20
+ export function createNotificationSettings(fetcher: SettingsFetch, render: (view: NotificationSettingsView) => void): NotificationSettingsController;
21
+ export interface NotificationSettingsElements {
22
+ readonly choice: { value: string; disabled: boolean; onchange: (() => Promise<void>) | null };
23
+ readonly status: { textContent: string | null; classList: Pick<DOMTokenList, "toggle"> };
24
+ }
25
+ export function setupNotificationSettings(elements: NotificationSettingsElements, fetcher?: SettingsFetch): NotificationSettingsController;
@@ -0,0 +1,118 @@
1
+ export const NOTIFICATION_SETTINGS_PATH = "/api/settings/notifications";
2
+
3
+ /** Parse gateway data without treating a failed or malformed response as a default. */
4
+ export function notificationMode(body) {
5
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
6
+ if (body.mode === "inbox-only" || body.mode === "all-replies") return body.mode;
7
+ return null;
8
+ }
9
+
10
+ /** Confirmed gateway state only. Requests belong to one connection epoch; writes run in order. */
11
+ export function createNotificationSettings(fetcher, render) {
12
+ let mode = "inbox-only";
13
+ let ready = false;
14
+ let online = false;
15
+ let error = "";
16
+ let epoch = 0;
17
+ let generation = 0;
18
+ let pending = 0;
19
+ let tail = Promise.resolve();
20
+ const view = () => ({ mode, ready, online, busy: pending > 0, error });
21
+ const paint = () => render(view());
22
+ const confirm = (value) => {
23
+ mode = value;
24
+ ready = true;
25
+ error = "";
26
+ paint();
27
+ };
28
+
29
+ async function read() {
30
+ if (!online) return;
31
+ const connection = epoch;
32
+ const request = ++generation;
33
+ try {
34
+ const response = await fetcher(NOTIFICATION_SETTINGS_PATH, { cache: "no-store" });
35
+ if (!response.ok) throw new Error("notification_read_failed");
36
+ const value = notificationMode(await response.json());
37
+ if (value === null) throw new Error("notification_response_invalid");
38
+ if (connection === epoch && request === generation) confirm(value);
39
+ } catch {
40
+ if (connection === epoch && request === generation) {
41
+ error = "알림 설정을 불러오지 못했습니다. 다시 연결하면 재시도합니다.";
42
+ paint();
43
+ }
44
+ }
45
+ }
46
+
47
+ function set(value) {
48
+ if (!online || !ready || notificationMode({ mode: value }) === null) return Promise.resolve();
49
+ const connection = epoch;
50
+ generation += 1;
51
+ pending += 1;
52
+ paint();
53
+ tail = tail.then(async () => {
54
+ try {
55
+ if (connection !== epoch) return;
56
+ const request = ++generation;
57
+ const response = await fetcher(NOTIFICATION_SETTINGS_PATH, {
58
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ mode: value }),
59
+ });
60
+ if (!response.ok) throw new Error("notification_save_failed");
61
+ const saved = notificationMode(await response.json());
62
+ if (saved === null) throw new Error("notification_response_invalid");
63
+ if (connection !== epoch) return;
64
+ if (request === generation) confirm(saved);
65
+ else await read(); // A newer event/read may describe another client's write.
66
+ } catch {
67
+ if (connection === epoch) {
68
+ error = "알림 설정을 저장하지 못했습니다. 확인된 설정을 유지합니다.";
69
+ paint();
70
+ }
71
+ } finally {
72
+ pending -= 1;
73
+ paint();
74
+ }
75
+ });
76
+ return tail;
77
+ }
78
+
79
+ paint();
80
+ return {
81
+ view,
82
+ read,
83
+ set,
84
+ connect() {
85
+ epoch += 1;
86
+ generation += 1;
87
+ online = true;
88
+ ready = false;
89
+ error = "";
90
+ paint();
91
+ return read();
92
+ },
93
+ disconnect() {
94
+ epoch += 1;
95
+ generation += 1;
96
+ online = false;
97
+ paint();
98
+ },
99
+ updated(body) {
100
+ const value = notificationMode(body);
101
+ if (!online || value === null) return;
102
+ generation += 1;
103
+ confirm(value);
104
+ },
105
+ };
106
+ }
107
+
108
+ /** Reuse the settings dialog's native select and live status region. */
109
+ export function setupNotificationSettings({ choice, status }, fetcher = fetch) {
110
+ const controller = createNotificationSettings(fetcher, (view) => {
111
+ choice.value = view.mode;
112
+ choice.disabled = !view.online || !view.ready || view.busy;
113
+ status.textContent = view.error || (view.busy ? "저장 중…" : !view.online ? "게이트웨이 연결을 기다립니다." : !view.ready ? "알림 설정 확인 중…" : "");
114
+ status.classList.toggle("error", Boolean(view.error));
115
+ });
116
+ choice.onchange = () => controller.set(choice.value);
117
+ return controller;
118
+ }