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.
- package/README.ko.md +77 -19
- package/README.md +78 -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 +23 -1
- package/docs/manual.md +23 -1
- 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 +169 -7
- 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 +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +20 -1
- package/src/hub.ts +140 -30
- package/src/main.ts +43 -2
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +30 -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 +158 -0
- package/web/decisions.js +3 -0
- package/web/index.html +16 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/style.css +12 -0
- package/web/turn-stats.d.ts +26 -0
- 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
|
|
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,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
|
+
}
|