zigmini 0.8.0-beta.6 → 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 (49) hide show
  1. package/README.ko.md +77 -19
  2. package/README.md +78 -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 +23 -1
  9. package/docs/manual.md +23 -1
  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 +169 -7
  17. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  18. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  19. package/package.json +4 -1
  20. package/scripts/secret-rules.ts +101 -0
  21. package/scripts/secret-scan.allow.json +155 -0
  22. package/scripts/secret-scan.rules.json +30 -0
  23. package/src/agent-request-rules.ts +82 -0
  24. package/src/cli/access.ts +31 -34
  25. package/src/cli/launchd.ts +30 -4
  26. package/src/cli/main.ts +3 -43
  27. package/src/cli/messages.ts +160 -0
  28. package/src/cli/pair.ts +32 -25
  29. package/src/cli/setup.ts +164 -75
  30. package/src/decisions.ts +20 -1
  31. package/src/hub.ts +140 -30
  32. package/src/main.ts +43 -2
  33. package/src/omo/client.ts +28 -0
  34. package/src/protocol.ts +30 -1
  35. package/src/reply-notifications.ts +144 -0
  36. package/src/runtime/paths.ts +2 -0
  37. package/src/security-check.ts +241 -0
  38. package/src/store.ts +71 -1
  39. package/src/turn-stats.ts +49 -0
  40. package/src/work-watch.ts +36 -10
  41. package/src/work.ts +29 -4
  42. package/web/app.js +158 -0
  43. package/web/decisions.js +3 -0
  44. package/web/index.html +16 -0
  45. package/web/notifications.d.ts +25 -0
  46. package/web/notifications.js +118 -0
  47. package/web/style.css +12 -0
  48. package/web/turn-stats.d.ts +26 -0
  49. package/web/turn-stats.js +58 -0
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,6 +43,7 @@ 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";
46
+ import { setupNotificationSettings } from "./notifications.js";
45
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
 
@@ -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);
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/index.html CHANGED
@@ -177,6 +177,22 @@
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
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>
@@ -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
+ }
package/web/style.css CHANGED
@@ -126,6 +126,8 @@ button { font: inherit; color: inherit; cursor: pointer; }
126
126
  .msg-name { font-weight: 600; }
127
127
  .msg-name.bot { color: #a3acff; }
128
128
  .msg-time { font-size: 12px; color: var(--muted); }
129
+ .turn-stats { margin-top: 4px; margin-right: 48px; font-size: 12px; line-height: 1.5; color: var(--muted); font-variant-numeric: tabular-nums; user-select: none; }
130
+ .turn-stats::before { content: attr(data-label); }
129
131
  .msg-tag { font-size: 11px; color: var(--muted); border: 1px solid #4e5058; border-radius: 4px; padding: 0 4px; }
130
132
  .msg-read { font-size: 11px; color: var(--muted); }
131
133
  .msg.precomment .msg-body { opacity: 0.72; font-size: 0.9em; }
@@ -569,3 +571,13 @@ button { font: inherit; color: inherit; cursor: pointer; }
569
571
  /* Bug 90: the page-drawn tooltip (web/tooltip.js). */
570
572
  .zgw-tip { position: fixed; z-index: 10000; max-width: min(360px, calc(100vw - 16px)); padding: 6px 9px; border-radius: 6px; background: #111214; color: var(--text); border: 1px solid #3f4147; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); font-size: 12px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; }
571
573
  .zgw-tip[hidden] { display: none; }
574
+
575
+ /* Improvement 165: the security check card (values the user decides to send or drop). */
576
+ .security-card { display: block; margin: 8px 0; border: 1px solid #4e5058; border-left: 4px solid var(--warn); background: var(--bg-card); border-radius: 6px; padding: 12px; max-width: 640px; }
577
+ .security-card .security-title { font-weight: 600; margin-bottom: 6px; }
578
+ .security-card .security-list { margin: 0 0 8px; padding-left: 18px; }
579
+ .security-card .security-value { word-break: break-all; }
580
+ .security-card .security-place { opacity: 0.7; font-size: 0.9em; }
581
+ .security-card .security-actions { display: flex; gap: 8px; }
582
+ .security-card .security-actions .btn { white-space: nowrap; writing-mode: horizontal-tb; }
583
+ .security-card.done { opacity: 0.75; }
@@ -0,0 +1,26 @@
1
+ export interface TurnStatsEvent {
2
+ readonly conv?: string;
3
+ readonly type: string;
4
+ readonly seq?: number;
5
+ readonly data?: {
6
+ readonly itemId?: unknown;
7
+ readonly messageSeq?: unknown;
8
+ readonly turnId?: unknown;
9
+ readonly stats?: unknown;
10
+ };
11
+ }
12
+ export interface StatsBody {
13
+ readonly ownerDocument: { createElement(tag: string): StatsLine };
14
+ querySelector(selector: string): unknown;
15
+ appendChild(line: StatsLine): unknown;
16
+ }
17
+ export interface StatsLine {
18
+ className: string;
19
+ readonly dataset: Record<string, string | undefined>;
20
+ setAttribute(name: string, value: string): void;
21
+ }
22
+ export function turnStatsLabel(stats: unknown): string;
23
+ export function createTurnStats(): {
24
+ reset(conv: string): void;
25
+ accept(event: TurnStatsEvent, body?: StatsBody): void;
26
+ };
@@ -0,0 +1,58 @@
1
+ /** Unknown fields are omitted, not rendered as zero. Model time excludes tools and wall time. */
2
+ export function turnStatsLabel(stats) {
3
+ if (!stats || typeof stats !== "object") return "";
4
+ const parts = [];
5
+ if (Number.isFinite(stats.tokensPerSecond) && stats.tokensPerSecond > 0) parts.push(`TPS ${stats.tokensPerSecond.toFixed(1)} tok/s`);
6
+ if (Number.isFinite(stats.modelDurationMs) && stats.modelDurationMs > 0) parts.push(`모델 처리 ${(stats.modelDurationMs / 1000).toFixed(1)}초`);
7
+ if (Number.isFinite(stats.cacheHitPercent) && stats.cacheHitPercent >= 0 && stats.cacheHitPercent <= 100) parts.push(`캐시 적중 ${stats.cacheHitPercent.toFixed(1)}%`);
8
+ return parts.join(" · ");
9
+ }
10
+
11
+ /**
12
+ * One transcript in one gateway origin. Nothing is shared in storage or across app instances.
13
+ * Keep unmatched stats until their final's older page arrives; never fall back to the latest row.
14
+ */
15
+ export function createTurnStats() {
16
+ let conv = null;
17
+ const rows = new Map();
18
+ const labels = new Map();
19
+ function draw(key) {
20
+ const body = rows.get(key);
21
+ const label = labels.get(key);
22
+ if (!body || !label || body.querySelector(".turn-stats")) return;
23
+ const line = body.ownerDocument.createElement("div");
24
+ line.className = "turn-stats";
25
+ // Generated content stays out of selection text and ordinary transcript text exports.
26
+ // The accessible name still exposes the same metadata to assistive technology.
27
+ line.dataset.label = label;
28
+ line.setAttribute("role", "note");
29
+ line.setAttribute("aria-label", label);
30
+ body.appendChild(line);
31
+ }
32
+ return {
33
+ reset(nextConv) {
34
+ conv = nextConv;
35
+ rows.clear();
36
+ labels.clear();
37
+ },
38
+ accept(event, body) {
39
+ if (!event || event.conv !== conv || !event.data) return;
40
+ const data = event.data;
41
+ if (typeof data.itemId !== "string" || !data.itemId) return;
42
+ const seq = event.type === "message.final" ? event.seq : data.messageSeq;
43
+ if (!Number.isInteger(seq) || seq <= 0) return;
44
+ const key = JSON.stringify([data.itemId, seq]);
45
+ if (event.type === "message.final") {
46
+ if (!body) return;
47
+ rows.set(key, body);
48
+ draw(key);
49
+ } else if (event.type === "message.stats") {
50
+ if (typeof data.turnId !== "string" || !data.turnId) return;
51
+ const label = turnStatsLabel(data.stats);
52
+ if (!label) return;
53
+ if (!labels.has(key)) labels.set(key, label);
54
+ draw(key);
55
+ }
56
+ },
57
+ };
58
+ }