zigmini 0.8.0-beta.7 → 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 +18 -5
- package/README.md +18 -5
- package/assets/agent/gateway-request-rules.md +3 -1
- 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 +50 -4
- package/docs/manual.md +48 -4
- 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 +1 -1
- package/src/decisions.ts +32 -13
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +57 -31
- package/src/main.ts +22 -7
- package/src/notification-history.ts +56 -0
- package/src/protocol.ts +2 -1
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +355 -37
- package/src/store.ts +165 -5
- package/src/work.ts +21 -7
- package/web/DESIGN.md +39 -0
- package/web/app.js +198 -69
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +3 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +13 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +31 -1
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";
|
|
@@ -46,6 +47,7 @@ import { createAppDownload } from "./app-download.js";
|
|
|
46
47
|
import { setupNotificationSettings } from "./notifications.js";
|
|
47
48
|
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
|
|
48
49
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
50
|
+
import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityInbox } from "./security-inbox.js";
|
|
49
51
|
|
|
50
52
|
installTooltips(document, window);
|
|
51
53
|
|
|
@@ -536,7 +538,7 @@ function systemLine(text, error = false) {
|
|
|
536
538
|
|
|
537
539
|
function renderMarkdown(el, text) {
|
|
538
540
|
el.dataset.raw = text;
|
|
539
|
-
el
|
|
541
|
+
renderDiagramMarkdown(el, text, marked, DOMPurify);
|
|
540
542
|
for (const a of el.querySelectorAll("a")) {
|
|
541
543
|
a.target = "_blank";
|
|
542
544
|
a.rel = "noopener noreferrer";
|
|
@@ -1112,6 +1114,7 @@ function clearMessages(conv) {
|
|
|
1112
1114
|
progressEls.clear();
|
|
1113
1115
|
resolvedSeen.clear();
|
|
1114
1116
|
hist = { conv, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
|
|
1117
|
+
renderSecuritySurfaces();
|
|
1115
1118
|
}
|
|
1116
1119
|
|
|
1117
1120
|
/** `resume`: after a reconnect, keep what is on screen and fetch only the events missed meanwhile. */
|
|
@@ -1249,7 +1252,7 @@ function recountPending(approvals) {
|
|
|
1249
1252
|
}
|
|
1250
1253
|
|
|
1251
1254
|
function onServerEvent(e, generation, statusGeneration) {
|
|
1252
|
-
devLog("in", e);
|
|
1255
|
+
if (e.type !== "security-check.held") devLog("in", e);
|
|
1253
1256
|
if (e.seq && e.conv === state.current && hist.conv === e.conv && hist.seen.has(e.seq)) return;
|
|
1254
1257
|
switch (e.type) {
|
|
1255
1258
|
case "state.snapshot":
|
|
@@ -1335,8 +1338,14 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1335
1338
|
case "security-check.held":
|
|
1336
1339
|
showHeldCard(e.data);
|
|
1337
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;
|
|
1338
1347
|
case "security-check.resolved":
|
|
1339
|
-
|
|
1348
|
+
void securityInbox.detail(e.data.id);
|
|
1340
1349
|
return;
|
|
1341
1350
|
case "voice.updated":
|
|
1342
1351
|
voice.reads += 1;
|
|
@@ -1663,6 +1672,8 @@ function connect() {
|
|
|
1663
1672
|
$("head-avatar").classList.add("live");
|
|
1664
1673
|
renderActivity();
|
|
1665
1674
|
inbox.online = true;
|
|
1675
|
+
void securityInbox.connect();
|
|
1676
|
+
void allowedValues.connect();
|
|
1666
1677
|
renderInboxOnline();
|
|
1667
1678
|
send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
|
|
1668
1679
|
sendWebPresence();
|
|
@@ -1670,7 +1681,6 @@ function connect() {
|
|
|
1670
1681
|
void readVoiceSetting();
|
|
1671
1682
|
void readSecuritySetting();
|
|
1672
1683
|
void notificationSettings.connect();
|
|
1673
|
-
void readHeld();
|
|
1674
1684
|
void readVoiceInstall();
|
|
1675
1685
|
// Also after an update restarted the gateway: the new version and the update result show without a reload.
|
|
1676
1686
|
loadUpdateStatus();
|
|
@@ -1689,6 +1699,9 @@ function connect() {
|
|
|
1689
1699
|
state.sessionTransitions.clear();
|
|
1690
1700
|
renderSessionTransition();
|
|
1691
1701
|
inbox.online = false;
|
|
1702
|
+
securityDetailsRequested.clear();
|
|
1703
|
+
securityInbox.disconnect();
|
|
1704
|
+
allowedValues.disconnect();
|
|
1692
1705
|
notificationSettings.disconnect();
|
|
1693
1706
|
historyRequests.length = 0;
|
|
1694
1707
|
renderInboxOnline();
|
|
@@ -2509,7 +2522,7 @@ renderVoiceToggle();
|
|
|
2509
2522
|
// ---- Improvement 165: the security check of every user message (settings toggle, blocked draft back in the composer) ----
|
|
2510
2523
|
const SECURITY_CHECK_PATH = "/api/settings/security-check";
|
|
2511
2524
|
const notificationSettings = setupNotificationSettings({ choice: $("notification-mode"), status: $("notification-status") });
|
|
2512
|
-
const security = { enabled: true, busy: false };
|
|
2525
|
+
const security = { enabled: true, busy: false, read: 0 };
|
|
2513
2526
|
const securityButton = $("toggle-security");
|
|
2514
2527
|
|
|
2515
2528
|
function renderSecurityToggle() {
|
|
@@ -2524,9 +2537,12 @@ function applySecurityBody(body) {
|
|
|
2524
2537
|
}
|
|
2525
2538
|
|
|
2526
2539
|
async function readSecuritySetting() {
|
|
2540
|
+
const request = ++security.read;
|
|
2541
|
+
const socket = state.ws;
|
|
2527
2542
|
try {
|
|
2528
2543
|
const res = await fetch(SECURITY_CHECK_PATH, { cache: "no-store" });
|
|
2529
|
-
|
|
2544
|
+
const body = await res.json();
|
|
2545
|
+
if (res.ok && request === security.read && socket === state.ws && inbox.online) applySecurityBody(body);
|
|
2530
2546
|
} catch {
|
|
2531
2547
|
// Offline: the reconnect reads it again.
|
|
2532
2548
|
}
|
|
@@ -2549,88 +2565,188 @@ async function setSecurityEnabled(enabled) {
|
|
|
2549
2565
|
|
|
2550
2566
|
/** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
|
|
2551
2567
|
function restoreDraft(conv, text) {
|
|
2552
|
-
if (typeof text !== "string" || state.current !== conv || els.input.value !== "") return;
|
|
2568
|
+
if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
|
|
2553
2569
|
els.input.value = text;
|
|
2554
2570
|
els.input.focus();
|
|
2555
2571
|
}
|
|
2556
2572
|
|
|
2557
|
-
//
|
|
2573
|
+
// Chat and inbox use the same opaque identity and dedicated sensitive detail.
|
|
2558
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
|
+
}
|
|
2559
2585
|
|
|
2560
2586
|
function showHeldCard(held) {
|
|
2561
|
-
if (!
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
const
|
|
2567
|
-
const
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
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 ?? ""));
|
|
2572
2600
|
list.append(item);
|
|
2573
2601
|
}
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
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}` : ""}`);
|
|
2583
2614
|
status.setAttribute("role", "status");
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
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));
|
|
2589
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;
|
|
2590
2655
|
}
|
|
2591
2656
|
|
|
2592
|
-
function
|
|
2593
|
-
const entry
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
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
|
+
}
|
|
2617
2688
|
}
|
|
2618
2689
|
}
|
|
2619
2690
|
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
}
|
|
2630
|
-
|
|
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);
|
|
2631
2712
|
}
|
|
2713
|
+
return li;
|
|
2632
2714
|
}
|
|
2633
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
|
+
|
|
2634
2750
|
securityButton.onclick = () => setSecurityEnabled(!security.enabled);
|
|
2635
2751
|
renderSecurityToggle();
|
|
2636
2752
|
|
|
@@ -2674,6 +2790,7 @@ const memoController = setupMemo({ uploadImage, reportError: (message) => system
|
|
|
2674
2790
|
// ---- 확인함: the server's durable decisions (GET /api/decisions), independent of the loaded chat page ----
|
|
2675
2791
|
const inbox = {
|
|
2676
2792
|
state: createInbox(),
|
|
2793
|
+
heldAnswers: new Set(),
|
|
2677
2794
|
open: false,
|
|
2678
2795
|
loading: false,
|
|
2679
2796
|
resync: false,
|
|
@@ -2779,15 +2896,21 @@ async function syncInbox({ reset = false, cursor = null } = {}) {
|
|
|
2779
2896
|
return;
|
|
2780
2897
|
}
|
|
2781
2898
|
const counterEpoch = reset ? beginPageReset(inbox.state) : inbox.state.counterEpoch;
|
|
2899
|
+
const generation = decisionGeneration;
|
|
2782
2900
|
inbox.loading = true;
|
|
2783
2901
|
inbox.error = null;
|
|
2784
2902
|
renderInboxStatus();
|
|
2785
2903
|
try {
|
|
2786
2904
|
const page = await fetchDecisions(cursor);
|
|
2905
|
+
if (generation !== decisionGeneration) return;
|
|
2787
2906
|
if (reset) {
|
|
2788
2907
|
dropSocketSubmissions(inbox.state);
|
|
2789
2908
|
}
|
|
2790
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
|
+
}
|
|
2791
2914
|
renderInboxList();
|
|
2792
2915
|
for (const decision of inbox.state.items.values()) syncCard(decision);
|
|
2793
2916
|
if (reset) {
|
|
@@ -2877,6 +3000,7 @@ function renderInboxList() {
|
|
|
2877
3000
|
// The draft and note are shared with the chat card; a mounted card still answering the decision keeps them.
|
|
2878
3001
|
const shown = decisionById(inbox.state, id);
|
|
2879
3002
|
if (shown && isUnresolved(shown) && cardForDecision(id)) continue;
|
|
3003
|
+
if (inbox.heldAnswers?.has(id)) continue;
|
|
2880
3004
|
inbox.forms.delete(id);
|
|
2881
3005
|
inbox.notes.delete(id);
|
|
2882
3006
|
}
|
|
@@ -3117,6 +3241,7 @@ function renderSourceContext(payload) {
|
|
|
3117
3241
|
}
|
|
3118
3242
|
|
|
3119
3243
|
function renderInboxItem(decision) {
|
|
3244
|
+
if (decision.security) return renderSecurityInboxItem(decision);
|
|
3120
3245
|
const p = presentation(decision);
|
|
3121
3246
|
const payload = decision.payload && typeof decision.payload === "object" ? decision.payload : {};
|
|
3122
3247
|
const form = formFor(decision);
|
|
@@ -3362,6 +3487,10 @@ function onDecisionUpdated(data) {
|
|
|
3362
3487
|
const decision = data.decision;
|
|
3363
3488
|
if (!decision) return;
|
|
3364
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
|
+
}
|
|
3365
3494
|
renderInboxBadge();
|
|
3366
3495
|
if (!result.changed) return;
|
|
3367
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;
|
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;
|