zigmini 0.8.0-beta.4 → 0.8.0-beta.6
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 +4 -3
- package/README.md +4 -3
- 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 +4 -3
- package/docs/manual.md +4 -3
- package/host/omo-bridge/continuity-control.ts +3 -2
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +13 -4
- package/host/omo-check/patches/omo-5.1.35-image-replay.patch +45 -0
- package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
- package/host/omo-check/patches/omo-5.1.35-question-capability.patch +65 -0
- package/package.json +1 -1
- package/src/cli/doctor.ts +5 -5
- package/src/cli/main.ts +15 -1
- package/src/cli/rotate-main.ts +411 -0
- package/src/engine/schema-guard.ts +1 -1
- package/src/hub.ts +184 -43
- package/src/main.ts +2 -2
- package/src/omo-check.ts +2 -2
- package/src/protocol.ts +3 -0
- 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 +68 -31
- package/web/cardbind.d.ts +3 -0
- package/web/cardbind.js +15 -0
- package/web/devlog.js +2 -2
- package/web/gc.d.ts +2 -0
- package/web/gc.js +12 -1
- package/web/index.html +12 -12
- package/web/style.css +8 -0
- package/web/tooltip.d.ts +16 -0
- package/web/tooltip.js +114 -0
- package/web/update.d.ts +1 -1
- package/web/update.js +10 -3
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,
|
|
@@ -40,9 +42,11 @@ import {
|
|
|
40
42
|
import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } from "./enroll.js";
|
|
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
|
-
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
|
|
45
|
+
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
|
|
44
46
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
45
47
|
|
|
48
|
+
installTooltips(document, window);
|
|
49
|
+
|
|
46
50
|
const $ = (id) => document.getElementById(id);
|
|
47
51
|
const state = {
|
|
48
52
|
conversations: [],
|
|
@@ -676,12 +680,9 @@ function cardForDecision(id) {
|
|
|
676
680
|
return els.messages.querySelector(`.approval[data-decision-id="${CSS.escape(id)}"]`);
|
|
677
681
|
}
|
|
678
682
|
|
|
679
|
-
/**
|
|
680
|
-
function
|
|
681
|
-
|
|
682
|
-
if (explicit) return explicit;
|
|
683
|
-
const card = cardFor(originalActionId(decision));
|
|
684
|
-
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));
|
|
685
686
|
}
|
|
686
687
|
|
|
687
688
|
/** Legacy or still-loading card bodies: controls on or off plus one status line, optionally with a retry control. */
|
|
@@ -715,13 +716,14 @@ function setCardBusy(actionId, busy) {
|
|
|
715
716
|
}
|
|
716
717
|
|
|
717
718
|
/** Re-renders the mounted card of a decision from canonical state; an id-less card follows only its own unique alias. */
|
|
718
|
-
function syncCard(decision) {
|
|
719
|
-
const card
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
+
}
|
|
725
727
|
}
|
|
726
728
|
|
|
727
729
|
/** One line of state under a canonical card: in transit, locked for review, offline, finished, or why it still waits. */
|
|
@@ -1556,7 +1558,7 @@ function renderOmo() {
|
|
|
1556
1558
|
if (omoStatus === null) return;
|
|
1557
1559
|
const ready = omoStatus === "ready";
|
|
1558
1560
|
const checking = ready && state.ws?.readyState === WebSocket.OPEN && isUnknown(activity, state.current);
|
|
1559
|
-
els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "
|
|
1561
|
+
els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "엔진 연결됨" : omoStatus === "connecting" ? "엔진 연결 중" : "엔진 끊김";
|
|
1560
1562
|
els.omo.classList.toggle("ok", ready);
|
|
1561
1563
|
}
|
|
1562
1564
|
|
|
@@ -1581,7 +1583,7 @@ function renderModels(info) {
|
|
|
1581
1583
|
modelSelect.append(group);
|
|
1582
1584
|
}
|
|
1583
1585
|
if (info.current) modelSelect.value = `${info.current.provider}/${info.current.id}`;
|
|
1584
|
-
modelSelect.title = info.current ?
|
|
1586
|
+
modelSelect.title = info.current ? `엔진 모델: ${info.current.name}` : "엔진 모델";
|
|
1585
1587
|
// A held switch stays on the current model until the next message compacts the context for the target.
|
|
1586
1588
|
const pending = info.pending ?? null;
|
|
1587
1589
|
modelPending.hidden = !pending;
|
|
@@ -2027,7 +2029,7 @@ function renderSlashMenu() {
|
|
|
2027
2029
|
});
|
|
2028
2030
|
return row;
|
|
2029
2031
|
});
|
|
2030
|
-
const note = slash.loading ? "
|
|
2032
|
+
const note = slash.loading ? "엔진 명령과 스킬을 불러오는 중…" : slash.failed ? "엔진 명령과 스킬을 불러오지 못했습니다. 웹 지그 명령만 보입니다." : rows.length === 0 ? "맞는 명령이나 스킬이 없습니다." : "";
|
|
2031
2033
|
if (note) {
|
|
2032
2034
|
const div = document.createElement("div");
|
|
2033
2035
|
div.className = "slash-note";
|
|
@@ -2110,7 +2112,7 @@ $("new-conv").onclick = () => {
|
|
|
2110
2112
|
};
|
|
2111
2113
|
$("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
|
|
2112
2114
|
$("rotate-session").onclick = () => {
|
|
2113
|
-
if (!confirm("이 대화의
|
|
2115
|
+
if (!confirm("이 대화의 엔진 세션을 새로 시작할까요?\n화면의 기록은 그대로 남고, 최근 대화 일부를 새 세션에 넘겨 줍니다.")) return;
|
|
2114
2116
|
send({ type: "conversation.rotate", conv: state.current });
|
|
2115
2117
|
};
|
|
2116
2118
|
function toggleDevPanel() {
|
|
@@ -2583,6 +2585,12 @@ async function fetchDecisions(cursor) {
|
|
|
2583
2585
|
}
|
|
2584
2586
|
|
|
2585
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
|
+
|
|
2586
2594
|
async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
2587
2595
|
const generation = decisionGeneration;
|
|
2588
2596
|
if ((!refresh && decisionById(inbox.state, id)) || !inbox.online || !beginDetailLoad(inbox.state, id, generation)) return;
|
|
@@ -2592,19 +2600,19 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
|
2592
2600
|
const body = await res.json().catch(() => null);
|
|
2593
2601
|
if (!res.ok) {
|
|
2594
2602
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2595
|
-
|
|
2603
|
+
for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: `결정 상태를 불러오지 못했습니다 (${res.status}).`, retry });
|
|
2596
2604
|
return;
|
|
2597
2605
|
}
|
|
2598
2606
|
const result = finishDetailLoad(inbox.state, id, generation, body);
|
|
2599
2607
|
if (!result.accepted) {
|
|
2600
|
-
if (
|
|
2608
|
+
if (result.reason === "mismatch") for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: "결정 식별자가 일치하지 않습니다.", retry });
|
|
2601
2609
|
return;
|
|
2602
2610
|
}
|
|
2603
2611
|
const decision = decisionById(inbox.state, id);
|
|
2604
2612
|
if (decision) syncCard(decision);
|
|
2605
2613
|
} catch (err) {
|
|
2606
2614
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2607
|
-
|
|
2615
|
+
for (const shown of detailCards(id, card)) setCardState(shown, {
|
|
2608
2616
|
disabled: true,
|
|
2609
2617
|
note: `결정 상태를 불러오지 못했습니다: ${err instanceof Error ? err.message : String(err)}`,
|
|
2610
2618
|
retry,
|
|
@@ -2938,8 +2946,7 @@ function reviewLatest(decision) {
|
|
|
2938
2946
|
|
|
2939
2947
|
/** Both surfaces render the same canonical decision; the surface being edited keeps its focus. */
|
|
2940
2948
|
function syncDecisionSurfaces(decision, except = null) {
|
|
2941
|
-
|
|
2942
|
-
if (card && card !== except) syncCard(decision);
|
|
2949
|
+
syncCard(decision, except);
|
|
2943
2950
|
if (inbox.state.items.has(decision.id) && inbox.nodes.get(decision.id) !== except) renderInboxItem(decision);
|
|
2944
2951
|
}
|
|
2945
2952
|
|
|
@@ -3175,8 +3182,7 @@ function settleSubmit(decision, result) {
|
|
|
3175
3182
|
break;
|
|
3176
3183
|
case "gone": {
|
|
3177
3184
|
forgetDecision(inbox.state, id);
|
|
3178
|
-
const card
|
|
3179
|
-
if (card) {
|
|
3185
|
+
for (const card of els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)) {
|
|
3180
3186
|
card.removeAttribute("aria-busy");
|
|
3181
3187
|
setCardState(card, { disabled: true, note: "서버에 더 이상 없는 요청입니다." });
|
|
3182
3188
|
}
|
|
@@ -3416,14 +3422,23 @@ async function checkUpdateNow() {
|
|
|
3416
3422
|
else showUpdateError(checkFailedLine(res?.status ?? 0));
|
|
3417
3423
|
}
|
|
3418
3424
|
|
|
3425
|
+
// The version this page first loaded on: a stored ok result that ended there is stale after a reload (live update.result is not filtered).
|
|
3426
|
+
let pageVersion = null;
|
|
3427
|
+
|
|
3419
3428
|
function loadUpdateStatus() {
|
|
3420
3429
|
fetch(UPDATE_STATUS_PATH)
|
|
3421
3430
|
.then((res) => (res.ok ? res.json() : null))
|
|
3422
|
-
.
|
|
3423
|
-
|
|
3424
|
-
|
|
3431
|
+
.catch((error) => {
|
|
3432
|
+
console.warn("update status load:", error);
|
|
3433
|
+
return null;
|
|
3434
|
+
})
|
|
3435
|
+
.then((status) => {
|
|
3436
|
+
renderUpdate(status);
|
|
3437
|
+
if (pageVersion === null && typeof status?.current === "string") pageVersion = status.current;
|
|
3438
|
+
return fetch(UPDATE_STATE_PATH);
|
|
3439
|
+
})
|
|
3425
3440
|
.then((res) => (res.ok ? res.json() : null))
|
|
3426
|
-
.then((state) => showUpdateProgress(progressLine(state)))
|
|
3441
|
+
.then((state) => showUpdateProgress(progressLine(state, pageVersion)))
|
|
3427
3442
|
.catch((error) => console.warn("update state load:", error));
|
|
3428
3443
|
}
|
|
3429
3444
|
|
|
@@ -3577,7 +3592,28 @@ const appDownload = createAppDownload({
|
|
|
3577
3592
|
apkEls.renew.onclick = () => void appDownload.renew();
|
|
3578
3593
|
|
|
3579
3594
|
// 정리 (improvement 66): nothing is cleaned until the user ticks rows and presses the button.
|
|
3580
|
-
const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean") };
|
|
3595
|
+
const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label") };
|
|
3596
|
+
|
|
3597
|
+
function gcBoxes() {
|
|
3598
|
+
return [...gcEls.list.querySelectorAll("input[type=checkbox]")];
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// Improvement 142: the 전체 선택 box and the clean button follow the ticked rows.
|
|
3602
|
+
function gcSync() {
|
|
3603
|
+
const ticked = gcTicked().length;
|
|
3604
|
+
const state = selectAllState(ticked, gcBoxes().length);
|
|
3605
|
+
gcEls.allLabel.hidden = state.hidden;
|
|
3606
|
+
gcEls.all.checked = state.checked;
|
|
3607
|
+
gcEls.all.indeterminate = state.indeterminate;
|
|
3608
|
+
gcEls.clean.disabled = ticked === 0;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
gcEls.all.onchange = () => {
|
|
3612
|
+
const boxes = gcBoxes();
|
|
3613
|
+
const tick = selectAllTarget(gcTicked().length, boxes.length);
|
|
3614
|
+
for (const box of boxes) box.checked = tick;
|
|
3615
|
+
gcSync();
|
|
3616
|
+
};
|
|
3581
3617
|
|
|
3582
3618
|
function gcTicked() {
|
|
3583
3619
|
return [...gcEls.list.querySelectorAll("input[type=checkbox]:checked")].map((box) => box.value);
|
|
@@ -3592,7 +3628,7 @@ function gcRow(row) {
|
|
|
3592
3628
|
const box = document.createElement("input");
|
|
3593
3629
|
box.type = "checkbox";
|
|
3594
3630
|
box.value = row.id;
|
|
3595
|
-
box.onchange =
|
|
3631
|
+
box.onchange = gcSync;
|
|
3596
3632
|
const name = document.createElement("span");
|
|
3597
3633
|
name.className = "gc-name";
|
|
3598
3634
|
name.textContent = row.label;
|
|
@@ -3619,6 +3655,7 @@ async function loadGc() {
|
|
|
3619
3655
|
gcEls.summary.textContent = view.summary;
|
|
3620
3656
|
gcEls.summary.classList.toggle("error", view.error);
|
|
3621
3657
|
gcEls.list.replaceChildren(...view.rows.map(gcRow));
|
|
3658
|
+
gcSync();
|
|
3622
3659
|
}
|
|
3623
3660
|
|
|
3624
3661
|
gcEls.clean.onclick = async () => {
|
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.d.ts
CHANGED
|
@@ -9,5 +9,7 @@ export const GC_FAILED_LINE: string;
|
|
|
9
9
|
export function formatBytes(bytes: unknown): string;
|
|
10
10
|
export function formatAge(ms: number): string;
|
|
11
11
|
export function gcView(report: unknown): { summary: string; rows: GcRow[]; error: boolean };
|
|
12
|
+
export function selectAllState(ticked: number, total: number): { hidden: boolean; checked: boolean; indeterminate: boolean };
|
|
13
|
+
export function selectAllTarget(ticked: number, total: number): boolean;
|
|
12
14
|
export function cleanRequest(ids: Iterable<string>): { method: "POST"; headers: { "content-type": "application/json" }; body: string };
|
|
13
15
|
export function cleanResultLine(result: unknown): string;
|
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 데몬",
|
|
@@ -39,6 +39,17 @@ export function gcView(report) {
|
|
|
39
39
|
return { summary, rows, error: false };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Improvement 142: the 전체 선택 box beside the summary. It is hidden with no rows, checked when every row is ticked and
|
|
43
|
+
// indeterminate when only some are.
|
|
44
|
+
export function selectAllState(ticked, total) {
|
|
45
|
+
return { hidden: total === 0, checked: total > 0 && ticked === total, indeterminate: ticked > 0 && ticked < total };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A click on 전체 선택 ticks every row unless every row was already ticked; then it clears them all.
|
|
49
|
+
export function selectAllTarget(ticked, total) {
|
|
50
|
+
return ticked < total;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
export function cleanRequest(ids) {
|
|
43
54
|
return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids: [...ids] }) };
|
|
44
55
|
}
|
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,16 +60,16 @@
|
|
|
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="이 대화의
|
|
66
|
-
<button class="text-btn" id="toggle-voice" title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
|
|
67
|
-
<button class="text-btn" id="voice-install" title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
|
|
68
|
-
<button class="text-btn" id="toggle-clap" title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
|
|
69
|
-
<button class="text-btn" id="toggle-wake" title=""헤이 지그"라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
|
|
70
|
-
<button class="text-btn" id="toggle-tts" title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
|
|
71
|
-
<button type="button" class="text-btn inbox-toggle" id="toggle-inbox" title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
|
|
72
|
-
<button type="button" class="text-btn" id="toggle-dev" title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
|
|
65
|
+
<button class="text-btn" id="rotate-session" data-tip-keep title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
|
|
66
|
+
<button class="text-btn" id="toggle-voice" data-tip-keep title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
|
|
67
|
+
<button class="text-btn" id="voice-install" data-tip-keep title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
|
|
68
|
+
<button class="text-btn" id="toggle-clap" data-tip-keep title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
|
|
69
|
+
<button class="text-btn" id="toggle-wake" data-tip-keep title=""헤이 지그"라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
|
|
70
|
+
<button class="text-btn" id="toggle-tts" data-tip-keep title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
|
|
71
|
+
<button type="button" class="text-btn inbox-toggle" id="toggle-inbox" data-tip-keep title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
|
|
72
|
+
<button type="button" class="text-btn" id="toggle-dev" data-tip-keep title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
|
|
73
73
|
<button type="button" class="text-btn" id="toggle-settings" title="설정: 폰 기기 등록 QR과 등록된 기기" aria-haspopup="dialog" aria-expanded="false" aria-controls="settings">설정</button>
|
|
74
74
|
</div>
|
|
75
75
|
</header>
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
<button type="button" class="mic" id="toggle-memo" title="쪽지 열기/닫기" aria-pressed="false">쪽지</button>
|
|
101
101
|
<button type="button" class="mic" id="attach" title="이미지 첨부 (최대 4장 · 붙여넣기나 끌어놓기도 됩니다)">이미지</button>
|
|
102
102
|
<input type="file" id="attach-input" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
|
|
103
|
-
<button type="button" class="mic" id="mic" title="누르고 말하기 (다시 누르면 전송)">마이크</button>
|
|
103
|
+
<button type="button" class="mic" id="mic" data-tip-keep title="누르고 말하기 (다시 누르면 전송)">마이크</button>
|
|
104
104
|
<textarea id="input" rows="1" placeholder="메시지 보내기" title="Enter 전송, Shift+Enter 줄바꿈"></textarea>
|
|
105
105
|
<button type="submit" class="send" id="send">전송</button>
|
|
106
106
|
<button type="button" class="stop" id="interrupt" title="진행 중인 작업 중단" hidden>중단</button>
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
<div class="enroll-actions"><button type="button" class="text-btn" id="apk-renew" hidden>새로 만들기</button></div>
|
|
179
179
|
</section>
|
|
180
180
|
<section class="settings-section gc" id="gc" aria-labelledby="gc-title">
|
|
181
|
-
<div class="section-label"><span id="gc-title">정리</span><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span></div>
|
|
181
|
+
<div class="section-label"><span id="gc-title">정리</span><span class="gc-head"><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span><label class="gc-all" id="gc-all-label" hidden><input type="checkbox" id="gc-all">전체 선택</label></span></div>
|
|
182
182
|
<ul class="gc-list" id="gc-list" aria-label="정리 후보"></ul>
|
|
183
183
|
<p class="gc-message" id="gc-message" role="status" aria-live="polite"></p>
|
|
184
184
|
<div class="gc-actions"><button type="button" class="text-btn" id="gc-clean" disabled>선택한 항목 정리</button></div>
|
package/web/style.css
CHANGED
|
@@ -412,6 +412,10 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
412
412
|
/* 정리 (improvement 66): inside .settings-body after 앱 내려받기; contain keeps its nowrap rows from widening the card on phones. */
|
|
413
413
|
.settings-section.gc { contain: inline-size; }
|
|
414
414
|
.gc-summary { text-transform: none; color: var(--text); }
|
|
415
|
+
.gc-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
416
|
+
.gc-all { display: inline-flex; align-items: center; gap: 6px; text-transform: none; color: var(--text); font-weight: 400; cursor: pointer; white-space: nowrap; }
|
|
417
|
+
.gc-all[hidden] { display: none; }
|
|
418
|
+
.gc-all input { width: 16px; height: 16px; margin: 0; }
|
|
415
419
|
.gc-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
|
416
420
|
.gc-list:empty { display: none; }
|
|
417
421
|
.gc-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg-card); border: 1px solid #4e5058; border-radius: 6px; font-size: 13px; }
|
|
@@ -561,3 +565,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
561
565
|
.app.status-open .ws-title { font-size: 14px; color: var(--text); }
|
|
562
566
|
.app.status-open .ws-caption { font-size: 13px; padding: 4px 10px; }
|
|
563
567
|
}
|
|
568
|
+
|
|
569
|
+
/* Bug 90: the page-drawn tooltip (web/tooltip.js). */
|
|
570
|
+
.zgw-tip { position: fixed; z-index: 10000; max-width: min(360px, calc(100vw - 16px)); padding: 6px 9px; border-radius: 6px; background: #111214; color: var(--text); border: 1px solid #3f4147; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); font-size: 12px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; }
|
|
571
|
+
.zgw-tip[hidden] { display: none; }
|
package/web/tooltip.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
};
|
|
8
|
+
export const TIP_GAP: number;
|
|
9
|
+
export const TIP_MARGIN: number;
|
|
10
|
+
export const TIP_ID: string;
|
|
11
|
+
export function placeTip(anchor: Box, tip: { width: number; height: number }, view: { width: number; height: number }): { left: number; top: number; side: "above" | "below" };
|
|
12
|
+
export function adoptTitle(el: Pick<TipElement, "getAttribute" | "setAttribute" | "removeAttribute" | "hasAttribute">): void;
|
|
13
|
+
export function tipText(el: TipElement): string;
|
|
14
|
+
export function addId(list: string | null, id: string): string;
|
|
15
|
+
export function removeId(list: string | null, id: string): string;
|
|
16
|
+
export function installTooltips(doc: Document, win: Window & typeof globalThis): void;
|
package/web/tooltip.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Bug 90: the page draws its own tooltip. Some browsers (the user's, likely Aside) never show the native `title`
|
|
2
|
+
// tooltip, so a `title` moves to `data-tip` and one shared element shows it on hover and keyboard focus, linked with
|
|
3
|
+
// aria-describedby. Bug 161: only elements marked `data-tip-keep` (새 세션, the voice buttons, 확인함, 이벤트) keep a
|
|
4
|
+
// visible tip; every other `title` moves to `aria-description`, so neither the page nor the browser shows a tip for it.
|
|
5
|
+
|
|
6
|
+
export const TIP_GAP = 6;
|
|
7
|
+
export const TIP_MARGIN = 8;
|
|
8
|
+
export const TIP_ID = "zgw-tip";
|
|
9
|
+
const HOVER_DELAY_MS = 250;
|
|
10
|
+
const SELECTOR = "[data-tip]";
|
|
11
|
+
|
|
12
|
+
export function placeTip(anchor, tip, view) {
|
|
13
|
+
const left = Math.max(TIP_MARGIN, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, view.width - tip.width - TIP_MARGIN));
|
|
14
|
+
const below = anchor.bottom + TIP_GAP;
|
|
15
|
+
const above = anchor.top - TIP_GAP - tip.height;
|
|
16
|
+
const fitsBelow = below + tip.height <= view.height - TIP_MARGIN;
|
|
17
|
+
if (!fitsBelow && above >= TIP_MARGIN) return { left, top: above, side: "above" };
|
|
18
|
+
const top = Math.max(TIP_MARGIN, Math.min(below, view.height - tip.height - TIP_MARGIN));
|
|
19
|
+
return { left, top, side: "below" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function adoptTitle(el) {
|
|
23
|
+
if (!el.hasAttribute("title")) return;
|
|
24
|
+
const text = el.getAttribute("title");
|
|
25
|
+
el.removeAttribute("title");
|
|
26
|
+
const attr = el.hasAttribute("data-tip-keep") ? "data-tip" : "aria-description";
|
|
27
|
+
if (text) el.setAttribute(attr, text);
|
|
28
|
+
else el.removeAttribute(attr);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function tipText(el) {
|
|
32
|
+
return el.hasAttribute("data-tip-keep") ? el.getAttribute("data-tip") || "" : "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addId(list, id) {
|
|
36
|
+
const ids = (list ?? "").split(/\s+/).filter(Boolean);
|
|
37
|
+
return ids.includes(id) ? ids.join(" ") : [...ids, id].join(" ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function removeId(list, id) {
|
|
41
|
+
return (list ?? "").split(/\s+/).filter((part) => part && part !== id).join(" ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function installTooltips(doc, win) {
|
|
45
|
+
const tip = doc.createElement("div");
|
|
46
|
+
tip.id = TIP_ID;
|
|
47
|
+
tip.className = "zgw-tip";
|
|
48
|
+
tip.setAttribute("role", "tooltip");
|
|
49
|
+
tip.hidden = true;
|
|
50
|
+
doc.body.append(tip);
|
|
51
|
+
let owner = null;
|
|
52
|
+
let timer = 0;
|
|
53
|
+
|
|
54
|
+
const adoptAll = (root) => {
|
|
55
|
+
if (root.nodeType !== 1) return;
|
|
56
|
+
adoptTitle(root);
|
|
57
|
+
for (const el of root.querySelectorAll("[title]")) adoptTitle(el);
|
|
58
|
+
};
|
|
59
|
+
adoptAll(doc.body);
|
|
60
|
+
new win.MutationObserver((records) => {
|
|
61
|
+
for (const record of records) {
|
|
62
|
+
if (record.type === "attributes") adoptTitle(record.target);
|
|
63
|
+
else for (const node of record.addedNodes) adoptAll(node);
|
|
64
|
+
}
|
|
65
|
+
}).observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["title"] });
|
|
66
|
+
|
|
67
|
+
const hide = () => {
|
|
68
|
+
win.clearTimeout(timer);
|
|
69
|
+
if (owner) {
|
|
70
|
+
const rest = removeId(owner.getAttribute("aria-describedby"), TIP_ID);
|
|
71
|
+
if (rest) owner.setAttribute("aria-describedby", rest);
|
|
72
|
+
else owner.removeAttribute("aria-describedby");
|
|
73
|
+
}
|
|
74
|
+
owner = null;
|
|
75
|
+
tip.hidden = true;
|
|
76
|
+
};
|
|
77
|
+
const show = (el) => {
|
|
78
|
+
const text = tipText(el);
|
|
79
|
+
if (!text || !el.isConnected) return hide();
|
|
80
|
+
if (owner !== el) hide();
|
|
81
|
+
owner = el;
|
|
82
|
+
tip.textContent = text;
|
|
83
|
+
tip.style.left = "0px";
|
|
84
|
+
tip.style.top = "0px";
|
|
85
|
+
tip.hidden = false;
|
|
86
|
+
el.setAttribute("aria-describedby", addId(el.getAttribute("aria-describedby"), TIP_ID));
|
|
87
|
+
const place = placeTip(el.getBoundingClientRect(), tip.getBoundingClientRect(), { width: win.innerWidth, height: win.innerHeight });
|
|
88
|
+
tip.style.left = `${Math.round(place.left)}px`;
|
|
89
|
+
tip.style.top = `${Math.round(place.top)}px`;
|
|
90
|
+
tip.dataset.side = place.side;
|
|
91
|
+
};
|
|
92
|
+
const target = (event) => event.target instanceof win.Element ? event.target.closest(SELECTOR) : null;
|
|
93
|
+
|
|
94
|
+
doc.addEventListener("pointerover", (event) => {
|
|
95
|
+
if (event.pointerType === "touch") return;
|
|
96
|
+
const el = target(event);
|
|
97
|
+
if (el === owner) return;
|
|
98
|
+
hide();
|
|
99
|
+
if (el) timer = win.setTimeout(() => show(el), HOVER_DELAY_MS);
|
|
100
|
+
});
|
|
101
|
+
doc.addEventListener("pointerout", (event) => {
|
|
102
|
+
const el = target(event);
|
|
103
|
+
if (el && !(event.relatedTarget instanceof win.Node && el.contains(event.relatedTarget))) hide();
|
|
104
|
+
});
|
|
105
|
+
doc.addEventListener("focusin", (event) => {
|
|
106
|
+
const el = target(event);
|
|
107
|
+
if (el && event.target.matches(":focus-visible")) show(el);
|
|
108
|
+
});
|
|
109
|
+
doc.addEventListener("focusout", hide);
|
|
110
|
+
doc.addEventListener("pointerdown", hide, true);
|
|
111
|
+
doc.addEventListener("keydown", (event) => { if (event.key === "Escape" && owner) hide(); });
|
|
112
|
+
win.addEventListener("scroll", hide, true);
|
|
113
|
+
win.addEventListener("resize", hide);
|
|
114
|
+
}
|
package/web/update.d.ts
CHANGED
|
@@ -42,4 +42,4 @@ export function questionGate(counters: { readonly pending: number; readonly need
|
|
|
42
42
|
export function canUpdate(status: unknown): boolean;
|
|
43
43
|
export function updateStep(status: number, body: unknown): UpdateStep;
|
|
44
44
|
export function resultLine(result: unknown): string | null;
|
|
45
|
-
export function progressLine(data: unknown): string | null;
|
|
45
|
+
export function progressLine(data: unknown, loadedVersion?: string | null): string | null;
|
package/web/update.js
CHANGED
|
@@ -89,10 +89,17 @@ export function resultLine(result) {
|
|
|
89
89
|
return `${typeof result.message === "string" ? result.message : "업데이트에 실패했습니다."}${typeof result.logPath === "string" && result.logPath !== "" ? ` (로그: ${result.logPath})` : ""}`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The line for an `update.progress` payload or a GET /api/update/state body; null shows nothing. The gateway keeps the last
|
|
94
|
+
* result in state.json for good, so `loadedVersion` (the version the page first loaded on) hides an ok result that ended
|
|
95
|
+
* at that version: the page was reloaded after the update and the "reload the page" line is stale.
|
|
96
|
+
*/
|
|
97
|
+
export function progressLine(data, loadedVersion = null) {
|
|
94
98
|
if (data === null || typeof data !== "object") return null;
|
|
95
|
-
if (data.result !== undefined && data.result !== null)
|
|
99
|
+
if (data.result !== undefined && data.result !== null) {
|
|
100
|
+
if (data.result.ok === true && loadedVersion !== null && data.result.to === loadedVersion) return null;
|
|
101
|
+
return resultLine(data.result);
|
|
102
|
+
}
|
|
96
103
|
if (typeof data.label !== "string" || data.phase === "done") return null;
|
|
97
104
|
return `업데이트 중 (${data.from} → ${data.to}): ${data.label}`;
|
|
98
105
|
}
|