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.
- package/README.ko.md +78 -19
- package/README.md +79 -19
- package/assets/agent/gateway-request-rules.md +1 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +25 -2
- package/docs/manual.md +25 -2
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +171 -8
- package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +13 -39
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/rotate-main.ts +411 -0
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +20 -1
- package/src/hub.ts +140 -30
- package/src/main.ts +45 -4
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +33 -1
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-check.ts +241 -0
- package/src/store.ts +71 -1
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +29 -4
- package/web/app.js +197 -10
- package/web/decisions.js +3 -0
- package/web/gc.d.ts +2 -0
- package/web/gc.js +11 -0
- package/web/index.html +26 -10
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/style.css +16 -0
- package/web/tooltip.d.ts +0 -6
- package/web/tooltip.js +8 -11
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
- package/web/update.d.ts +1 -1
- 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
|
|
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), () =>
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
.
|
|
3429
|
-
|
|
3430
|
-
|
|
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 =
|
|
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.
|
|
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=""헤이 지그"라고 부르면 받아 적어 입력창에 채우기" 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=""헤이 지그"라고 부르면 받아 적어 입력창에 채우기" 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
|
+
}
|