zigmini 0.8.0-beta.3 → 0.8.0-beta.5
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 +267 -0
- package/README.md +155 -129
- package/assets/agent/gateway-rules.md +1 -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 +382 -0
- package/docs/manual.md +382 -0
- package/host/omo-bridge/continuity-control.ts +5 -26
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +12 -4
- package/host/omo-check/patches/omo-5.1.35-image-replay.patch +45 -0
- package/host/omo-check/patches/omo-5.1.35-question-capability.patch +65 -0
- package/package.json +5 -2
- package/src/cli/doctor.ts +5 -5
- package/src/cli/main.ts +6 -1
- package/src/cli/setup-hint.ts +25 -0
- package/src/engine/schema-guard.ts +1 -1
- package/src/hub.ts +203 -52
- package/src/main.ts +7 -8
- package/src/manual.ts +31 -0
- package/src/omo/generations.ts +28 -2
- package/src/omo-check.ts +2 -2
- package/src/transcript.ts +1 -1
- package/src/update/handoff.ts +1 -1
- package/src/usage.ts +1 -1
- package/src/version.ts +6 -6
- package/web/app.js +123 -25
- package/web/cardbind.d.ts +3 -0
- package/web/cardbind.js +15 -0
- package/web/devlog.js +2 -2
- package/web/gc.js +1 -1
- package/web/index.html +15 -3
- package/web/manual.d.ts +7 -0
- package/web/manual.js +29 -0
- package/web/style.css +9 -0
- package/web/tooltip.d.ts +22 -0
- package/web/tooltip.js +117 -0
package/src/version.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { CORE_NAME, ENGINE_PACKAGE, PLUGIN, OMO_REGISTRY_URL, readEnginePin, for
|
|
|
3
3
|
export { OMO_REGISTRY_URL } from "./engine/index.ts";
|
|
4
4
|
|
|
5
5
|
/** `/version`: the gateway answers it with a notice and never forwards it to omo. */
|
|
6
|
-
export const VERSION_COMMAND = { name: "version", description: "
|
|
6
|
+
export const VERSION_COMMAND = { name: "version", description: "게이트웨이·엔진 버전 보기 (게이트웨이가 바로 답함)", source: "extension" };
|
|
7
7
|
|
|
8
8
|
export function isVersionCommand(text: string): boolean {
|
|
9
9
|
return /^\/version$/i.test(text.trim());
|
|
@@ -104,22 +104,22 @@ export function formatVersionReport(facts: VersionFacts): string {
|
|
|
104
104
|
const build = facts.gateway.build ? `빌드 커밋 ${facts.gateway.build}` : "빌드 커밋 알 수 없음";
|
|
105
105
|
lines.push(`· 게이트웨이: ${facts.gateway.version ?? "알 수 없음"} (${build})`);
|
|
106
106
|
const pin = facts.pin?.omo
|
|
107
|
-
? ` · 저장소 고정
|
|
107
|
+
? ` · 저장소 고정 엔진 ${formatEnginePin(facts.pin)}`
|
|
108
108
|
: " · 저장소 고정 버전 읽기 실패";
|
|
109
109
|
let running: string | null = null;
|
|
110
|
-
if (!facts.omo) lines.push(`·
|
|
110
|
+
if (!facts.omo) lines.push(`· 엔진: 확인 실패 (엔진 연결 상태 ${facts.omoStatus})${pin}`);
|
|
111
111
|
else {
|
|
112
112
|
running = facts.omo.install;
|
|
113
113
|
const engine = facts.omo.engine ? ` (${CORE_NAME} ${facts.omo.engine})` : "";
|
|
114
|
-
lines.push(`·
|
|
114
|
+
lines.push(`· 엔진: ${facts.omo.install ?? "설치 경로 확인 실패"}${engine}${pin}`);
|
|
115
115
|
}
|
|
116
116
|
if ("version" in facts.latest) {
|
|
117
|
-
lines.push(`· 최신
|
|
117
|
+
lines.push(`· 최신 엔진: ${facts.latest.version} (npm ${ENGINE_PACKAGE})`);
|
|
118
118
|
const current = running ?? facts.pin?.omo ?? null;
|
|
119
119
|
if (current && isNewer(facts.latest.version, current)) {
|
|
120
120
|
lines.push(`업데이트 가능: ${current} → ${facts.latest.version} (자동으로 업데이트하지 않습니다)`);
|
|
121
121
|
}
|
|
122
|
-
} else lines.push(`· 최신
|
|
122
|
+
} else lines.push(`· 최신 엔진: 확인 실패 (${facts.latest.error})`);
|
|
123
123
|
return lines.join("\n");
|
|
124
124
|
}
|
|
125
125
|
|
package/web/app.js
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
import { agentIconUrl, editAgentIcon, readAgentIcon, writeAgentIcon } from "./agent-icon.js";
|
|
27
27
|
import { hasTextSelection, panelShortcut } from "./shortcuts.js";
|
|
28
28
|
import { decisionCommand, lastSentLabel, lastSentTooltip, nextLastSent } from "./lastsent.js";
|
|
29
|
+
import { installTooltips } from "./tooltip.js";
|
|
29
30
|
import { placeRow, unseenEvents } from "./chatorder.js";
|
|
31
|
+
import { cardsOfDecision } from "./cardbind.js";
|
|
30
32
|
import { createRecall, recallKey, resetRecall } from "./recall.js";
|
|
31
33
|
import {
|
|
32
34
|
addReference, clearReferences, createReferences, inputData, markReferenceable, planSend, referenceSeqs, referenceSource, removeReference,
|
|
@@ -41,6 +43,9 @@ import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } f
|
|
|
41
43
|
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";
|
|
42
44
|
import { createAppDownload } from "./app-download.js";
|
|
43
45
|
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
|
|
46
|
+
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
47
|
+
|
|
48
|
+
installTooltips(document, window);
|
|
44
49
|
|
|
45
50
|
const $ = (id) => document.getElementById(id);
|
|
46
51
|
const state = {
|
|
@@ -675,12 +680,9 @@ function cardForDecision(id) {
|
|
|
675
680
|
return els.messages.querySelector(`.approval[data-decision-id="${CSS.escape(id)}"]`);
|
|
676
681
|
}
|
|
677
682
|
|
|
678
|
-
/**
|
|
679
|
-
function
|
|
680
|
-
|
|
681
|
-
if (explicit) return explicit;
|
|
682
|
-
const card = cardFor(originalActionId(decision));
|
|
683
|
-
return card && (card.dataset.decisionId === undefined || card.dataset.decisionId === decision.id) ? card : null;
|
|
683
|
+
/** Every mounted card of a decision; a rebound question keeps its decision id on a new card (bug 154). */
|
|
684
|
+
function cardsOf(decision) {
|
|
685
|
+
return cardsOfDecision(els.messages.querySelectorAll(".approval"), decision.id, originalActionId(decision));
|
|
684
686
|
}
|
|
685
687
|
|
|
686
688
|
/** Legacy or still-loading card bodies: controls on or off plus one status line, optionally with a retry control. */
|
|
@@ -714,13 +716,14 @@ function setCardBusy(actionId, busy) {
|
|
|
714
716
|
}
|
|
715
717
|
|
|
716
718
|
/** Re-renders the mounted card of a decision from canonical state; an id-less card follows only its own unique alias. */
|
|
717
|
-
function syncCard(decision) {
|
|
718
|
-
const card
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
719
|
+
function syncCard(decision, except = null) {
|
|
720
|
+
for (const card of cardsOf(decision)) {
|
|
721
|
+
if (card === except) continue;
|
|
722
|
+
const request = cardRequests.get(card);
|
|
723
|
+
if (!request) continue;
|
|
724
|
+
if (typeof request.decisionId !== "string" && decisionForAction(inbox.state, request.actionId)?.id !== decision.id) continue;
|
|
725
|
+
renderCardBody(card, decision);
|
|
726
|
+
}
|
|
724
727
|
}
|
|
725
728
|
|
|
726
729
|
/** One line of state under a canonical card: in transit, locked for review, offline, finished, or why it still waits. */
|
|
@@ -1555,7 +1558,7 @@ function renderOmo() {
|
|
|
1555
1558
|
if (omoStatus === null) return;
|
|
1556
1559
|
const ready = omoStatus === "ready";
|
|
1557
1560
|
const checking = ready && state.ws?.readyState === WebSocket.OPEN && isUnknown(activity, state.current);
|
|
1558
|
-
els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "
|
|
1561
|
+
els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "엔진 연결됨" : omoStatus === "connecting" ? "엔진 연결 중" : "엔진 끊김";
|
|
1559
1562
|
els.omo.classList.toggle("ok", ready);
|
|
1560
1563
|
}
|
|
1561
1564
|
|
|
@@ -1580,7 +1583,7 @@ function renderModels(info) {
|
|
|
1580
1583
|
modelSelect.append(group);
|
|
1581
1584
|
}
|
|
1582
1585
|
if (info.current) modelSelect.value = `${info.current.provider}/${info.current.id}`;
|
|
1583
|
-
modelSelect.title = info.current ?
|
|
1586
|
+
modelSelect.title = info.current ? `엔진 모델: ${info.current.name}` : "엔진 모델";
|
|
1584
1587
|
// A held switch stays on the current model until the next message compacts the context for the target.
|
|
1585
1588
|
const pending = info.pending ?? null;
|
|
1586
1589
|
modelPending.hidden = !pending;
|
|
@@ -2026,7 +2029,7 @@ function renderSlashMenu() {
|
|
|
2026
2029
|
});
|
|
2027
2030
|
return row;
|
|
2028
2031
|
});
|
|
2029
|
-
const note = slash.loading ? "
|
|
2032
|
+
const note = slash.loading ? "엔진 명령과 스킬을 불러오는 중…" : slash.failed ? "엔진 명령과 스킬을 불러오지 못했습니다. 웹 지그 명령만 보입니다." : rows.length === 0 ? "맞는 명령이나 스킬이 없습니다." : "";
|
|
2030
2033
|
if (note) {
|
|
2031
2034
|
const div = document.createElement("div");
|
|
2032
2035
|
div.className = "slash-note";
|
|
@@ -2109,7 +2112,7 @@ $("new-conv").onclick = () => {
|
|
|
2109
2112
|
};
|
|
2110
2113
|
$("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
|
|
2111
2114
|
$("rotate-session").onclick = () => {
|
|
2112
|
-
if (!confirm("이 대화의
|
|
2115
|
+
if (!confirm("이 대화의 엔진 세션을 새로 시작할까요?\n화면의 기록은 그대로 남고, 최근 대화 일부를 새 세션에 넘겨 줍니다.")) return;
|
|
2113
2116
|
send({ type: "conversation.rotate", conv: state.current });
|
|
2114
2117
|
};
|
|
2115
2118
|
function toggleDevPanel() {
|
|
@@ -2582,6 +2585,12 @@ async function fetchDecisions(cursor) {
|
|
|
2582
2585
|
}
|
|
2583
2586
|
|
|
2584
2587
|
/** `refresh`: load the current copy even though one is cached (a mounted card the reset page no longer lists). */
|
|
2588
|
+
/** The mounted cards a detail load reports to: every card of the decision id, so a second card never waits on the first. */
|
|
2589
|
+
function detailCards(id, card) {
|
|
2590
|
+
const cards = [...els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)];
|
|
2591
|
+
return card?.isConnected && !cards.includes(card) ? [...cards, card] : cards;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2585
2594
|
async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
2586
2595
|
const generation = decisionGeneration;
|
|
2587
2596
|
if ((!refresh && decisionById(inbox.state, id)) || !inbox.online || !beginDetailLoad(inbox.state, id, generation)) return;
|
|
@@ -2591,19 +2600,19 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
|
2591
2600
|
const body = await res.json().catch(() => null);
|
|
2592
2601
|
if (!res.ok) {
|
|
2593
2602
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2594
|
-
|
|
2603
|
+
for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: `결정 상태를 불러오지 못했습니다 (${res.status}).`, retry });
|
|
2595
2604
|
return;
|
|
2596
2605
|
}
|
|
2597
2606
|
const result = finishDetailLoad(inbox.state, id, generation, body);
|
|
2598
2607
|
if (!result.accepted) {
|
|
2599
|
-
if (
|
|
2608
|
+
if (result.reason === "mismatch") for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: "결정 식별자가 일치하지 않습니다.", retry });
|
|
2600
2609
|
return;
|
|
2601
2610
|
}
|
|
2602
2611
|
const decision = decisionById(inbox.state, id);
|
|
2603
2612
|
if (decision) syncCard(decision);
|
|
2604
2613
|
} catch (err) {
|
|
2605
2614
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2606
|
-
|
|
2615
|
+
for (const shown of detailCards(id, card)) setCardState(shown, {
|
|
2607
2616
|
disabled: true,
|
|
2608
2617
|
note: `결정 상태를 불러오지 못했습니다: ${err instanceof Error ? err.message : String(err)}`,
|
|
2609
2618
|
retry,
|
|
@@ -2937,8 +2946,7 @@ function reviewLatest(decision) {
|
|
|
2937
2946
|
|
|
2938
2947
|
/** Both surfaces render the same canonical decision; the surface being edited keeps its focus. */
|
|
2939
2948
|
function syncDecisionSurfaces(decision, except = null) {
|
|
2940
|
-
|
|
2941
|
-
if (card && card !== except) syncCard(decision);
|
|
2949
|
+
syncCard(decision, except);
|
|
2942
2950
|
if (inbox.state.items.has(decision.id) && inbox.nodes.get(decision.id) !== except) renderInboxItem(decision);
|
|
2943
2951
|
}
|
|
2944
2952
|
|
|
@@ -3174,8 +3182,7 @@ function settleSubmit(decision, result) {
|
|
|
3174
3182
|
break;
|
|
3175
3183
|
case "gone": {
|
|
3176
3184
|
forgetDecision(inbox.state, id);
|
|
3177
|
-
const card
|
|
3178
|
-
if (card) {
|
|
3185
|
+
for (const card of els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)) {
|
|
3179
3186
|
card.removeAttribute("aria-busy");
|
|
3180
3187
|
setCardState(card, { disabled: true, note: "서버에 더 이상 없는 요청입니다." });
|
|
3181
3188
|
}
|
|
@@ -3595,10 +3602,12 @@ function gcRow(row) {
|
|
|
3595
3602
|
const name = document.createElement("span");
|
|
3596
3603
|
name.className = "gc-name";
|
|
3597
3604
|
name.textContent = row.label;
|
|
3598
|
-
name.
|
|
3605
|
+
name.dataset.tip = row.title;
|
|
3606
|
+
name.dataset.tipOverflow = "";
|
|
3599
3607
|
const sub = document.createElement("span");
|
|
3600
3608
|
sub.className = "gc-sub";
|
|
3601
3609
|
sub.textContent = row.sub;
|
|
3610
|
+
sub.dataset.tipOverflow = "";
|
|
3602
3611
|
main.append(name, sub);
|
|
3603
3612
|
label.append(box, main);
|
|
3604
3613
|
li.append(label);
|
|
@@ -3689,4 +3698,93 @@ settings.toggle.onclick = () => (settings.open ? closeSettings() : openSettings(
|
|
|
3689
3698
|
settings.close.onclick = closeSettings;
|
|
3690
3699
|
enrollEls.renew.onclick = () => void enrollment.renew();
|
|
3691
3700
|
|
|
3701
|
+
// 설정 "매뉴얼": GET /api/manual (Korean first) rendered with the chat Markdown renderer; the first-line language links
|
|
3702
|
+
// and the header button switch to the other language in place.
|
|
3703
|
+
const manualEls = { el: $("manual"), open: $("manual-open"), close: $("manual-close"), title: $("manual-title"), lang: $("manual-lang"), status: $("manual-status"), body: $("manual-body") };
|
|
3704
|
+
const manualView = { open: false, lang: "ko", load: 0 };
|
|
3705
|
+
|
|
3706
|
+
function showManualStatus(text, error = false) {
|
|
3707
|
+
manualEls.status.textContent = text;
|
|
3708
|
+
manualEls.status.classList.toggle("error", error);
|
|
3709
|
+
manualEls.status.hidden = text === "";
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
async function loadManual(lang) {
|
|
3713
|
+
const load = ++manualView.load;
|
|
3714
|
+
manualView.lang = lang;
|
|
3715
|
+
manualEls.lang.textContent = otherLang(lang) === "en" ? "English" : "한국어";
|
|
3716
|
+
manualEls.el.lang = lang;
|
|
3717
|
+
showManualStatus("매뉴얼을 불러오는 중입니다.");
|
|
3718
|
+
let status = 0;
|
|
3719
|
+
let text = "";
|
|
3720
|
+
try {
|
|
3721
|
+
const res = await fetch(manualUrl(lang));
|
|
3722
|
+
status = res.status;
|
|
3723
|
+
if (res.ok) text = await res.text();
|
|
3724
|
+
} catch (error) {
|
|
3725
|
+
console.warn("manual:", error);
|
|
3726
|
+
}
|
|
3727
|
+
if (load !== manualView.load) return;
|
|
3728
|
+
if (text === "") {
|
|
3729
|
+
manualEls.body.innerHTML = "";
|
|
3730
|
+
showManualStatus(manualFailureText(status), true);
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
showManualStatus("");
|
|
3734
|
+
renderMarkdown(manualEls.body, text);
|
|
3735
|
+
manualEls.body.scrollTop = 0;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
function openManual() {
|
|
3739
|
+
if (manualView.open) return;
|
|
3740
|
+
manualView.open = true;
|
|
3741
|
+
manualEls.el.hidden = false;
|
|
3742
|
+
manualEls.open.setAttribute("aria-expanded", "true");
|
|
3743
|
+
manualEls.title.focus();
|
|
3744
|
+
void loadManual(manualView.lang);
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
function closeManual() {
|
|
3748
|
+
if (!manualView.open) return;
|
|
3749
|
+
manualView.open = false;
|
|
3750
|
+
manualView.load++;
|
|
3751
|
+
manualEls.el.hidden = true;
|
|
3752
|
+
manualEls.open.setAttribute("aria-expanded", "false");
|
|
3753
|
+
manualEls.open.focus();
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
manualEls.open.onclick = openManual;
|
|
3757
|
+
manualEls.close.onclick = closeManual;
|
|
3758
|
+
manualEls.lang.onclick = () => void loadManual(otherLang(manualView.lang));
|
|
3759
|
+
manualEls.body.addEventListener("click", (ev) => {
|
|
3760
|
+
const link = ev.target instanceof Element ? ev.target.closest("a") : null;
|
|
3761
|
+
const lang = manualLinkLang(link?.getAttribute("href"));
|
|
3762
|
+
if (lang === null) return;
|
|
3763
|
+
ev.preventDefault();
|
|
3764
|
+
void loadManual(lang);
|
|
3765
|
+
});
|
|
3766
|
+
manualEls.el.addEventListener("click", (ev) => {
|
|
3767
|
+
if (ev.target === manualEls.el) closeManual();
|
|
3768
|
+
});
|
|
3769
|
+
manualEls.el.addEventListener("keydown", (ev) => {
|
|
3770
|
+
if (ev.isComposing) return;
|
|
3771
|
+
if (ev.key === "Escape") {
|
|
3772
|
+
ev.preventDefault();
|
|
3773
|
+
closeManual();
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
if (ev.key !== "Tab") return;
|
|
3777
|
+
const focusable = [...manualEls.el.querySelectorAll(FOCUSABLE)].filter((node) => node.offsetParent !== null);
|
|
3778
|
+
if (focusable.length === 0) return;
|
|
3779
|
+
const first = focusable[0];
|
|
3780
|
+
const last = focusable.at(-1);
|
|
3781
|
+
if (ev.shiftKey && (document.activeElement === first || document.activeElement === manualEls.title)) {
|
|
3782
|
+
ev.preventDefault();
|
|
3783
|
+
last.focus();
|
|
3784
|
+
} else if (!ev.shiftKey && document.activeElement === last) {
|
|
3785
|
+
ev.preventDefault();
|
|
3786
|
+
first.focus();
|
|
3787
|
+
}
|
|
3788
|
+
});
|
|
3789
|
+
|
|
3692
3790
|
connect();
|
package/web/cardbind.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Which chat cards show one decision (bug 154). A question that is rebound (for example after a session change) gets a
|
|
2
|
+
// new card that keeps the same decision id, so one decision can own several cards. Every one of them must follow the
|
|
3
|
+
// canonical decision; updating only the first left the others open or stuck at "확인 중…" after the answer was delivered.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every mounted card of a decision: the cards that carry its explicit id, else the id-less cards of its original action
|
|
7
|
+
* alias. A card bound to another decision never matches.
|
|
8
|
+
*/
|
|
9
|
+
export function cardsOfDecision(cards, decisionId, aliasActionId) {
|
|
10
|
+
const list = [...cards];
|
|
11
|
+
const explicit = list.filter((card) => card.dataset.decisionId === decisionId);
|
|
12
|
+
if (explicit.length > 0) return explicit;
|
|
13
|
+
return list.filter((card) => card.dataset.actionId === aliasActionId &&
|
|
14
|
+
(card.dataset.decisionId === undefined || card.dataset.decisionId === decisionId));
|
|
15
|
+
}
|
package/web/devlog.js
CHANGED
|
@@ -71,9 +71,9 @@ function summary(type, d = {}) {
|
|
|
71
71
|
case "notice":
|
|
72
72
|
return clip(d.message);
|
|
73
73
|
case "omo.status":
|
|
74
|
-
return clip(
|
|
74
|
+
return clip(`엔진 ${d.status}${d.detail ? ` · ${d.detail}` : ""}`);
|
|
75
75
|
case "state.snapshot":
|
|
76
|
-
return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} ·
|
|
76
|
+
return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} · 엔진 ${d.omo}`;
|
|
77
77
|
case "history":
|
|
78
78
|
return `${d.conv} 기록 ${d.events?.length ?? 0}건`;
|
|
79
79
|
case "conversation.created":
|
package/web/gc.js
CHANGED
|
@@ -5,7 +5,7 @@ export const GC_PATH = "/api/gc";
|
|
|
5
5
|
export const GC_CLEAN_PATH = "/api/gc/clean";
|
|
6
6
|
export const KIND_LABELS = Object.freeze({
|
|
7
7
|
"db-backup": "DB 백업",
|
|
8
|
-
"omo-version": "
|
|
8
|
+
"omo-version": "엔진 버전 폴더",
|
|
9
9
|
"update-file": "업데이트 남은 파일",
|
|
10
10
|
"temp-dir": "임시 폴더",
|
|
11
11
|
"rpc-daemon": "이전 RPC 데몬",
|
package/web/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<aside class="sidebar">
|
|
12
12
|
<header class="sidebar-head">
|
|
13
13
|
<span class="sidebar-title">zigmini</span>
|
|
14
|
-
<span class="badge" id="omo-status" title="
|
|
14
|
+
<span class="badge" id="omo-status" title="엔진 연결 상태">엔진</span>
|
|
15
15
|
</header>
|
|
16
16
|
<div class="sidebar-section">
|
|
17
17
|
<div class="section-label">
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
<span class="login-notice" id="schema-notice" role="alert" hidden></span>
|
|
61
61
|
<button type="button" class="update-notice" id="update-notice" title="설정의 업데이트 열기" hidden></button>
|
|
62
62
|
<div class="chat-actions">
|
|
63
|
-
<select class="model-select" id="model-select" title="
|
|
63
|
+
<select class="model-select" id="model-select" title="엔진이 쓸 모델" disabled><option>모델 불러오는 중</option></select>
|
|
64
64
|
<span class="model-pending" id="model-pending" hidden></span>
|
|
65
|
-
<button class="text-btn" id="rotate-session" title="이 대화의
|
|
65
|
+
<button class="text-btn" id="rotate-session" title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
|
|
66
66
|
<button class="text-btn" id="toggle-voice" title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
|
|
67
67
|
<button class="text-btn" id="voice-install" title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
|
|
68
68
|
<button class="text-btn" id="toggle-clap" title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
<div class="settings-card">
|
|
123
123
|
<header class="settings-head">
|
|
124
124
|
<h2 class="settings-title" id="settings-title" tabindex="-1">설정</h2>
|
|
125
|
+
<button type="button" class="text-btn" id="manual-open" title="사용자 매뉴얼: 화면, 단축키, 기능" aria-haspopup="dialog" aria-expanded="false" aria-controls="manual">매뉴얼</button>
|
|
125
126
|
<button type="button" class="icon-btn settings-close" id="settings-close" aria-label="설정 닫기" title="닫기 (Esc)">×</button>
|
|
126
127
|
</header>
|
|
127
128
|
<div class="settings-body">
|
|
@@ -185,6 +186,17 @@
|
|
|
185
186
|
</div>
|
|
186
187
|
</div>
|
|
187
188
|
</aside>
|
|
189
|
+
<aside class="settings manual" id="manual" role="dialog" aria-labelledby="manual-title" hidden>
|
|
190
|
+
<div class="settings-card manual-card">
|
|
191
|
+
<header class="settings-head">
|
|
192
|
+
<h2 class="settings-title" id="manual-title" tabindex="-1">매뉴얼</h2>
|
|
193
|
+
<button type="button" class="text-btn" id="manual-lang">English</button>
|
|
194
|
+
<button type="button" class="icon-btn settings-close" id="manual-close" aria-label="매뉴얼 닫기" title="닫기 (Esc)">×</button>
|
|
195
|
+
</header>
|
|
196
|
+
<p class="manual-status" id="manual-status" role="status" aria-live="polite" hidden></p>
|
|
197
|
+
<div class="manual-body msg-text" id="manual-body"></div>
|
|
198
|
+
</div>
|
|
199
|
+
</aside>
|
|
188
200
|
<aside class="dev" id="dev">
|
|
189
201
|
<header class="dev-head">
|
|
190
202
|
<span>이벤트</span>
|
package/web/manual.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type ManualLang = "ko" | "en";
|
|
2
|
+
export declare const MANUAL_PATH: string;
|
|
3
|
+
export declare const MANUAL_MISSING_TEXT: string;
|
|
4
|
+
export declare function manualUrl(lang: string): string;
|
|
5
|
+
export declare function otherLang(lang: string): ManualLang;
|
|
6
|
+
export declare function manualLinkLang(href: string | null | undefined): ManualLang | null;
|
|
7
|
+
export declare function manualFailureText(status: number): string;
|
package/web/manual.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The user manual view of 설정 "매뉴얼": decisions only, with no DOM, so test/unit/manual.test.ts and the mutation run
|
|
2
|
+
// drive them directly; app.js fetches GET /api/manual and renders the Markdown with the chat renderer.
|
|
3
|
+
|
|
4
|
+
export const MANUAL_PATH = "/api/manual";
|
|
5
|
+
export const MANUAL_MISSING_TEXT = "이 게이트웨이 버전에는 매뉴얼이 없습니다. 게이트웨이를 업데이트하면 볼 수 있습니다.";
|
|
6
|
+
|
|
7
|
+
export function manualUrl(lang) {
|
|
8
|
+
return `${MANUAL_PATH}?lang=${lang === "en" ? "en" : "ko"}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function otherLang(lang) {
|
|
12
|
+
return lang === "en" ? "ko" : "en";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The language a rendered link switches to: the manuals' first line links manual.md (English) and manual.ko.md (Korean). */
|
|
16
|
+
export function manualLinkLang(href) {
|
|
17
|
+
if (typeof href !== "string") return null;
|
|
18
|
+
const file = href.split(/[?#]/)[0].split("/").pop();
|
|
19
|
+
if (file === "manual.md") return "en";
|
|
20
|
+
if (file === "manual.ko.md") return "ko";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The line for a failed load: [status] is the HTTP status, or 0 when the request itself failed. */
|
|
25
|
+
export function manualFailureText(status) {
|
|
26
|
+
if (status === 404) return MANUAL_MISSING_TEXT;
|
|
27
|
+
if (status === 0) return "매뉴얼을 불러오지 못했습니다. 게이트웨이 연결을 확인하세요.";
|
|
28
|
+
return `매뉴얼을 불러오지 못했습니다 (HTTP ${status}).`;
|
|
29
|
+
}
|
package/web/style.css
CHANGED
|
@@ -423,6 +423,11 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
423
423
|
.gc-message:empty { display: none; }
|
|
424
424
|
.gc-message.error { color: #f38b8d; }
|
|
425
425
|
.gc-actions { display: flex; justify-content: flex-end; }
|
|
426
|
+
.settings.manual { z-index: 31; }
|
|
427
|
+
.manual-card { width: min(100%, 760px); height: 100%; }
|
|
428
|
+
.manual-status { margin: 0; padding: 12px 16px 0; font-size: 13px; color: var(--muted); }
|
|
429
|
+
.manual-status.error { color: #f38b8d; }
|
|
430
|
+
.manual-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px; }
|
|
426
431
|
@media (max-width: 640px) {
|
|
427
432
|
.settings { padding: 0; }
|
|
428
433
|
.settings-card { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
|
|
@@ -556,3 +561,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
556
561
|
.app.status-open .ws-title { font-size: 14px; color: var(--text); }
|
|
557
562
|
.app.status-open .ws-caption { font-size: 13px; padding: 4px 10px; }
|
|
558
563
|
}
|
|
564
|
+
|
|
565
|
+
/* Bug 90: the page-drawn tooltip (web/tooltip.js). */
|
|
566
|
+
.zgw-tip { position: fixed; z-index: 10000; max-width: min(360px, calc(100vw - 16px)); padding: 6px 9px; border-radius: 6px; background: #111214; color: var(--text); border: 1px solid #3f4147; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); font-size: 12px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; }
|
|
567
|
+
.zgw-tip[hidden] { display: none; }
|
package/web/tooltip.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Box = { left: number; top: number; right: number; bottom: number };
|
|
2
|
+
export type TipElement = {
|
|
3
|
+
getAttribute(name: string): string | null;
|
|
4
|
+
setAttribute(name: string, value: string): void;
|
|
5
|
+
removeAttribute(name: string): void;
|
|
6
|
+
hasAttribute(name: string): boolean;
|
|
7
|
+
textContent: string | null;
|
|
8
|
+
scrollWidth: number;
|
|
9
|
+
clientWidth: number;
|
|
10
|
+
scrollHeight: number;
|
|
11
|
+
clientHeight: number;
|
|
12
|
+
};
|
|
13
|
+
export const TIP_GAP: number;
|
|
14
|
+
export const TIP_MARGIN: number;
|
|
15
|
+
export const TIP_ID: string;
|
|
16
|
+
export function placeTip(anchor: Box, tip: { width: number; height: number }, view: { width: number; height: number }): { left: number; top: number; side: "above" | "below" };
|
|
17
|
+
export function isTruncated(el: { scrollWidth: number; clientWidth: number; scrollHeight: number; clientHeight: number }): boolean;
|
|
18
|
+
export function adoptTitle(el: Pick<TipElement, "getAttribute" | "setAttribute" | "removeAttribute" | "hasAttribute">): void;
|
|
19
|
+
export function tipText(el: TipElement): string;
|
|
20
|
+
export function addId(list: string | null, id: string): string;
|
|
21
|
+
export function removeId(list: string | null, id: string): string;
|
|
22
|
+
export function installTooltips(doc: Document, win: Window & typeof globalThis): void;
|
package/web/tooltip.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Bug 90: the page draws its own tooltip. Some browsers (the user's, likely Aside) never show the native `title`
|
|
2
|
+
// tooltip, so every `title` in the page moves to `data-tip` and one shared element shows it on hover and keyboard focus,
|
|
3
|
+
// linked with aria-describedby. Improvement 143: `data-tip-overflow` shows the tip only while the text is cut off.
|
|
4
|
+
|
|
5
|
+
export const TIP_GAP = 6;
|
|
6
|
+
export const TIP_MARGIN = 8;
|
|
7
|
+
export const TIP_ID = "zgw-tip";
|
|
8
|
+
const HOVER_DELAY_MS = 250;
|
|
9
|
+
const SELECTOR = "[data-tip], [data-tip-overflow]";
|
|
10
|
+
|
|
11
|
+
export function placeTip(anchor, tip, view) {
|
|
12
|
+
const left = Math.max(TIP_MARGIN, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, view.width - tip.width - TIP_MARGIN));
|
|
13
|
+
const below = anchor.bottom + TIP_GAP;
|
|
14
|
+
const above = anchor.top - TIP_GAP - tip.height;
|
|
15
|
+
const fitsBelow = below + tip.height <= view.height - TIP_MARGIN;
|
|
16
|
+
if (!fitsBelow && above >= TIP_MARGIN) return { left, top: above, side: "above" };
|
|
17
|
+
const top = Math.max(TIP_MARGIN, Math.min(below, view.height - tip.height - TIP_MARGIN));
|
|
18
|
+
return { left, top, side: "below" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isTruncated(el) {
|
|
22
|
+
return el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function adoptTitle(el) {
|
|
26
|
+
if (!el.hasAttribute("title")) return;
|
|
27
|
+
const text = el.getAttribute("title");
|
|
28
|
+
el.removeAttribute("title");
|
|
29
|
+
if (text) el.setAttribute("data-tip", text);
|
|
30
|
+
else el.removeAttribute("data-tip");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function tipText(el) {
|
|
34
|
+
if (el.hasAttribute("data-tip-overflow") && !isTruncated(el)) return "";
|
|
35
|
+
return el.getAttribute("data-tip") || (el.hasAttribute("data-tip-overflow") ? el.textContent : "") || "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function addId(list, id) {
|
|
39
|
+
const ids = (list ?? "").split(/\s+/).filter(Boolean);
|
|
40
|
+
return ids.includes(id) ? ids.join(" ") : [...ids, id].join(" ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function removeId(list, id) {
|
|
44
|
+
return (list ?? "").split(/\s+/).filter((part) => part && part !== id).join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function installTooltips(doc, win) {
|
|
48
|
+
const tip = doc.createElement("div");
|
|
49
|
+
tip.id = TIP_ID;
|
|
50
|
+
tip.className = "zgw-tip";
|
|
51
|
+
tip.setAttribute("role", "tooltip");
|
|
52
|
+
tip.hidden = true;
|
|
53
|
+
doc.body.append(tip);
|
|
54
|
+
let owner = null;
|
|
55
|
+
let timer = 0;
|
|
56
|
+
|
|
57
|
+
const adoptAll = (root) => {
|
|
58
|
+
if (root.nodeType !== 1) return;
|
|
59
|
+
adoptTitle(root);
|
|
60
|
+
for (const el of root.querySelectorAll("[title]")) adoptTitle(el);
|
|
61
|
+
};
|
|
62
|
+
adoptAll(doc.body);
|
|
63
|
+
new win.MutationObserver((records) => {
|
|
64
|
+
for (const record of records) {
|
|
65
|
+
if (record.type === "attributes") adoptTitle(record.target);
|
|
66
|
+
else for (const node of record.addedNodes) adoptAll(node);
|
|
67
|
+
}
|
|
68
|
+
}).observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["title"] });
|
|
69
|
+
|
|
70
|
+
const hide = () => {
|
|
71
|
+
win.clearTimeout(timer);
|
|
72
|
+
if (owner) {
|
|
73
|
+
const rest = removeId(owner.getAttribute("aria-describedby"), TIP_ID);
|
|
74
|
+
if (rest) owner.setAttribute("aria-describedby", rest);
|
|
75
|
+
else owner.removeAttribute("aria-describedby");
|
|
76
|
+
}
|
|
77
|
+
owner = null;
|
|
78
|
+
tip.hidden = true;
|
|
79
|
+
};
|
|
80
|
+
const show = (el) => {
|
|
81
|
+
const text = tipText(el);
|
|
82
|
+
if (!text || !el.isConnected) return hide();
|
|
83
|
+
if (owner !== el) hide();
|
|
84
|
+
owner = el;
|
|
85
|
+
tip.textContent = text;
|
|
86
|
+
tip.style.left = "0px";
|
|
87
|
+
tip.style.top = "0px";
|
|
88
|
+
tip.hidden = false;
|
|
89
|
+
el.setAttribute("aria-describedby", addId(el.getAttribute("aria-describedby"), TIP_ID));
|
|
90
|
+
const place = placeTip(el.getBoundingClientRect(), tip.getBoundingClientRect(), { width: win.innerWidth, height: win.innerHeight });
|
|
91
|
+
tip.style.left = `${Math.round(place.left)}px`;
|
|
92
|
+
tip.style.top = `${Math.round(place.top)}px`;
|
|
93
|
+
tip.dataset.side = place.side;
|
|
94
|
+
};
|
|
95
|
+
const target = (event) => event.target instanceof win.Element ? event.target.closest(SELECTOR) : null;
|
|
96
|
+
|
|
97
|
+
doc.addEventListener("pointerover", (event) => {
|
|
98
|
+
if (event.pointerType === "touch") return;
|
|
99
|
+
const el = target(event);
|
|
100
|
+
if (el === owner) return;
|
|
101
|
+
hide();
|
|
102
|
+
if (el) timer = win.setTimeout(() => show(el), HOVER_DELAY_MS);
|
|
103
|
+
});
|
|
104
|
+
doc.addEventListener("pointerout", (event) => {
|
|
105
|
+
const el = target(event);
|
|
106
|
+
if (el && !(event.relatedTarget instanceof win.Node && el.contains(event.relatedTarget))) hide();
|
|
107
|
+
});
|
|
108
|
+
doc.addEventListener("focusin", (event) => {
|
|
109
|
+
const el = target(event);
|
|
110
|
+
if (el && event.target.matches(":focus-visible")) show(el);
|
|
111
|
+
});
|
|
112
|
+
doc.addEventListener("focusout", hide);
|
|
113
|
+
doc.addEventListener("pointerdown", hide, true);
|
|
114
|
+
doc.addEventListener("keydown", (event) => { if (event.key === "Escape" && owner) hide(); });
|
|
115
|
+
win.addEventListener("scroll", hide, true);
|
|
116
|
+
win.addEventListener("resize", hide);
|
|
117
|
+
}
|