zigmini 0.8.0-beta.7 → 0.9.0-beta.2
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 +27 -5
- package/README.md +27 -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 +57 -4
- package/docs/manual.md +55 -4
- package/host/omo-bridge/ascii-json.ts +5 -0
- package/host/omo-bridge/bridge.ts +50 -44
- 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/host/omo-check/patches/omo-5.1.48-compaction-port.patch +2629 -0
- package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +355 -0
- package/host/omo-check/patches/omo-5.1.48-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.48-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.48-runtime-compat.patch +31 -0
- package/package.json +1 -1
- package/src/cli/setup.ts +6 -2
- package/src/decisions.ts +32 -13
- package/src/engine/compat.json +4 -4
- package/src/engine/install.ts +68 -20
- package/src/engine/rpc-socket.ts +36 -0
- package/src/engine/schema-guard.ts +34 -2
- package/src/hub.ts +57 -31
- package/src/main.ts +22 -7
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +16 -3
- package/src/omo/frame-socket.ts +330 -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/update/handoff-system.ts +22 -0
- package/src/update/handoff.ts +10 -2
- package/src/update/helper.ts +11 -1
- package/src/update/request.ts +15 -2
- 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/diagram.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Diagram input is data only. Markdown recognition stays at the actual lexer boundary.
|
|
2
|
+
const BODY_LIMIT = 16_384;
|
|
3
|
+
const ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
4
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
5
|
+
|
|
6
|
+
function keys(value, required, optional = []) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
&& required.every((key) => Object.hasOwn(value, key))
|
|
9
|
+
&& Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function plain(value, limit) {
|
|
13
|
+
return typeof value === "string" && value.trim().length > 0
|
|
14
|
+
&& [...value].length <= limit && !CONTROL.test(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseDiagram(body) {
|
|
18
|
+
if (body.length > BODY_LIMIT) return null;
|
|
19
|
+
let value;
|
|
20
|
+
try {
|
|
21
|
+
value = JSON.parse(body);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof SyntaxError) return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (!keys(value, ["version", "nodes", "edges"], ["title"]) || value.version !== 1) return null;
|
|
27
|
+
if (Object.hasOwn(value, "title") && !plain(value.title, 80)) return null;
|
|
28
|
+
if (!Array.isArray(value.nodes) || value.nodes.length < 1 || value.nodes.length > 16) return null;
|
|
29
|
+
if (!Array.isArray(value.edges) || value.edges.length > 24) return null;
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const node of value.nodes) {
|
|
32
|
+
if (!keys(node, ["id", "label"]) || typeof node.id !== "string" || !ID.test(node.id)
|
|
33
|
+
|| ids.has(node.id) || !plain(node.label, 48)) return null;
|
|
34
|
+
ids.add(node.id);
|
|
35
|
+
}
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const edge of value.edges) {
|
|
38
|
+
if (!keys(edge, ["from", "to"]) || !ids.has(edge.from) || !ids.has(edge.to)
|
|
39
|
+
|| edge.from === edge.to) return null;
|
|
40
|
+
const pair = `${edge.from}:${edge.to}`;
|
|
41
|
+
if (seen.has(pair)) return null;
|
|
42
|
+
seen.add(pair);
|
|
43
|
+
}
|
|
44
|
+
if (diagramLevels(value) === null) return null;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Kahn traversal uses maximum parent depth, then restores input order within levels.
|
|
49
|
+
export function diagramLevels(diagram) {
|
|
50
|
+
const depths = new Map(diagram.nodes.map((node) => [node.id, 0]));
|
|
51
|
+
const incoming = new Map(diagram.nodes.map((node) => [node.id, 0]));
|
|
52
|
+
const children = new Map(diagram.nodes.map((node) => [node.id, []]));
|
|
53
|
+
for (const edge of diagram.edges) {
|
|
54
|
+
incoming.set(edge.to, incoming.get(edge.to) + 1);
|
|
55
|
+
children.get(edge.from).push(edge.to);
|
|
56
|
+
}
|
|
57
|
+
const queue = diagram.nodes.filter((node) => incoming.get(node.id) === 0).map((node) => node.id);
|
|
58
|
+
let visited = 0;
|
|
59
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
60
|
+
const id = queue[cursor];
|
|
61
|
+
visited++;
|
|
62
|
+
for (const child of children.get(id)) {
|
|
63
|
+
depths.set(child, Math.max(depths.get(child), depths.get(id) + 1));
|
|
64
|
+
incoming.set(child, incoming.get(child) - 1);
|
|
65
|
+
if (incoming.get(child) === 0) queue.push(child);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (visited !== diagram.nodes.length) return null;
|
|
69
|
+
const levels = [];
|
|
70
|
+
for (const node of diagram.nodes) {
|
|
71
|
+
const depth = depths.get(node.id);
|
|
72
|
+
(levels[depth] ??= []).push(node);
|
|
73
|
+
}
|
|
74
|
+
return levels;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function diagramParts(text, markdown) {
|
|
78
|
+
const options = { gfm: true, breaks: true };
|
|
79
|
+
const tokens = markdown.lexer(text, options);
|
|
80
|
+
const parts = [];
|
|
81
|
+
let ordinary = [];
|
|
82
|
+
// The lexer normalizes CRLF. Recover original spans for the body-size limit
|
|
83
|
+
// and source view instead of validating a shorter normalized body.
|
|
84
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
85
|
+
let tokenCursor = 0;
|
|
86
|
+
let originalCursor = 0;
|
|
87
|
+
let normalizedCursor = 0;
|
|
88
|
+
const originalOffset = (target) => {
|
|
89
|
+
while (normalizedCursor < target) {
|
|
90
|
+
if (text[originalCursor] === "\r" && text[originalCursor + 1] === "\n") originalCursor++;
|
|
91
|
+
originalCursor++;
|
|
92
|
+
normalizedCursor++;
|
|
93
|
+
}
|
|
94
|
+
return originalCursor;
|
|
95
|
+
};
|
|
96
|
+
const flush = () => {
|
|
97
|
+
if (ordinary.length === 0) return;
|
|
98
|
+
// The full-message lexer has already resolved inline reference links.
|
|
99
|
+
parts.push({ html: markdown.parser(ordinary, options) });
|
|
100
|
+
ordinary = [];
|
|
101
|
+
};
|
|
102
|
+
for (const token of tokens) {
|
|
103
|
+
const start = normalized.indexOf(token.raw, tokenCursor);
|
|
104
|
+
tokenCursor = start + token.raw.length;
|
|
105
|
+
const from = originalOffset(start);
|
|
106
|
+
const source = text.slice(from, originalOffset(tokenCursor));
|
|
107
|
+
// Do not search rendered <code> elements: raw HTML is not a supported fence.
|
|
108
|
+
const fence = token.type === "code" && token.lang === "zigmini-diagram"
|
|
109
|
+
? /^```zigmini-diagram\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)$/.exec(source) : null;
|
|
110
|
+
const diagram = fence ? parseDiagram(fence[1]) : null;
|
|
111
|
+
if (!diagram) {
|
|
112
|
+
ordinary.push(token);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
flush();
|
|
116
|
+
parts.push({ diagram, source });
|
|
117
|
+
}
|
|
118
|
+
flush();
|
|
119
|
+
return parts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function element(doc, tag, className, text) {
|
|
123
|
+
const el = doc.createElement(tag);
|
|
124
|
+
if (className) el.className = className;
|
|
125
|
+
if (text !== undefined) el.textContent = text;
|
|
126
|
+
return el;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createDiagram(doc, diagram, source) {
|
|
130
|
+
const figure = element(doc, "figure", "chat-diagram");
|
|
131
|
+
const title = diagram.title ?? "다이어그램";
|
|
132
|
+
figure.append(element(doc, "figcaption", "diagram-title", title));
|
|
133
|
+
const viewport = element(doc, "div", "diagram-viewport");
|
|
134
|
+
viewport.tabIndex = 0;
|
|
135
|
+
viewport.setAttribute("role", "region");
|
|
136
|
+
viewport.setAttribute("aria-label", `${title}. 다이어그램 영역, 방향키로 스크롤할 수 있습니다.`);
|
|
137
|
+
const stage = element(doc, "div", "diagram-stage");
|
|
138
|
+
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
139
|
+
svg.setAttribute("class", "diagram-edges");
|
|
140
|
+
svg.setAttribute("aria-hidden", "true");
|
|
141
|
+
svg.setAttribute("focusable", "false");
|
|
142
|
+
stage.append(svg);
|
|
143
|
+
const nodes = new Map();
|
|
144
|
+
const depths = new Map();
|
|
145
|
+
const levels = diagramLevels(diagram);
|
|
146
|
+
for (const [row, level] of levels.entries()) {
|
|
147
|
+
for (const [column, node] of level.entries()) {
|
|
148
|
+
const label = element(doc, "div", "diagram-node", node.label);
|
|
149
|
+
label.style.gridRow = String(row + 1);
|
|
150
|
+
label.style.gridColumn = String(column + 1);
|
|
151
|
+
stage.append(label);
|
|
152
|
+
nodes.set(node.id, label);
|
|
153
|
+
depths.set(node.id, row);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
viewport.append(stage);
|
|
157
|
+
const relationships = element(doc, "ul", "sr-only");
|
|
158
|
+
const labels = new Map(diagram.nodes.map((node) => [node.id, node.label]));
|
|
159
|
+
for (const edge of diagram.edges) {
|
|
160
|
+
relationships.append(element(doc, "li", "", `${labels.get(edge.from)} → ${labels.get(edge.to)}`));
|
|
161
|
+
}
|
|
162
|
+
const details = element(doc, "details", "diagram-source");
|
|
163
|
+
details.append(element(doc, "summary", "", "다이어그램 원문"));
|
|
164
|
+
const pre = element(doc, "pre");
|
|
165
|
+
pre.append(element(doc, "code", "", source));
|
|
166
|
+
details.append(pre);
|
|
167
|
+
figure.append(viewport, relationships, details);
|
|
168
|
+
|
|
169
|
+
// Measure native text boxes, not estimated glyph widths. Text zoom/font changes
|
|
170
|
+
// resize the stage; scrolling does not change the stage-relative coordinates.
|
|
171
|
+
const observer = new ResizeObserver(() => {
|
|
172
|
+
if (!stage.isConnected) {
|
|
173
|
+
observer.disconnect();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const bounds = stage.getBoundingClientRect();
|
|
177
|
+
svg.setAttribute("width", String(bounds.width));
|
|
178
|
+
svg.setAttribute("height", String(bounds.height));
|
|
179
|
+
svg.replaceChildren();
|
|
180
|
+
const boxes = new Map([...nodes].map(([id, node]) => [id, node.getBoundingClientRect()]));
|
|
181
|
+
const rowBottoms = new Map();
|
|
182
|
+
let right = 0;
|
|
183
|
+
for (const [id, box] of boxes) {
|
|
184
|
+
const row = depths.get(id);
|
|
185
|
+
rowBottoms.set(row, Math.max(rowBottoms.get(row) ?? 0, box.bottom - bounds.top));
|
|
186
|
+
right = Math.max(right, box.right - bounds.left);
|
|
187
|
+
}
|
|
188
|
+
const gutter = right + 12;
|
|
189
|
+
for (const edge of diagram.edges) {
|
|
190
|
+
const from = boxes.get(edge.from);
|
|
191
|
+
const to = boxes.get(edge.to);
|
|
192
|
+
const x1 = from.left + from.width / 2 - bounds.left;
|
|
193
|
+
const y1 = from.bottom - bounds.top;
|
|
194
|
+
const x2 = to.left + to.width / 2 - bounds.left;
|
|
195
|
+
const y2 = to.top - bounds.top;
|
|
196
|
+
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
197
|
+
// Long edges travel outside all node boxes, entering/leaving in row gaps.
|
|
198
|
+
const mid = rowBottoms.get(depths.get(edge.from)) + 24;
|
|
199
|
+
const skips = depths.get(edge.to) > depths.get(edge.from) + 1;
|
|
200
|
+
const route = skips ? `V${mid} H${gutter} V${y2 - 24} H${x2}` : `V${mid} H${x2}`;
|
|
201
|
+
path.setAttribute("d", `M${x1},${y1} ${route} V${y2} m-4,-6 l4,6 l4,-6`);
|
|
202
|
+
svg.append(path);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
observer.observe(stage);
|
|
206
|
+
return { element: figure, dispose: () => observer.disconnect() };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const mounted = new WeakMap();
|
|
210
|
+
|
|
211
|
+
export function renderDiagramMarkdown(el, text, markdown, purify) {
|
|
212
|
+
const previous = mounted.get(el) ?? [];
|
|
213
|
+
const open = previous.map(({ element: figure }) => figure.querySelector("details").open);
|
|
214
|
+
for (const view of previous) view.dispose();
|
|
215
|
+
el.replaceChildren();
|
|
216
|
+
const views = [];
|
|
217
|
+
for (const part of diagramParts(text, markdown)) {
|
|
218
|
+
if ("html" in part) {
|
|
219
|
+
el.insertAdjacentHTML("beforeend", purify.sanitize(part.html));
|
|
220
|
+
} else {
|
|
221
|
+
const view = createDiagram(el.ownerDocument, part.diagram, part.source);
|
|
222
|
+
view.element.querySelector("details").open = open[views.length] ?? false;
|
|
223
|
+
el.append(view.element);
|
|
224
|
+
views.push(view);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
mounted.set(el, views);
|
|
228
|
+
}
|
package/web/index.html
CHANGED
|
@@ -181,6 +181,19 @@
|
|
|
181
181
|
<div class="section-label"><span id="security-check-title">보안검사</span></div>
|
|
182
182
|
<p class="update-line">보낸 메시지에 비밀키, 토큰, 비밀번호, 홈 폴더 경로, 내부 IP 같은 값이 있으면 엔진에 보내지 않고 보류한 뒤, 값을 보여 주고 보낼지 묻습니다.</p>
|
|
183
183
|
<div class="update-buttons"><button type="button" class="text-btn" id="toggle-security" aria-pressed="true">보안검사 켜짐</button></div>
|
|
184
|
+
<div class="section-label">기억한 정확한 값</div>
|
|
185
|
+
<p class="update-note">이 설치에서 승인한 값만 다음 검사부터 허용합니다. 제거·전체 삭제는 실행 시점의 값을 지웁니다. 다음 검사에서 다시 보류되며 이미 시작한 전송은 취소하지 않습니다. 저장소·릴리스 보안검사는 바뀌지 않습니다.</p>
|
|
186
|
+
<div class="update-buttons">
|
|
187
|
+
<button type="button" class="text-btn" id="security-allowed-refresh">목록 확인 / 다시 시도</button>
|
|
188
|
+
<button type="button" class="text-btn" id="security-allowed-clear" disabled>전체 삭제</button>
|
|
189
|
+
</div>
|
|
190
|
+
<p class="enroll-message" id="security-allowed-status" role="status" aria-live="polite"></p>
|
|
191
|
+
<ul class="security-allowed-list" id="security-allowed-list" aria-label="기억한 정확한 값"></ul>
|
|
192
|
+
<div class="inbox-dismiss mode-cancel" id="security-allowed-confirm" hidden>
|
|
193
|
+
<p class="inbox-dismiss-text">선택한 값 또는 실행 시점의 전체 값을 제거합니다. 다음 검사부터 다시 보류되며 이미 시작한 전송은 취소되지 않습니다.</p>
|
|
194
|
+
<button type="button" class="text-btn danger" id="security-allowed-remove">제거 확인</button>
|
|
195
|
+
<button type="button" class="text-btn" id="security-allowed-keep">그대로 두기</button>
|
|
196
|
+
</div>
|
|
184
197
|
</section>
|
|
185
198
|
<section class="settings-section" id="notification-settings" aria-labelledby="notification-title">
|
|
186
199
|
<div class="section-label"><label id="notification-title" for="notification-mode">알림</label></div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HeldState, AllowedValue } from "../src/security-allow.ts";
|
|
2
|
+
import type { HeldMessage } from "../src/security-check.ts";
|
|
3
|
+
|
|
4
|
+
export const HELD_PATH: "/api/security-check/held";
|
|
5
|
+
export const ALLOWED_PATH: "/api/settings/security-check/allowed-values";
|
|
6
|
+
export type HeldDetail = HeldState & Partial<Pick<HeldMessage, "occurrences" | "draft">>;
|
|
7
|
+
export interface Entry {
|
|
8
|
+
held: HeldDetail;
|
|
9
|
+
busy: boolean;
|
|
10
|
+
uncertain: boolean;
|
|
11
|
+
draft: string | undefined;
|
|
12
|
+
restored: boolean;
|
|
13
|
+
request?: {
|
|
14
|
+
readonly send: boolean; readonly remember?: true;
|
|
15
|
+
readonly expectedRevision: number; readonly submissionId: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export type Fetcher = (path: string, init: RequestInit) => Promise<Pick<Response, "ok" | "status" | "json">>;
|
|
19
|
+
export function completeDetail(held: HeldDetail): boolean;
|
|
20
|
+
export function canRestoreHeld(held: HeldState, composer: { conv: string; text: string; images: number; references: number }): boolean;
|
|
21
|
+
export function heldView(entry: Entry, online: boolean, supported: boolean): {
|
|
22
|
+
status: HeldState["status"]; remembered: boolean | null; approve: boolean; cancel: boolean; check: boolean; hide: boolean;
|
|
23
|
+
};
|
|
24
|
+
export function createSecurityInbox(fetcher: Fetcher, changed?: () => void): {
|
|
25
|
+
entries: Map<string, Entry>; hidden: Set<string>;
|
|
26
|
+
merge(held: HeldDetail, confirmed?: boolean): boolean;
|
|
27
|
+
refresh(): Promise<void>; detail(id: string): Promise<void>; decide(id: string, send: boolean): Promise<void>;
|
|
28
|
+
view(id: string): ReturnType<typeof heldView>;
|
|
29
|
+
supported(value: boolean): void; connect(): Promise<void>; disconnect(): void; hide(id: string): boolean;
|
|
30
|
+
};
|
|
31
|
+
export function createAllowedValues(fetcher: Fetcher, changed?: () => void): {
|
|
32
|
+
state: { revision: number; values: AllowedValue[]; online: boolean; busy: boolean; loading: boolean; error: string; ready: boolean };
|
|
33
|
+
refresh(): Promise<void>; connect(): Promise<void>; disconnect(): void; updated(revision: number): void;
|
|
34
|
+
remove(id?: string | null): Promise<void>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Installation-local, memory-only client state. Never persist held details or drafts.
|
|
2
|
+
export const HELD_PATH = "/api/security-check/held";
|
|
3
|
+
export const ALLOWED_PATH = "/api/settings/security-check/allowed-values";
|
|
4
|
+
const TERMINAL = new Set(["sent", "cancelled", "failed", "unconfirmed"]);
|
|
5
|
+
const STATUSES = new Set(["pending", "processing", ...TERMINAL]);
|
|
6
|
+
|
|
7
|
+
export function completeDetail(held) {
|
|
8
|
+
return Array.isArray(held?.occurrences) && held.occurrences.length > 0 &&
|
|
9
|
+
held.occurrences.every((o) => typeof o.canonicalValue === "string" && o.canonicalValue.length > 0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function canRestoreHeld(held, composer) {
|
|
13
|
+
return held.source === "message" && (held.status === "cancelled" || held.status === "failed") &&
|
|
14
|
+
held.conv === composer.conv && composer.text === "" && composer.images === 0 && composer.references === 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function heldView(entry, online, supported) {
|
|
18
|
+
const held = entry.held;
|
|
19
|
+
const status = entry.uncertain ? "unconfirmed" : entry.busy && !TERMINAL.has(held.status) ? "processing" : held.status;
|
|
20
|
+
return {
|
|
21
|
+
status, remembered: entry.uncertain ? null : held.remembered,
|
|
22
|
+
approve: online && supported && !entry.busy && !entry.uncertain && held.status === "pending" && completeDetail(held) && (!entry.request || entry.request.send),
|
|
23
|
+
cancel: online && !entry.busy && !entry.uncertain && held.status === "pending" && (!entry.request || !entry.request.send),
|
|
24
|
+
check: online && !entry.busy && (entry.uncertain || held.status === "processing" || held.status === "unconfirmed"),
|
|
25
|
+
hide: !entry.busy && !entry.uncertain && TERMINAL.has(held.status),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One controller for both surfaces; changed is synchronous, including the pre-await lock. */
|
|
30
|
+
export function createSecurityInbox(fetcher, changed = () => {}) {
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
const hidden = new Set();
|
|
33
|
+
const loads = new Map();
|
|
34
|
+
let generation = 0;
|
|
35
|
+
let online = false;
|
|
36
|
+
let supported = false;
|
|
37
|
+
|
|
38
|
+
function merge(held, confirmed = false) {
|
|
39
|
+
if (!held || typeof held.id !== "string" || !Number.isSafeInteger(held.revision) || held.revision < 1 ||
|
|
40
|
+
!STATUSES.has(held.status) || typeof held.remembered !== "boolean") return false;
|
|
41
|
+
const previous = entries.get(held.id);
|
|
42
|
+
if (previous && held.revision < previous.held.revision) return false;
|
|
43
|
+
if (previous && held.revision === previous.held.revision) {
|
|
44
|
+
if (!completeDetail(previous.held) && completeDetail(held) && !TERMINAL.has(previous.held.status)) {
|
|
45
|
+
previous.held = { ...previous.held, occurrences: held.occurrences, draft: held.draft };
|
|
46
|
+
}
|
|
47
|
+
if (previous.draft === undefined && typeof held.draft === "string" && !TERMINAL.has(previous.held.status)) previous.draft = held.draft;
|
|
48
|
+
if (confirmed) previous.uncertain = false;
|
|
49
|
+
} else {
|
|
50
|
+
const detail = previous && !TERMINAL.has(held.status) ? previous.held : {};
|
|
51
|
+
const next = {
|
|
52
|
+
held: { ...detail, ...held }, busy: previous?.busy ?? false, uncertain: false,
|
|
53
|
+
draft: held.draft ?? previous?.draft, restored: previous?.restored ?? false,
|
|
54
|
+
};
|
|
55
|
+
// Keep the entry identity held by an in-flight request's finally block.
|
|
56
|
+
if (previous) Object.assign(previous, next);
|
|
57
|
+
else entries.set(held.id, next);
|
|
58
|
+
}
|
|
59
|
+
changed();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function detail(id) {
|
|
64
|
+
if (!online || loads.has(id)) return;
|
|
65
|
+
const epoch = generation;
|
|
66
|
+
loads.set(id, epoch);
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetcher(`${HELD_PATH}/${encodeURIComponent(id)}`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
69
|
+
const body = await response.json();
|
|
70
|
+
if (epoch !== generation) return;
|
|
71
|
+
if (!response.ok || body?.id !== id || !merge(body, true)) {
|
|
72
|
+
const entry = entries.get(id);
|
|
73
|
+
if (entry && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
if (epoch !== generation) return;
|
|
77
|
+
const entry = entries.get(id);
|
|
78
|
+
if (entry && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
79
|
+
} finally {
|
|
80
|
+
if (epoch === generation) {
|
|
81
|
+
loads.delete(id);
|
|
82
|
+
changed();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function refresh() {
|
|
88
|
+
const epoch = generation;
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetcher(HELD_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
91
|
+
const body = await response.json();
|
|
92
|
+
if (epoch !== generation || !response.ok) return;
|
|
93
|
+
const snapshots = body.held ?? [];
|
|
94
|
+
const ids = new Set(snapshots.map((h) => h.id));
|
|
95
|
+
const missing = [];
|
|
96
|
+
for (const entry of entries.values()) {
|
|
97
|
+
if (!ids.has(entry.held.id) && !TERMINAL.has(entry.held.status)) {
|
|
98
|
+
entry.uncertain = true;
|
|
99
|
+
missing.push(entry.held.id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const held of snapshots) merge(held);
|
|
103
|
+
if (missing.length > 0) changed();
|
|
104
|
+
// Store-backed gateways return terminal receipts through the dedicated detail endpoint.
|
|
105
|
+
await Promise.all(missing.map((id) => detail(id)));
|
|
106
|
+
} catch {
|
|
107
|
+
// A missing response never proves non-delivery.
|
|
108
|
+
} finally {
|
|
109
|
+
if (epoch === generation) changed();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function decide(id, send) {
|
|
114
|
+
const entry = entries.get(id);
|
|
115
|
+
if (!entry) return;
|
|
116
|
+
const view = heldView(entry, online, supported);
|
|
117
|
+
if (!(send ? view.approve : view.cancel)) return;
|
|
118
|
+
entry.request ??= {
|
|
119
|
+
...(send ? { send: true, remember: true } : { send: false }),
|
|
120
|
+
expectedRevision: entry.held.revision, submissionId: crypto.randomUUID(),
|
|
121
|
+
};
|
|
122
|
+
entry.busy = true;
|
|
123
|
+
const epoch = generation;
|
|
124
|
+
changed();
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetcher(`${HELD_PATH}/${encodeURIComponent(id)}`, {
|
|
127
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify(entry.request),
|
|
129
|
+
signal: AbortSignal.timeout(30_000),
|
|
130
|
+
});
|
|
131
|
+
const body = await response.json();
|
|
132
|
+
if (epoch !== generation) return;
|
|
133
|
+
const snapshot = body?.held ?? body;
|
|
134
|
+
const accepted = snapshot?.id === id && Number.isSafeInteger(snapshot.revision) && merge(snapshot);
|
|
135
|
+
if ((!accepted || response.status >= 500) && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
136
|
+
} catch {
|
|
137
|
+
if (epoch === generation && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
138
|
+
} finally {
|
|
139
|
+
if (epoch === generation) {
|
|
140
|
+
entry.busy = false;
|
|
141
|
+
changed();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
entries, hidden, merge, refresh, detail, decide,
|
|
148
|
+
view: (id) => heldView(entries.get(id), online, supported),
|
|
149
|
+
supported: (value) => { supported = value === true; changed(); },
|
|
150
|
+
connect: () => { generation++; online = true; loads.clear(); return refresh(); },
|
|
151
|
+
disconnect: () => {
|
|
152
|
+
generation++;
|
|
153
|
+
online = false;
|
|
154
|
+
supported = false;
|
|
155
|
+
loads.clear();
|
|
156
|
+
for (const entry of entries.values()) {
|
|
157
|
+
if (!TERMINAL.has(entry.held.status) && (entry.busy || entry.held.status === "processing")) entry.uncertain = true;
|
|
158
|
+
entry.busy = false;
|
|
159
|
+
}
|
|
160
|
+
changed();
|
|
161
|
+
},
|
|
162
|
+
hide: (id) => {
|
|
163
|
+
const entry = entries.get(id);
|
|
164
|
+
if (!entry || !heldView(entry, online, supported).hide) return false;
|
|
165
|
+
hidden.add(id);
|
|
166
|
+
changed();
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createAllowedValues(fetcher, changed = () => {}) {
|
|
173
|
+
const state = { revision: -1, values: [], online: false, busy: false, loading: false, error: "", ready: false };
|
|
174
|
+
let generation = 0;
|
|
175
|
+
let read = 0;
|
|
176
|
+
let wanted = -1;
|
|
177
|
+
async function refresh() {
|
|
178
|
+
if (!state.online) return;
|
|
179
|
+
const epoch = generation;
|
|
180
|
+
const request = ++read;
|
|
181
|
+
state.loading = true;
|
|
182
|
+
state.error = "";
|
|
183
|
+
changed();
|
|
184
|
+
try {
|
|
185
|
+
const response = await fetcher(ALLOWED_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
186
|
+
const body = await response.json();
|
|
187
|
+
if (epoch !== generation || request !== read) return;
|
|
188
|
+
if (!response.ok || !Number.isSafeInteger(body.revision) || !Array.isArray(body.values)) throw new Error("list_unavailable");
|
|
189
|
+
if (body.revision >= state.revision && body.revision >= wanted) {
|
|
190
|
+
state.revision = body.revision;
|
|
191
|
+
state.values = body.values;
|
|
192
|
+
state.ready = true;
|
|
193
|
+
} else state.error = "목록이 변경됐습니다. 다시 확인하세요.";
|
|
194
|
+
} catch {
|
|
195
|
+
if (epoch === generation && request === read) state.error = "허용 값을 확인하지 못했습니다. 다시 시도하세요.";
|
|
196
|
+
} finally {
|
|
197
|
+
if (epoch === generation && request === read) {
|
|
198
|
+
state.loading = false;
|
|
199
|
+
changed();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
state, refresh,
|
|
205
|
+
connect: () => { generation++; state.online = true; return refresh(); },
|
|
206
|
+
disconnect: () => {
|
|
207
|
+
generation++; state.online = false; state.busy = false; state.loading = false; state.ready = false; changed();
|
|
208
|
+
},
|
|
209
|
+
updated: (revision) => {
|
|
210
|
+
if (revision <= Math.max(wanted, state.revision)) return;
|
|
211
|
+
wanted = revision;
|
|
212
|
+
void refresh();
|
|
213
|
+
},
|
|
214
|
+
remove: async (id = null) => {
|
|
215
|
+
if (!state.online || state.busy || state.loading || !state.ready || state.error) return;
|
|
216
|
+
state.busy = true;
|
|
217
|
+
state.error = "";
|
|
218
|
+
const epoch = generation;
|
|
219
|
+
changed();
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetcher(id === null ? ALLOWED_PATH : `${ALLOWED_PATH}/${encodeURIComponent(id)}`, {
|
|
222
|
+
method: "DELETE", headers: { "content-type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ expectedRevision: state.revision }), signal: AbortSignal.timeout(30_000),
|
|
224
|
+
});
|
|
225
|
+
const body = await response.json();
|
|
226
|
+
if (epoch !== generation) return;
|
|
227
|
+
if (!response.ok) throw new Error("delete_unconfirmed");
|
|
228
|
+
wanted = Math.max(wanted, body.revision);
|
|
229
|
+
await refresh();
|
|
230
|
+
} catch {
|
|
231
|
+
if (epoch === generation) state.error = "삭제 결과를 확인하지 못했습니다. 목록을 다시 확인하세요. 삭제를 자동 반복하지 않습니다.";
|
|
232
|
+
} finally {
|
|
233
|
+
if (epoch === generation) { state.busy = false; changed(); }
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
package/web/style.css
CHANGED
|
@@ -99,7 +99,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
99
99
|
.text-btn[hidden] { display: none; }
|
|
100
100
|
.text-btn[aria-expanded="true"] { background: var(--accent); color: #fff; }
|
|
101
101
|
.messages-wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
102
|
-
.messages { position: relative; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 0; }
|
|
102
|
+
.messages { position: relative; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 0; margin-right: 64px; }
|
|
103
103
|
.memo { position: absolute; left: 16px; bottom: 8px; z-index: 5; width: 400px; height: 400px; min-width: 220px; min-height: 160px; max-width: calc(100% - 80px); max-height: calc(100% - 16px); display: flex; flex-direction: column; background: #2b2d31; border: 1px solid #4e5058; border-radius: 8px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); }
|
|
104
104
|
.memo[hidden] { display: none; }
|
|
105
105
|
.memo-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 700; color: var(--muted); border-bottom: 1px solid var(--border); }
|
|
@@ -146,6 +146,21 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
146
146
|
.msg-text code { font-family: ui-monospace, monospace; font-size: 13px; color: var(--warn); background: #1e1f22; border-radius: 3px; padding: 1px 4px; }
|
|
147
147
|
.msg-text pre { background: #1e1f22; border: 1px solid #1a1b1e; border-radius: 4px; padding: 8px 10px; overflow-x: auto; }
|
|
148
148
|
.msg-text pre code { color: var(--text); background: none; padding: 0; white-space: pre; }
|
|
149
|
+
.chat-diagram { margin: 8px 0; min-width: 0; max-width: 100%; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); }
|
|
150
|
+
@media (max-width: 640px) {
|
|
151
|
+
/* Keep this component's source and scroll controls clear of the existing chat navigation. */
|
|
152
|
+
.chat-diagram { margin-right: 48px; }
|
|
153
|
+
}
|
|
154
|
+
.diagram-title { padding: 8px 12px; font-weight: 600; overflow-wrap: anywhere; }
|
|
155
|
+
.diagram-viewport { max-width: 100%; max-height: 24rem; overflow: auto; padding: 12px; }
|
|
156
|
+
.diagram-viewport:focus-visible, .diagram-source summary:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; }
|
|
157
|
+
.diagram-stage { position: relative; display: grid; grid-auto-columns: max-content; gap: 48px 24px; width: max-content; min-width: 100%; padding-right: 24px; isolation: isolate; }
|
|
158
|
+
.diagram-node { width: max-content; justify-self: center; align-self: start; padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-input); color: var(--text); white-space: pre; font: inherit; }
|
|
159
|
+
.diagram-edges { position: absolute; top: 0; left: 0; z-index: -1; pointer-events: none; overflow: visible; }
|
|
160
|
+
.diagram-edges path { fill: none; stroke: var(--muted); stroke-width: 1.5; }
|
|
161
|
+
.diagram-source { border-top: 1px solid var(--border); min-width: 0; }
|
|
162
|
+
.diagram-source summary { min-height: 44px; padding: 12px; color: var(--text); cursor: pointer; }
|
|
163
|
+
.msg-text .diagram-source pre { margin: 0; max-height: 24rem; overflow: auto; border: 0; border-radius: 0 0 8px 8px; background: var(--bg-input); }
|
|
149
164
|
.msg-text blockquote { border-left: 4px solid #4e5058; padding-left: 10px; color: #b5bac1; }
|
|
150
165
|
.msg-text table { border-collapse: collapse; display: block; overflow-x: auto; }
|
|
151
166
|
.msg-text th, .msg-text td { border: 1px solid #4e5058; padding: 4px 8px; text-align: left; }
|
|
@@ -581,3 +596,18 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
581
596
|
.security-card .security-actions { display: flex; gap: 8px; }
|
|
582
597
|
.security-card .security-actions .btn { white-space: nowrap; writing-mode: horizontal-tb; }
|
|
583
598
|
.security-card.done { opacity: 0.75; }
|
|
599
|
+
.security-inbox-item .security-value, .security-allowed-item .security-value { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; }
|
|
600
|
+
.security-inbox-item .security-list { margin: 0; padding-left: 18px; }
|
|
601
|
+
.security-inbox-item .security-question { font-size: 12px; color: var(--muted); }
|
|
602
|
+
.security-actions { flex-wrap: wrap; gap: 8px; }
|
|
603
|
+
.security-actions .text-btn { white-space: normal; }
|
|
604
|
+
.security-status { font-size: 12px; color: var(--muted); }
|
|
605
|
+
.security-status.confirmed { color: var(--ok); }
|
|
606
|
+
.security-allowed-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
|
607
|
+
.security-allowed-item { padding: 8px; border: 1px solid var(--border); border-radius: 4px; min-width: 0; }
|
|
608
|
+
.security-allowed-item .security-value { display: block; font-size: 12px; }
|
|
609
|
+
#security-allowed-confirm[hidden] { display: none; }
|
|
610
|
+
@media (max-width: 640px) {
|
|
611
|
+
.security-actions .text-btn, .security-inbox-item .text-btn, #security-check .text-btn { min-height: 44px; }
|
|
612
|
+
}
|
|
613
|
+
.security-card .security-value { white-space: pre-wrap; overflow-wrap: anywhere; }
|