zigmini 0.8.0-beta.6 → 0.9.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -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 +69 -1
- package/docs/manual.md +67 -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/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -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 +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
package/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClapDetector } from "./clap.js";
|
|
2
|
+
import { renderDiagramMarkdown } from "./diagram.js";
|
|
2
3
|
import { setupMemo } from "./memo.js";
|
|
3
4
|
import { setupAutoCopy } from "./autocopy.js";
|
|
4
5
|
import { setupWorkIdCopy } from "./workid.js";
|
|
@@ -28,6 +29,7 @@ import { hasTextSelection, panelShortcut } from "./shortcuts.js";
|
|
|
28
29
|
import { decisionCommand, lastSentLabel, lastSentTooltip, nextLastSent } from "./lastsent.js";
|
|
29
30
|
import { installTooltips } from "./tooltip.js";
|
|
30
31
|
import { placeRow, unseenEvents } from "./chatorder.js";
|
|
32
|
+
import { createTurnStats } from "./turn-stats.js";
|
|
31
33
|
import { cardsOfDecision } from "./cardbind.js";
|
|
32
34
|
import { createRecall, recallKey, resetRecall } from "./recall.js";
|
|
33
35
|
import {
|
|
@@ -42,8 +44,10 @@ import {
|
|
|
42
44
|
import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } from "./enroll.js";
|
|
43
45
|
import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
|
|
44
46
|
import { createAppDownload } from "./app-download.js";
|
|
47
|
+
import { setupNotificationSettings } from "./notifications.js";
|
|
45
48
|
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
|
|
46
49
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
50
|
+
import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityInbox } from "./security-inbox.js";
|
|
47
51
|
|
|
48
52
|
installTooltips(document, window);
|
|
49
53
|
|
|
@@ -60,6 +64,7 @@ const state = {
|
|
|
60
64
|
// Work activity the gateway reports per conversation (snapshot + activity.updated). It drives the avatar only;
|
|
61
65
|
// state.running stays the sole input of #interrupt, and history pages never write here.
|
|
62
66
|
const activity = createActivity();
|
|
67
|
+
const turnStats = createTurnStats();
|
|
63
68
|
const HISTORY_PAGE = 100;
|
|
64
69
|
// Loaded window of the current conversation's stored events (by seq), for resuming after a reconnect and paging back.
|
|
65
70
|
let hist = { conv: null, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
|
|
@@ -533,7 +538,7 @@ function systemLine(text, error = false) {
|
|
|
533
538
|
|
|
534
539
|
function renderMarkdown(el, text) {
|
|
535
540
|
el.dataset.raw = text;
|
|
536
|
-
el
|
|
541
|
+
renderDiagramMarkdown(el, text, marked, DOMPurify);
|
|
537
542
|
for (const a of el.querySelectorAll("a")) {
|
|
538
543
|
a.target = "_blank";
|
|
539
544
|
a.rel = "noopener noreferrer";
|
|
@@ -984,8 +989,12 @@ function renderEvent(e, fromHistory = false) {
|
|
|
984
989
|
}
|
|
985
990
|
const source = referenceSource(e);
|
|
986
991
|
if (source) markReferenceable(body.parentElement, source);
|
|
992
|
+
turnStats.accept(e, body);
|
|
987
993
|
break;
|
|
988
994
|
}
|
|
995
|
+
case "message.stats":
|
|
996
|
+
turnStats.accept(e);
|
|
997
|
+
break;
|
|
989
998
|
case "progress.update":
|
|
990
999
|
renderProgress(e);
|
|
991
1000
|
break;
|
|
@@ -1096,6 +1105,7 @@ function rememberSent(e) {
|
|
|
1096
1105
|
|
|
1097
1106
|
function clearMessages(conv) {
|
|
1098
1107
|
els.messages.innerHTML = "";
|
|
1108
|
+
turnStats.reset(conv);
|
|
1099
1109
|
setLastSent(null);
|
|
1100
1110
|
recall.sent.length = 0;
|
|
1101
1111
|
resetRecall(recall.rec);
|
|
@@ -1104,6 +1114,7 @@ function clearMessages(conv) {
|
|
|
1104
1114
|
progressEls.clear();
|
|
1105
1115
|
resolvedSeen.clear();
|
|
1106
1116
|
hist = { conv, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
|
|
1117
|
+
renderSecuritySurfaces();
|
|
1107
1118
|
}
|
|
1108
1119
|
|
|
1109
1120
|
/** `resume`: after a reconnect, keep what is on screen and fetch only the events missed meanwhile. */
|
|
@@ -1241,7 +1252,7 @@ function recountPending(approvals) {
|
|
|
1241
1252
|
}
|
|
1242
1253
|
|
|
1243
1254
|
function onServerEvent(e, generation, statusGeneration) {
|
|
1244
|
-
devLog("in", e);
|
|
1255
|
+
if (e.type !== "security-check.held") devLog("in", e);
|
|
1245
1256
|
if (e.seq && e.conv === state.current && hist.conv === e.conv && hist.seen.has(e.seq)) return;
|
|
1246
1257
|
switch (e.type) {
|
|
1247
1258
|
case "state.snapshot":
|
|
@@ -1318,6 +1329,24 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1318
1329
|
showUpdateProgress(resultLine(e.data));
|
|
1319
1330
|
loadUpdateStatus();
|
|
1320
1331
|
return;
|
|
1332
|
+
case "security-check.updated":
|
|
1333
|
+
applySecurityBody(e.data);
|
|
1334
|
+
return;
|
|
1335
|
+
case "notifications.updated":
|
|
1336
|
+
notificationSettings.updated(e.data);
|
|
1337
|
+
return;
|
|
1338
|
+
case "security-check.held":
|
|
1339
|
+
showHeldCard(e.data);
|
|
1340
|
+
return;
|
|
1341
|
+
case "security-check.state":
|
|
1342
|
+
securityInbox.merge(e.data);
|
|
1343
|
+
return;
|
|
1344
|
+
case "security-check.allowed-values.updated":
|
|
1345
|
+
allowedValues.updated(e.data.revision);
|
|
1346
|
+
return;
|
|
1347
|
+
case "security-check.resolved":
|
|
1348
|
+
void securityInbox.detail(e.data.id);
|
|
1349
|
+
return;
|
|
1321
1350
|
case "voice.updated":
|
|
1322
1351
|
voice.reads += 1;
|
|
1323
1352
|
void applyVoiceSetting(voiceEnabledFrom(e.data));
|
|
@@ -1643,11 +1672,15 @@ function connect() {
|
|
|
1643
1672
|
$("head-avatar").classList.add("live");
|
|
1644
1673
|
renderActivity();
|
|
1645
1674
|
inbox.online = true;
|
|
1675
|
+
void securityInbox.connect();
|
|
1676
|
+
void allowedValues.connect();
|
|
1646
1677
|
renderInboxOnline();
|
|
1647
1678
|
send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
|
|
1648
1679
|
sendWebPresence();
|
|
1649
1680
|
resendPendingReportSpeech();
|
|
1650
1681
|
void readVoiceSetting();
|
|
1682
|
+
void readSecuritySetting();
|
|
1683
|
+
void notificationSettings.connect();
|
|
1651
1684
|
void readVoiceInstall();
|
|
1652
1685
|
// Also after an update restarted the gateway: the new version and the update result show without a reload.
|
|
1653
1686
|
loadUpdateStatus();
|
|
@@ -1666,6 +1699,10 @@ function connect() {
|
|
|
1666
1699
|
state.sessionTransitions.clear();
|
|
1667
1700
|
renderSessionTransition();
|
|
1668
1701
|
inbox.online = false;
|
|
1702
|
+
securityDetailsRequested.clear();
|
|
1703
|
+
securityInbox.disconnect();
|
|
1704
|
+
allowedValues.disconnect();
|
|
1705
|
+
notificationSettings.disconnect();
|
|
1669
1706
|
historyRequests.length = 0;
|
|
1670
1707
|
renderInboxOnline();
|
|
1671
1708
|
setTimeout(connect, 1500);
|
|
@@ -2482,6 +2519,237 @@ async function setVoiceEnabled(enabled) {
|
|
|
2482
2519
|
voiceButton.onclick = () => setVoiceEnabled(!voice.enabled);
|
|
2483
2520
|
renderVoiceToggle();
|
|
2484
2521
|
|
|
2522
|
+
// ---- Improvement 165: the security check of every user message (settings toggle, blocked draft back in the composer) ----
|
|
2523
|
+
const SECURITY_CHECK_PATH = "/api/settings/security-check";
|
|
2524
|
+
const notificationSettings = setupNotificationSettings({ choice: $("notification-mode"), status: $("notification-status") });
|
|
2525
|
+
const security = { enabled: true, busy: false, read: 0 };
|
|
2526
|
+
const securityButton = $("toggle-security");
|
|
2527
|
+
|
|
2528
|
+
function renderSecurityToggle() {
|
|
2529
|
+
securityButton.textContent = security.enabled ? "보안검사 켜짐" : "보안검사 꺼짐";
|
|
2530
|
+
securityButton.setAttribute("aria-pressed", String(security.enabled));
|
|
2531
|
+
securityButton.classList.toggle("on", security.enabled);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
function applySecurityBody(body) {
|
|
2535
|
+
if (typeof body?.enabled === "boolean") security.enabled = body.enabled;
|
|
2536
|
+
renderSecurityToggle();
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
async function readSecuritySetting() {
|
|
2540
|
+
const request = ++security.read;
|
|
2541
|
+
const socket = state.ws;
|
|
2542
|
+
try {
|
|
2543
|
+
const res = await fetch(SECURITY_CHECK_PATH, { cache: "no-store" });
|
|
2544
|
+
const body = await res.json();
|
|
2545
|
+
if (res.ok && request === security.read && socket === state.ws && inbox.online) applySecurityBody(body);
|
|
2546
|
+
} catch {
|
|
2547
|
+
// Offline: the reconnect reads it again.
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
async function setSecurityEnabled(enabled) {
|
|
2552
|
+
if (security.busy) return;
|
|
2553
|
+
security.busy = true;
|
|
2554
|
+
try {
|
|
2555
|
+
const res = await fetch(SECURITY_CHECK_PATH, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
|
|
2556
|
+
const body = await res.json().catch(() => ({}));
|
|
2557
|
+
if (!res.ok) throw new Error(body.error ?? String(res.status));
|
|
2558
|
+
applySecurityBody(body);
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
systemLine(`보안검사 설정을 바꾸지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
|
|
2561
|
+
} finally {
|
|
2562
|
+
security.busy = false;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
|
|
2567
|
+
function restoreDraft(conv, text) {
|
|
2568
|
+
if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
|
|
2569
|
+
els.input.value = text;
|
|
2570
|
+
els.input.focus();
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// Chat and inbox use the same opaque identity and dedicated sensitive detail.
|
|
2574
|
+
const heldCards = new Map();
|
|
2575
|
+
const securityDetailsRequested = new Set();
|
|
2576
|
+
const securityInbox = createSecurityInbox(fetch, () => renderSecuritySurfaces());
|
|
2577
|
+
const allowedValues = createAllowedValues(fetch, onAllowedValuesChanged);
|
|
2578
|
+
let allowedRemoval = undefined;
|
|
2579
|
+
|
|
2580
|
+
function onAllowedValuesChanged() {
|
|
2581
|
+
// A successful read of the new endpoint establishes support; full canonical detail still gates each approval.
|
|
2582
|
+
securityInbox.supported(allowedValues.state.online && allowedValues.state.ready);
|
|
2583
|
+
renderAllowedValues();
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function showHeldCard(held) {
|
|
2587
|
+
if (!securityInbox.merge(held)) return;
|
|
2588
|
+
if (!decisionById(inbox.state, held.id)) void syncInbox({ reset: true });
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
function securityContents(id, surface) {
|
|
2592
|
+
const entry = securityInbox.entries.get(id);
|
|
2593
|
+
const view = securityInbox.view(id);
|
|
2594
|
+
const held = entry.held;
|
|
2595
|
+
const nodes = [make("div", "security-title", held.source === "answer" ? "답변 보안 확인" : "메시지 보안 확인")];
|
|
2596
|
+
const list = make("ul", "security-list");
|
|
2597
|
+
for (const occurrence of held.occurrences ?? []) {
|
|
2598
|
+
const item = make("li");
|
|
2599
|
+
item.append(make("span", "security-kind", occurrence.label), " ", make("code", "security-value", occurrence.canonicalValue ?? ""));
|
|
2600
|
+
list.append(item);
|
|
2601
|
+
}
|
|
2602
|
+
nodes.push(list);
|
|
2603
|
+
if (held.status === "pending") nodes.push(make("p", "security-question",
|
|
2604
|
+
"승인하면 원본을 전송하고 위의 정확한 값 전체를 이 설치에 기억합니다. 같은 값은 다음 검사부터 허용합니다. 취소는 이번 전송만 중단하고 기존 작업은 멈추지 않습니다. " +
|
|
2605
|
+
(held.source === "answer" ? "이번 답은 제출하지 않으며 원래 질문과 답변 작성 내용은 그대로 둡니다."
|
|
2606
|
+
: "메시지는 같은 대화의 빈 입력창에만 복구하며 새 초안·첨부·참조는 바꾸지 않습니다.")));
|
|
2607
|
+
const labels = { pending: "선택 대기", processing: "처리 중", sent: "전송 확인", cancelled: "이번 전송 취소", failed: "전송 실패", unconfirmed: "전송 결과 미확인 · 자동 재전송하지 않습니다" };
|
|
2608
|
+
const remembered = view.remembered === null ? "값 기억 결과 미확인" : view.remembered ? "정확한 값 기억됨" : "값 기억 안 됨";
|
|
2609
|
+
const reasons = { allowlist_persist_failed: "허용 값을 저장하지 못했습니다", state_persist_failed: "처리 기록을 확인하지 못했습니다",
|
|
2610
|
+
payload_lost: "보류 원문이 없어 보내지 못했습니다", dispatch_failed: "전송을 시작하지 못했습니다",
|
|
2611
|
+
dispatch_unknown: "전달 여부를 확인하지 못했습니다", original_decision_conflict: "원래 질문이 변경되어 이번 답을 보내지 않았습니다" };
|
|
2612
|
+
const reason = held.errorCode ? reasons[held.errorCode] ?? "상태를 다시 확인하세요" : "";
|
|
2613
|
+
const status = make("p", `security-status${view.status === "sent" ? " confirmed" : ""}`, `${labels[view.status]} · ${remembered}${reason ? ` · ${reason}` : ""}`);
|
|
2614
|
+
status.setAttribute("role", "status");
|
|
2615
|
+
nodes.push(status);
|
|
2616
|
+
const actions = make("div", "security-actions inbox-actions");
|
|
2617
|
+
const button = (label, cls, enabled, action) => {
|
|
2618
|
+
const b = make("button", `text-btn ${cls}`, label);
|
|
2619
|
+
b.type = "button"; b.disabled = !enabled; b.onclick = action; actions.append(b);
|
|
2620
|
+
};
|
|
2621
|
+
if (held.status === "pending" || view.status === "processing") {
|
|
2622
|
+
button("원본 전송하고 값 기억", "security-approve", view.approve, () => void securityInbox.decide(id, true));
|
|
2623
|
+
button("이번 전송 취소", "security-cancel", view.cancel, () => void securityInbox.decide(id, false));
|
|
2624
|
+
}
|
|
2625
|
+
if (view.check || (held.status === "pending" && !completeDetail(held))) {
|
|
2626
|
+
button("상태 확인", "security-refresh", inbox.online && !entry.busy, () => void securityInbox.detail(id));
|
|
2627
|
+
}
|
|
2628
|
+
if (held.status === "pending" && !view.approve && !entry.busy && !entry.uncertain) {
|
|
2629
|
+
nodes.push(make("p", "inbox-note", "게이트웨이의 기억 지원과 전체 값을 확인해야 승인할 수 있습니다."));
|
|
2630
|
+
}
|
|
2631
|
+
if (surface === "inbox" && (view.hide || view.cancel)) {
|
|
2632
|
+
button("삭제", "inbox-dismiss-open", true, () => {
|
|
2633
|
+
if (view.hide) { securityInbox.hide(id); renderInboxList(); return; }
|
|
2634
|
+
inbox.dismissing.add(id);
|
|
2635
|
+
renderInboxItem(decisionById(inbox.state, id)).querySelector(".inbox-dismiss-keep")?.focus();
|
|
2636
|
+
});
|
|
2637
|
+
if (inbox.dismissing.has(id)) {
|
|
2638
|
+
const box = make("div", "inbox-dismiss mode-cancel");
|
|
2639
|
+
box.append(make("p", "inbox-dismiss-text", "이번 전송만 취소합니다. 원래 질문과 답변 작성 내용은 남습니다."));
|
|
2640
|
+
const confirm = make("button", "text-btn danger security-dismiss-confirm", "전송 취소하고 삭제");
|
|
2641
|
+
confirm.disabled = !view.cancel;
|
|
2642
|
+
confirm.onclick = async () => {
|
|
2643
|
+
await securityInbox.decide(id, false);
|
|
2644
|
+
if (securityInbox.view(id).status === "cancelled") securityInbox.hide(id);
|
|
2645
|
+
inbox.dismissing.delete(id);
|
|
2646
|
+
renderInboxList();
|
|
2647
|
+
};
|
|
2648
|
+
const keep = make("button", "text-btn inbox-dismiss-keep", "그대로 두기");
|
|
2649
|
+
keep.onclick = () => { inbox.dismissing.delete(id); renderInboxItem(decisionById(inbox.state, id)); };
|
|
2650
|
+
box.append(confirm, keep); nodes.push(box);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
nodes.push(actions);
|
|
2654
|
+
return nodes;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function renderSecuritySurfaces() {
|
|
2658
|
+
for (const [id, entry] of securityInbox.entries) {
|
|
2659
|
+
const held = entry.held;
|
|
2660
|
+
if (held.source === "answer" && held.originalDecisionId) inbox.heldAnswers?.add(held.originalDecisionId);
|
|
2661
|
+
let card = heldCards.get(id);
|
|
2662
|
+
if (securityInbox.hidden.has(id)) {
|
|
2663
|
+
card?.remove();
|
|
2664
|
+
const row = inbox.nodes.get(id);
|
|
2665
|
+
if (row) row.hidden = true;
|
|
2666
|
+
continue;
|
|
2667
|
+
}
|
|
2668
|
+
if (held.conv === state.current || held.source === "answer") {
|
|
2669
|
+
if (!card) {
|
|
2670
|
+
card = make("div", "msg system security-card");
|
|
2671
|
+
card.dataset.heldId = id;
|
|
2672
|
+
card.setAttribute("role", "group");
|
|
2673
|
+
card.setAttribute("aria-label", "보안 확인");
|
|
2674
|
+
heldCards.set(id, card);
|
|
2675
|
+
}
|
|
2676
|
+
card.setAttribute("aria-busy", String(entry.busy));
|
|
2677
|
+
card.replaceChildren(...securityContents(id, "chat"));
|
|
2678
|
+
if (!card.isConnected) els.messages.append(card);
|
|
2679
|
+
} else card?.remove();
|
|
2680
|
+
const decision = decisionById(inbox.state, id);
|
|
2681
|
+
if (decision && inbox.state.items.has(id)) renderInboxItem(decision);
|
|
2682
|
+
if (!entry.restored && (held.status === "cancelled" || held.status === "failed")) {
|
|
2683
|
+
if (canRestoreHeld(held, {
|
|
2684
|
+
conv: state.current, text: els.input.value, images: attach.items.length, references: refs.state.items.length,
|
|
2685
|
+
})) restoreDraft(held.conv, entry.draft);
|
|
2686
|
+
entry.restored = true;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
function renderSecurityInboxItem(decision) {
|
|
2692
|
+
const id = decision.id;
|
|
2693
|
+
let entry = securityInbox.entries.get(id);
|
|
2694
|
+
if (!entry) {
|
|
2695
|
+
// The generic row carries state only; complete canonical values come from the dedicated endpoint.
|
|
2696
|
+
entry = { held: decision.security, busy: false, uncertain: false, restored: false };
|
|
2697
|
+
securityInbox.entries.set(id, entry);
|
|
2698
|
+
} else if (decision.security.revision > entry.held.revision) {
|
|
2699
|
+
entry.held = { ...entry.held, ...decision.security };
|
|
2700
|
+
}
|
|
2701
|
+
const li = make("li", "inbox-item security-inbox-item");
|
|
2702
|
+
li.dataset.id = id; li.dataset.heldId = id; li.dataset.revision = String(entry.held.revision);
|
|
2703
|
+
li.hidden = securityInbox.hidden.has(id);
|
|
2704
|
+
li.setAttribute("aria-busy", String(entry.busy));
|
|
2705
|
+
li.append(...securityContents(id, "inbox"));
|
|
2706
|
+
const previous = inbox.nodes.get(id);
|
|
2707
|
+
if (previous?.isConnected) previous.replaceWith(li);
|
|
2708
|
+
inbox.nodes.set(id, li);
|
|
2709
|
+
if (entry.held.status === "pending" && !completeDetail(entry.held) && !securityDetailsRequested.has(id)) {
|
|
2710
|
+
securityDetailsRequested.add(id);
|
|
2711
|
+
void securityInbox.detail(id);
|
|
2712
|
+
}
|
|
2713
|
+
return li;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function renderAllowedValues() {
|
|
2717
|
+
const model = allowedValues.state;
|
|
2718
|
+
const list = $("security-allowed-list");
|
|
2719
|
+
list.replaceChildren();
|
|
2720
|
+
$("security-allowed-status").textContent = !model.online ? "연결이 끊겼습니다." : model.busy ? "삭제 처리 중…" : model.loading ? "불러오는 중…" : model.error || (model.ready && model.values.length === 0 ? "기억한 값이 없습니다." : "");
|
|
2721
|
+
const locked = !model.online || model.loading || model.busy || !model.ready || Boolean(model.error);
|
|
2722
|
+
$("security-allowed-refresh").disabled = !model.online || model.loading || model.busy;
|
|
2723
|
+
$("security-allowed-clear").disabled = locked || model.values.length === 0;
|
|
2724
|
+
for (const value of model.values) {
|
|
2725
|
+
const row = make("li", "security-allowed-item");
|
|
2726
|
+
row.dataset.allowedId = value.id;
|
|
2727
|
+
const client = value.approvedBy.client === "android" ? "Android 앱" : "웹";
|
|
2728
|
+
row.append(make("code", "security-value", value.value), make("div", "inbox-note", `${value.label} · ${dateTimeLabel(value.approvedAt)} · ${client}`));
|
|
2729
|
+
const remove = make("button", "text-btn", "제거");
|
|
2730
|
+
remove.disabled = locked;
|
|
2731
|
+
remove.onclick = () => { allowedRemoval = value.id; renderAllowedValues(); $("security-allowed-keep").focus(); };
|
|
2732
|
+
row.append(remove); list.append(row);
|
|
2733
|
+
}
|
|
2734
|
+
const confirm = $("security-allowed-confirm");
|
|
2735
|
+
confirm.hidden = allowedRemoval === undefined;
|
|
2736
|
+
$("security-allowed-remove").disabled = locked;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
$("security-allowed-refresh").onclick = () => { allowedRemoval = undefined; void allowedValues.refresh(); };
|
|
2740
|
+
$("security-allowed-clear").onclick = () => { allowedRemoval = null; renderAllowedValues(); $("security-allowed-keep").focus(); };
|
|
2741
|
+
$("security-allowed-keep").onclick = () => { allowedRemoval = undefined; renderAllowedValues(); };
|
|
2742
|
+
$("security-allowed-remove").onclick = async () => {
|
|
2743
|
+
if (allowedRemoval === undefined) return;
|
|
2744
|
+
const id = allowedRemoval;
|
|
2745
|
+
allowedRemoval = undefined;
|
|
2746
|
+
await allowedValues.remove(id);
|
|
2747
|
+
renderAllowedValues();
|
|
2748
|
+
};
|
|
2749
|
+
|
|
2750
|
+
securityButton.onclick = () => setSecurityEnabled(!security.enabled);
|
|
2751
|
+
renderSecurityToggle();
|
|
2752
|
+
|
|
2485
2753
|
// ---- Todo 26: an npm install installs the voice servers on demand; the button is hidden everywhere else ----
|
|
2486
2754
|
const voiceInstallButton = $("voice-install");
|
|
2487
2755
|
|
|
@@ -2522,6 +2790,7 @@ const memoController = setupMemo({ uploadImage, reportError: (message) => system
|
|
|
2522
2790
|
// ---- 확인함: the server's durable decisions (GET /api/decisions), independent of the loaded chat page ----
|
|
2523
2791
|
const inbox = {
|
|
2524
2792
|
state: createInbox(),
|
|
2793
|
+
heldAnswers: new Set(),
|
|
2525
2794
|
open: false,
|
|
2526
2795
|
loading: false,
|
|
2527
2796
|
resync: false,
|
|
@@ -2627,15 +2896,21 @@ async function syncInbox({ reset = false, cursor = null } = {}) {
|
|
|
2627
2896
|
return;
|
|
2628
2897
|
}
|
|
2629
2898
|
const counterEpoch = reset ? beginPageReset(inbox.state) : inbox.state.counterEpoch;
|
|
2899
|
+
const generation = decisionGeneration;
|
|
2630
2900
|
inbox.loading = true;
|
|
2631
2901
|
inbox.error = null;
|
|
2632
2902
|
renderInboxStatus();
|
|
2633
2903
|
try {
|
|
2634
2904
|
const page = await fetchDecisions(cursor);
|
|
2905
|
+
if (generation !== decisionGeneration) return;
|
|
2635
2906
|
if (reset) {
|
|
2636
2907
|
dropSocketSubmissions(inbox.state);
|
|
2637
2908
|
}
|
|
2638
2909
|
applyPage(inbox.state, page, { reset, counterEpoch });
|
|
2910
|
+
for (const decision of page.decisions) if (decision.security) {
|
|
2911
|
+
securityInbox.merge(decision.security);
|
|
2912
|
+
if (decision.security.status === "pending") void securityInbox.detail(decision.id);
|
|
2913
|
+
}
|
|
2639
2914
|
renderInboxList();
|
|
2640
2915
|
for (const decision of inbox.state.items.values()) syncCard(decision);
|
|
2641
2916
|
if (reset) {
|
|
@@ -2725,6 +3000,7 @@ function renderInboxList() {
|
|
|
2725
3000
|
// The draft and note are shared with the chat card; a mounted card still answering the decision keeps them.
|
|
2726
3001
|
const shown = decisionById(inbox.state, id);
|
|
2727
3002
|
if (shown && isUnresolved(shown) && cardForDecision(id)) continue;
|
|
3003
|
+
if (inbox.heldAnswers?.has(id)) continue;
|
|
2728
3004
|
inbox.forms.delete(id);
|
|
2729
3005
|
inbox.notes.delete(id);
|
|
2730
3006
|
}
|
|
@@ -2965,6 +3241,7 @@ function renderSourceContext(payload) {
|
|
|
2965
3241
|
}
|
|
2966
3242
|
|
|
2967
3243
|
function renderInboxItem(decision) {
|
|
3244
|
+
if (decision.security) return renderSecurityInboxItem(decision);
|
|
2968
3245
|
const p = presentation(decision);
|
|
2969
3246
|
const payload = decision.payload && typeof decision.payload === "object" ? decision.payload : {};
|
|
2970
3247
|
const form = formFor(decision);
|
|
@@ -3175,6 +3452,12 @@ function settleSubmit(decision, result) {
|
|
|
3175
3452
|
noteConflict(inbox.state, decision, result.decision);
|
|
3176
3453
|
noteFor(id, "다른 곳에서 먼저 처리됐거나 질문이 바뀌었습니다. 최신 내용을 확인하고 다시 골라 주세요.", true);
|
|
3177
3454
|
break;
|
|
3455
|
+
case "blocked":
|
|
3456
|
+
// Improvement 165: the answer is held; the card decides. The typed answer stays in the form for a fix.
|
|
3457
|
+
inbox.state.drafts.delete(id);
|
|
3458
|
+
noteFor(id, `${result.message} 채팅의 보안검사 결과 카드에서 보내기 또는 보내지 않기를 고르세요.`, true);
|
|
3459
|
+
if (result.held) showHeldCard(result.held);
|
|
3460
|
+
break;
|
|
3178
3461
|
case "rejected":
|
|
3179
3462
|
inbox.state.drafts.delete(id);
|
|
3180
3463
|
if (result.decision) applyDecision(inbox.state, result.decision);
|
|
@@ -3204,6 +3487,10 @@ function onDecisionUpdated(data) {
|
|
|
3204
3487
|
const decision = data.decision;
|
|
3205
3488
|
if (!decision) return;
|
|
3206
3489
|
const result = applyUpdate(inbox.state, data);
|
|
3490
|
+
if (decision.security) {
|
|
3491
|
+
securityInbox.merge(decision.security);
|
|
3492
|
+
if (decision.security.status === "pending") void securityInbox.detail(decision.id);
|
|
3493
|
+
}
|
|
3207
3494
|
renderInboxBadge();
|
|
3208
3495
|
if (!result.changed) return;
|
|
3209
3496
|
if (endSocketSubmit(inbox.state, decision)) setCardBusy(originalActionId(decision), false);
|
package/web/decisions.d.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function finishDetailLoad(
|
|
|
86
86
|
): DetailResult;
|
|
87
87
|
export function failDetailLoad(inbox: Inbox, id: string, generation: number): boolean;
|
|
88
88
|
export function presentation(decision: DecisionInfo): {
|
|
89
|
-
readonly kind: "terminal" | "submitted" | "live" | "resume" | "permission" | "recheck";
|
|
89
|
+
readonly kind: "terminal" | "submitted" | "live" | "resume" | "permission" | "recheck" | "security";
|
|
90
90
|
readonly mode: AnswerMode | null;
|
|
91
91
|
readonly form: boolean;
|
|
92
92
|
readonly recheck: boolean;
|
package/web/decisions.js
CHANGED
|
@@ -338,6 +338,7 @@ export function sourceContext(payload) {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
export function presentation(decision) {
|
|
341
|
+
if (decision.security) return { kind: "security", mode: null, form: false, recheck: false, terminal: false };
|
|
341
342
|
const actions = decision.allowedActions ?? [];
|
|
342
343
|
const terminal = TERMINAL_WORK.has(decision.work?.status ?? "");
|
|
343
344
|
let kind = "terminal";
|
|
@@ -361,6 +362,7 @@ export function dismissMode(decision) {
|
|
|
361
362
|
}
|
|
362
363
|
|
|
363
364
|
export function canDismiss(decision) {
|
|
365
|
+
if (decision.security) return false;
|
|
364
366
|
return isUnresolved(decision) && decision.status !== "submitted";
|
|
365
367
|
}
|
|
366
368
|
|
|
@@ -445,6 +447,7 @@ export function composeResponse(decision, form = {}) {
|
|
|
445
447
|
|
|
446
448
|
/** An unchanged response replays its pending request verbatim, even after the current presentation mode changes. */
|
|
447
449
|
export function buildRequest(inbox, decision, mode, response, newId = () => crypto.randomUUID()) {
|
|
450
|
+
if (decision.security) return null;
|
|
448
451
|
if (inbox.reviewRequired.has(decision.id)) return null;
|
|
449
452
|
const draft = inbox.drafts.get(decision.id);
|
|
450
453
|
const payload = response === undefined ? null : response;
|
|
@@ -473,6 +476,9 @@ export function interpretAnswerResponse(status, body) {
|
|
|
473
476
|
if (status === 409) return { kind: "conflict", decision, error };
|
|
474
477
|
if (status === 404) return { kind: "gone", error };
|
|
475
478
|
if (status === 400) return { kind: "rejected", decision, error };
|
|
479
|
+
if (status === 422 && error === "security_check_blocked") {
|
|
480
|
+
return { kind: "blocked", error, held: record(body.held) ? body.held : null, message: typeof body.message === "string" ? body.message : "보안검사에 걸려 보내지 않고 보류했습니다." };
|
|
481
|
+
}
|
|
476
482
|
return { kind: "unavailable", status, error };
|
|
477
483
|
}
|
|
478
484
|
|
package/web/diagram.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface DiagramNode {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly label: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Diagram {
|
|
6
|
+
readonly version: 1;
|
|
7
|
+
readonly title?: string;
|
|
8
|
+
readonly nodes: readonly DiagramNode[];
|
|
9
|
+
readonly edges: readonly { readonly from: string; readonly to: string }[];
|
|
10
|
+
}
|
|
11
|
+
export interface MarkdownToken {
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly raw: string;
|
|
14
|
+
readonly lang?: string;
|
|
15
|
+
}
|
|
16
|
+
export type MarkdownTokens = MarkdownToken[];
|
|
17
|
+
export interface DiagramMarkdown {
|
|
18
|
+
lexer(text: string, options: { readonly gfm: boolean; readonly breaks: boolean }): MarkdownTokens;
|
|
19
|
+
parser(tokens: MarkdownTokens, options: { readonly gfm: boolean; readonly breaks: boolean }): string;
|
|
20
|
+
}
|
|
21
|
+
export type DiagramPart = { readonly html: string } | { readonly diagram: Diagram; readonly source: string };
|
|
22
|
+
export function parseDiagram(body: string): Diagram | null;
|
|
23
|
+
export function diagramLevels(diagram: Diagram): readonly (readonly DiagramNode[])[] | null;
|
|
24
|
+
export function diagramParts(text: string, markdown: DiagramMarkdown): readonly DiagramPart[];
|
|
25
|
+
export function createDiagram(doc: Document, diagram: Diagram, source: string): { readonly element: HTMLElement; dispose(): void };
|
|
26
|
+
export function renderDiagramMarkdown(
|
|
27
|
+
el: HTMLElement, text: string, markdown: DiagramMarkdown, purify: { sanitize(html: string): string },
|
|
28
|
+
): void;
|