zigmini 0.9.0-beta.3 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +24 -4
- package/README.md +24 -4
- package/assets/agent/gateway-rules.md +1 -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 +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
package/web/memo.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { registerStrings, t } from "./i18n.js";
|
|
2
|
+
import strings from "./i18n/memo.js";
|
|
3
|
+
|
|
4
|
+
registerStrings(strings);
|
|
5
|
+
|
|
1
6
|
const STORAGE_KEY = "zgw.memo";
|
|
2
7
|
const MAX_IMAGES = 2;
|
|
3
8
|
const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
@@ -35,13 +40,13 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
35
40
|
link.rel = "noopener";
|
|
36
41
|
const image = document.createElement("img");
|
|
37
42
|
image.src = link.href;
|
|
38
|
-
image.alt =
|
|
43
|
+
image.alt = t("memo.savedImage", { index: index + 1 });
|
|
39
44
|
link.append(image);
|
|
40
45
|
const remove = document.createElement("button");
|
|
41
46
|
remove.type = "button";
|
|
42
47
|
remove.className = "text-btn";
|
|
43
|
-
remove.textContent = "
|
|
44
|
-
remove.setAttribute("aria-label",
|
|
48
|
+
remove.textContent = t("memo.delete");
|
|
49
|
+
remove.setAttribute("aria-label", t("memo.deleteImage", { index: index + 1 }));
|
|
45
50
|
remove.disabled = imageBusy;
|
|
46
51
|
remove.onclick = () => saveImages(images.filter((_, at) => at !== index));
|
|
47
52
|
item.append(link, remove);
|
|
@@ -83,20 +88,20 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
83
88
|
pendingText = null;
|
|
84
89
|
void put({ text: value }, keepalive).catch((error) => {
|
|
85
90
|
pendingText ??= value;
|
|
86
|
-
fail("
|
|
91
|
+
fail(t("memo.saveFailed"), error);
|
|
87
92
|
});
|
|
88
93
|
}
|
|
89
94
|
async function saveImages(next) {
|
|
90
95
|
if (imageBusy) return;
|
|
91
96
|
imageBusy = true;
|
|
92
|
-
status.textContent = "
|
|
97
|
+
status.textContent = t("memo.savingImage");
|
|
93
98
|
renderImages();
|
|
94
99
|
try {
|
|
95
100
|
const saved = await put({ images: next });
|
|
96
101
|
images = saved.images;
|
|
97
102
|
status.textContent = "";
|
|
98
103
|
} catch (error) {
|
|
99
|
-
fail("
|
|
104
|
+
fail(t("memo.imageSaveFailed"), error);
|
|
100
105
|
} finally {
|
|
101
106
|
imageBusy = false;
|
|
102
107
|
renderImages();
|
|
@@ -106,15 +111,15 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
106
111
|
const files = [...selected];
|
|
107
112
|
if (imageBusy || files.length === 0) return;
|
|
108
113
|
if (images.length + files.length > MAX_IMAGES) {
|
|
109
|
-
status.textContent = "
|
|
114
|
+
status.textContent = t("memo.imageLimit");
|
|
110
115
|
return;
|
|
111
116
|
}
|
|
112
117
|
if (files.some((file) => !IMAGE_TYPES.has(file.type))) {
|
|
113
|
-
status.textContent = "
|
|
118
|
+
status.textContent = t("memo.imageTypes");
|
|
114
119
|
return;
|
|
115
120
|
}
|
|
116
121
|
imageBusy = true;
|
|
117
|
-
status.textContent = "
|
|
122
|
+
status.textContent = t("memo.uploadingImage");
|
|
118
123
|
renderImages();
|
|
119
124
|
try {
|
|
120
125
|
const next = [...images];
|
|
@@ -123,7 +128,7 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
123
128
|
images = saved.images;
|
|
124
129
|
status.textContent = "";
|
|
125
130
|
} catch (error) {
|
|
126
|
-
fail("
|
|
131
|
+
fail(t("memo.imageStoreFailed"), error);
|
|
127
132
|
} finally {
|
|
128
133
|
imageBusy = false;
|
|
129
134
|
renderImages();
|
|
@@ -139,7 +144,7 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
139
144
|
rememberOpen(value);
|
|
140
145
|
if (value) {
|
|
141
146
|
text.focus();
|
|
142
|
-
void pull().catch((error) => fail("
|
|
147
|
+
void pull().catch((error) => fail(t("memo.loadFailed"), error));
|
|
143
148
|
} else flush();
|
|
144
149
|
}
|
|
145
150
|
|
|
@@ -161,7 +166,7 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
161
166
|
if (!value.width && legacy.width) Object.assign(patch, { width: legacy.width, height: legacy.height });
|
|
162
167
|
if (Object.keys(patch).length) { apply(patch); await put(patch); }
|
|
163
168
|
rememberOpen(Boolean(saved.open));
|
|
164
|
-
}).catch((error) => fail("
|
|
169
|
+
}).catch((error) => fail(t("memo.loadFailed"), error));
|
|
165
170
|
toggle.onclick = () => open(box.hidden);
|
|
166
171
|
add.onclick = () => picker.click();
|
|
167
172
|
picker.onchange = () => { void addImages(picker.files); picker.value = ""; };
|
|
@@ -201,7 +206,7 @@ export function setupMemo({ uploadImage, reportError }) {
|
|
|
201
206
|
handle.removeEventListener("pointermove", move);
|
|
202
207
|
handle.removeEventListener("pointerup", end);
|
|
203
208
|
handle.removeEventListener("pointercancel", end);
|
|
204
|
-
void put({ width: box.offsetWidth, height: box.offsetHeight }).catch((error) => fail("
|
|
209
|
+
void put({ width: box.offsetWidth, height: box.offsetHeight }).catch((error) => fail(t("memo.resizeFailed"), error));
|
|
205
210
|
};
|
|
206
211
|
handle.addEventListener("pointermove", move);
|
|
207
212
|
handle.addEventListener("pointerup", end);
|
package/web/notifications.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import dict from "./i18n/notifications.js";
|
|
2
|
+
import { registerStrings, t } from "./i18n.js";
|
|
3
|
+
|
|
4
|
+
registerStrings(dict);
|
|
5
|
+
|
|
1
6
|
export const NOTIFICATION_SETTINGS_PATH = "/api/settings/notifications";
|
|
2
7
|
|
|
3
8
|
/** Parse gateway data without treating a failed or malformed response as a default. */
|
|
@@ -12,17 +17,17 @@ export function createNotificationSettings(fetcher, render) {
|
|
|
12
17
|
let mode = "inbox-only";
|
|
13
18
|
let ready = false;
|
|
14
19
|
let online = false;
|
|
15
|
-
let
|
|
20
|
+
let errorKey = "";
|
|
16
21
|
let epoch = 0;
|
|
17
22
|
let generation = 0;
|
|
18
23
|
let pending = 0;
|
|
19
24
|
let tail = Promise.resolve();
|
|
20
|
-
const view = () => ({ mode, ready, online, busy: pending > 0, error });
|
|
25
|
+
const view = () => ({ mode, ready, online, busy: pending > 0, error: errorKey ? t(errorKey) : "" });
|
|
21
26
|
const paint = () => render(view());
|
|
22
27
|
const confirm = (value) => {
|
|
23
28
|
mode = value;
|
|
24
29
|
ready = true;
|
|
25
|
-
|
|
30
|
+
errorKey = "";
|
|
26
31
|
paint();
|
|
27
32
|
};
|
|
28
33
|
|
|
@@ -38,7 +43,7 @@ export function createNotificationSettings(fetcher, render) {
|
|
|
38
43
|
if (connection === epoch && request === generation) confirm(value);
|
|
39
44
|
} catch {
|
|
40
45
|
if (connection === epoch && request === generation) {
|
|
41
|
-
|
|
46
|
+
errorKey = "notifications.error.read";
|
|
42
47
|
paint();
|
|
43
48
|
}
|
|
44
49
|
}
|
|
@@ -65,7 +70,7 @@ export function createNotificationSettings(fetcher, render) {
|
|
|
65
70
|
else await read(); // A newer event/read may describe another client's write.
|
|
66
71
|
} catch {
|
|
67
72
|
if (connection === epoch) {
|
|
68
|
-
|
|
73
|
+
errorKey = "notifications.error.save";
|
|
69
74
|
paint();
|
|
70
75
|
}
|
|
71
76
|
} finally {
|
|
@@ -86,7 +91,7 @@ export function createNotificationSettings(fetcher, render) {
|
|
|
86
91
|
generation += 1;
|
|
87
92
|
online = true;
|
|
88
93
|
ready = false;
|
|
89
|
-
|
|
94
|
+
errorKey = "";
|
|
90
95
|
paint();
|
|
91
96
|
return read();
|
|
92
97
|
},
|
|
@@ -110,7 +115,7 @@ export function setupNotificationSettings({ choice, status }, fetcher = fetch) {
|
|
|
110
115
|
const controller = createNotificationSettings(fetcher, (view) => {
|
|
111
116
|
choice.value = view.mode;
|
|
112
117
|
choice.disabled = !view.online || !view.ready || view.busy;
|
|
113
|
-
status.textContent = view.error || (view.busy ? "
|
|
118
|
+
status.textContent = view.error || (view.busy ? t("notifications.saving") : !view.online ? t("notifications.waiting") : !view.ready ? t("notifications.checking") : "");
|
|
114
119
|
status.classList.toggle("error", Boolean(view.error));
|
|
115
120
|
});
|
|
116
121
|
choice.onchange = () => controller.set(choice.value);
|
package/web/profile.js
CHANGED
|
@@ -1,18 +1,45 @@
|
|
|
1
|
-
|
|
1
|
+
import dict from "./i18n/profile.js";
|
|
2
|
+
import { onLanguageChange, registerStrings, t } from "./i18n.js";
|
|
3
|
+
|
|
4
|
+
registerStrings(dict);
|
|
5
|
+
|
|
6
|
+
// The user's profile nickname: shown in the sidebar profile, on the user's chat messages and on the work status user card.
|
|
2
7
|
// Double-clicking the profile edits it; the name is kept in this browser's localStorage.
|
|
3
8
|
// The agent's (gateway main) name sits beside it with the same editing; the gateway applies the same rules (src/profile.ts).
|
|
4
9
|
|
|
5
10
|
export const NICKNAME_KEY = "zgw.profile.nickname";
|
|
6
|
-
|
|
11
|
+
/** A live binding: it follows the UI language, so importers always read the current default. */
|
|
12
|
+
export let DEFAULT_NICKNAME = t("profile.nickname.default");
|
|
7
13
|
export const NICKNAME_MAX = 20;
|
|
14
|
+
export const AGENT_NAME_KEY = "zgw.profile.agentName";
|
|
15
|
+
/** A live binding like DEFAULT_NICKNAME. */
|
|
16
|
+
export let DEFAULT_AGENT_NAME = t("profile.agent.default");
|
|
17
|
+
onLanguageChange(() => {
|
|
18
|
+
DEFAULT_NICKNAME = t("profile.nickname.default");
|
|
19
|
+
DEFAULT_AGENT_NAME = t("profile.agent.default");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** True when [name] is a built-in default of either language (the same rule as src/profile.ts). */
|
|
23
|
+
function isBuiltIn(name, key) {
|
|
24
|
+
return name === dict.en[key] || name === dict.ko[key];
|
|
25
|
+
}
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
/** The user's own nickname: whitespace collapsed and trimmed, at most NICKNAME_MAX characters; "" for blank or a built-in default. */
|
|
28
|
+
function ownNickname(value) {
|
|
29
|
+
const name = typeof value === "string" ? [...value.replace(/\s+/g, " ").trim()].slice(0, NICKNAME_MAX).join("") : "";
|
|
30
|
+
return isBuiltIn(name, "profile.nickname.default") ? "" : name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ownAgentName(value) {
|
|
10
34
|
const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
11
|
-
return name
|
|
35
|
+
return isBuiltIn(name, "profile.agent.default") ? "" : name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The nickname to show: the user's own, else the current language's default. */
|
|
39
|
+
export function normalizeNickname(value) {
|
|
40
|
+
return ownNickname(value) || DEFAULT_NICKNAME;
|
|
12
41
|
}
|
|
13
42
|
|
|
14
|
-
export const AGENT_NAME_KEY = "zgw.profile.agentName";
|
|
15
|
-
export const DEFAULT_AGENT_NAME = "지그";
|
|
16
43
|
export const AGENT_NAME_WIDTH = 20;
|
|
17
44
|
|
|
18
45
|
/** Same ranges as src/profile.ts: Hangul, CJK, full-width forms and wide emoji count 2. */
|
|
@@ -29,9 +56,9 @@ export function displayWidth(text) {
|
|
|
29
56
|
return width;
|
|
30
57
|
}
|
|
31
58
|
|
|
32
|
-
/** Whitespace collapsed and trimmed; blank becomes the default. A name wider than AGENT_NAME_WIDTH is not shortened. */
|
|
59
|
+
/** Whitespace collapsed and trimmed; blank or a built-in default becomes the current default. A name wider than AGENT_NAME_WIDTH is not shortened. */
|
|
33
60
|
export function normalizeAgentName(value) {
|
|
34
|
-
return (
|
|
61
|
+
return ownAgentName(value) || DEFAULT_AGENT_NAME;
|
|
35
62
|
}
|
|
36
63
|
|
|
37
64
|
export function agentNameFits(value) {
|
|
@@ -39,14 +66,13 @@ export function agentNameFits(value) {
|
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
export function readAgentName(storage) {
|
|
42
|
-
const name =
|
|
43
|
-
return agentNameFits(name) ? name : DEFAULT_AGENT_NAME;
|
|
69
|
+
const name = ownAgentName(storage.getItem(AGENT_NAME_KEY));
|
|
70
|
+
return name && agentNameFits(name) ? name : DEFAULT_AGENT_NAME;
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
export function writeAgentName(storage, value) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return name;
|
|
74
|
+
storage.setItem(AGENT_NAME_KEY, ownAgentName(value));
|
|
75
|
+
return normalizeAgentName(value);
|
|
50
76
|
}
|
|
51
77
|
|
|
52
78
|
/**
|
|
@@ -94,9 +120,8 @@ export function readNickname(storage) {
|
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
export function writeNickname(storage, value) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return name;
|
|
123
|
+
storage.setItem(NICKNAME_KEY, ownNickname(value));
|
|
124
|
+
return normalizeNickname(value);
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
export function nicknameInitial(name) {
|
|
@@ -115,7 +140,7 @@ export function editNickname(nameEl, current, onSave, options = {}) {
|
|
|
115
140
|
input.className = "me-name-input";
|
|
116
141
|
input.value = current;
|
|
117
142
|
input.maxLength = NICKNAME_MAX * 2;
|
|
118
|
-
input.setAttribute("aria-label", options.label ?? "
|
|
143
|
+
input.setAttribute("aria-label", options.label ?? t("profile.nickname.label"));
|
|
119
144
|
const mark = () => input.setAttribute("aria-invalid", String(!fits(input.value)));
|
|
120
145
|
input.addEventListener("input", mark);
|
|
121
146
|
mark();
|
package/web/reference.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { registerStrings, t } from "./i18n.js";
|
|
2
|
+
import strings from "./i18n/reference.js";
|
|
3
|
+
|
|
4
|
+
registerStrings(strings);
|
|
5
|
+
|
|
6
|
+
// Message references (backlog 28): a right-click (or the row's reference button) on a stored message of the open conversation
|
|
2
7
|
// adds it as a removable tag above the composer. A send carries the sources' stored event seqs
|
|
3
8
|
// (`input.text.data.references`); the gateway stores canonical snapshots on the user message
|
|
4
9
|
// (`message.user.data.references`), which history shows as quotes. Nothing is copied into the draft text.
|
|
@@ -7,13 +12,13 @@ export const MAX_REFERENCES = 4;
|
|
|
7
12
|
// The gateway forwards the tagged sources' images with the new attachments and admits at most this many in one message.
|
|
8
13
|
export const MAX_IMAGES = 4;
|
|
9
14
|
// The assistant label is the agent's profile name (improvement 87): app.js passes `{ ...ROLE_LABELS, assistant: agentName }`.
|
|
10
|
-
export const ROLE_LABELS = { user
|
|
11
|
-
export const NOTICES = {
|
|
12
|
-
duplicate:
|
|
13
|
-
full:
|
|
14
|
-
locked:
|
|
15
|
-
images:
|
|
16
|
-
};
|
|
15
|
+
export const ROLE_LABELS = { get user() { return t("reference.user"); }, assistant: "zigmini" };
|
|
16
|
+
export const NOTICES = Object.defineProperties({}, {
|
|
17
|
+
duplicate: { enumerable: true, get: () => t("reference.duplicate") },
|
|
18
|
+
full: { enumerable: true, get: () => t("reference.full", { count: MAX_REFERENCES }) },
|
|
19
|
+
locked: { enumerable: true, get: () => t("reference.locked") },
|
|
20
|
+
images: { enumerable: true, get: () => t("reference.images", { count: MAX_IMAGES }) },
|
|
21
|
+
});
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* The reference a stored chat event can be the source of, or null. Only an event with a stored seq counts: a user
|
|
@@ -42,12 +47,12 @@ function imageNames(list) {
|
|
|
42
47
|
|
|
43
48
|
/** One line for a tag: the image count first, then the text with its whitespace collapsed (CSS clips the rest). */
|
|
44
49
|
export function referenceLabel(ref) {
|
|
45
|
-
const image = ref.images.length > 0 ?
|
|
50
|
+
const image = ref.images.length > 0 ? t("reference.imageCount", { count: ref.images.length }) : "";
|
|
46
51
|
return [image, ref.text.replace(/\s+/g, " ").trim()].filter(Boolean).join(" ");
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
export function referenceTitle(ref) {
|
|
50
|
-
const image = ref.images.length > 0 ?
|
|
55
|
+
const image = ref.images.length > 0 ? t("reference.imageCount", { count: ref.images.length }) : "";
|
|
51
56
|
return [image, ref.text.trim()].filter(Boolean).join("\n");
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -148,7 +153,7 @@ export function sentReferences(data) {
|
|
|
148
153
|
// A source row's data comes from its event, never read back from what the row shows.
|
|
149
154
|
const rowSources = new WeakMap();
|
|
150
155
|
|
|
151
|
-
/** Marks [row] as a source and gives it the
|
|
156
|
+
/** Marks [row] as a source and gives it the reference button: the keyboard route to the same tag as a right-click. */
|
|
152
157
|
export function markReferenceable(row, source) {
|
|
153
158
|
rowSources.set(row, source);
|
|
154
159
|
row.dataset.refSeq = String(source.seq);
|
|
@@ -156,8 +161,8 @@ export function markReferenceable(row, source) {
|
|
|
156
161
|
const button = document.createElement("button");
|
|
157
162
|
button.type = "button";
|
|
158
163
|
button.className = "msg-ref-btn";
|
|
159
|
-
button.textContent = "
|
|
160
|
-
button.title = "
|
|
164
|
+
button.textContent = t("reference.button");
|
|
165
|
+
button.title = t("reference.buttonTitle");
|
|
161
166
|
row.querySelector(".msg-head")?.append(button);
|
|
162
167
|
}
|
|
163
168
|
|
|
@@ -184,8 +189,8 @@ function referenceChip(ref, locked, onRemove, labels) {
|
|
|
184
189
|
remove.type = "button";
|
|
185
190
|
remove.className = "ref-tag-remove";
|
|
186
191
|
remove.textContent = "×";
|
|
187
|
-
remove.title = "
|
|
188
|
-
remove.setAttribute("aria-label",
|
|
192
|
+
remove.title = t("reference.remove");
|
|
193
|
+
remove.setAttribute("aria-label", t("reference.removeLabel", { label }));
|
|
189
194
|
remove.disabled = locked;
|
|
190
195
|
remove.onclick = () => onRemove(ref.seq, chip);
|
|
191
196
|
chip.addEventListener("keydown", (ev) => {
|
package/web/security-inbox.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { HeldState, AllowedValue } from "../src/security-allow.ts";
|
|
2
2
|
import type { HeldMessage } from "../src/security-check.ts";
|
|
3
|
+
import type { SecurityBaselineTerm } from "../src/store.ts";
|
|
3
4
|
|
|
4
5
|
export const HELD_PATH: "/api/security-check/held";
|
|
5
6
|
export const ALLOWED_PATH: "/api/settings/security-check/allowed-values";
|
|
7
|
+
export const BASELINE_PATH: "/api/settings/security-check/baseline";
|
|
6
8
|
export type HeldDetail = HeldState & Partial<Pick<HeldMessage, "occurrences" | "draft">>;
|
|
7
9
|
export interface Entry {
|
|
8
10
|
held: HeldDetail;
|
|
@@ -29,7 +31,22 @@ export function createSecurityInbox(fetcher: Fetcher, changed?: () => void): {
|
|
|
29
31
|
supported(value: boolean): void; connect(): Promise<void>; disconnect(): void; hide(id: string): boolean;
|
|
30
32
|
};
|
|
31
33
|
export function createAllowedValues(fetcher: Fetcher, changed?: () => void): {
|
|
32
|
-
state: { revision: number; values: AllowedValue[]; online: boolean; busy: boolean; loading: boolean; error: string; ready: boolean };
|
|
34
|
+
state: { revision: number; values: AllowedValue[]; online: boolean; busy: boolean; loading: boolean; error: string; notice: string; ready: boolean };
|
|
33
35
|
refresh(): Promise<void>; connect(): Promise<void>; disconnect(): void; updated(revision: number): void;
|
|
34
36
|
remove(id?: string | null): Promise<void>;
|
|
37
|
+
setScope(id: string, repo: boolean): Promise<boolean>;
|
|
38
|
+
};
|
|
39
|
+
export type AllowScope = NonNullable<AllowedValue["scopes"]>[number];
|
|
40
|
+
export function repoAllowed(value: Pick<AllowedValue, "scopes">): boolean;
|
|
41
|
+
export function scopesFor(repo: boolean): AllowScope[];
|
|
42
|
+
export function repoScopeBlock(value: string, auto: BaselineState["auto"]): string;
|
|
43
|
+
export interface BaselineState {
|
|
44
|
+
revision: number; terms: SecurityBaselineTerm[]; auto: { home: string; user: string } | null;
|
|
45
|
+
exportedAt: string | null; exportError: string | null;
|
|
46
|
+
online: boolean; busy: boolean; loading: boolean; error: string; notice: string; ready: boolean;
|
|
47
|
+
}
|
|
48
|
+
export function createSecurityBaseline(fetcher: Fetcher, changed?: () => void): {
|
|
49
|
+
state: BaselineState;
|
|
50
|
+
refresh(): Promise<void>; connect(): Promise<void>; disconnect(): void; updated(revision: number): void;
|
|
51
|
+
add(value: string): Promise<boolean>; remove(id: string): Promise<boolean>;
|
|
35
52
|
};
|
package/web/security-inbox.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { registerStrings, t } from "./i18n.js";
|
|
2
|
+
import strings from "./i18n/security-inbox.js";
|
|
3
|
+
|
|
4
|
+
registerStrings(strings);
|
|
5
|
+
|
|
1
6
|
// Installation-local, memory-only client state. Never persist held details or drafts.
|
|
2
7
|
export const HELD_PATH = "/api/security-check/held";
|
|
3
8
|
export const ALLOWED_PATH = "/api/settings/security-check/allowed-values";
|
|
9
|
+
export const BASELINE_PATH = "/api/settings/security-check/baseline";
|
|
4
10
|
const TERMINAL = new Set(["sent", "cancelled", "failed", "unconfirmed"]);
|
|
5
11
|
const STATUSES = new Set(["pending", "processing", ...TERMINAL]);
|
|
6
12
|
|
|
@@ -170,7 +176,7 @@ export function createSecurityInbox(fetcher, changed = () => {}) {
|
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
export function createAllowedValues(fetcher, changed = () => {}) {
|
|
173
|
-
const state = { revision: -1, values: [], online: false, busy: false, loading: false, error: "", ready: false };
|
|
179
|
+
const state = { revision: -1, values: [], online: false, busy: false, loading: false, error: "", notice: "", ready: false };
|
|
174
180
|
let generation = 0;
|
|
175
181
|
let read = 0;
|
|
176
182
|
let wanted = -1;
|
|
@@ -180,6 +186,7 @@ export function createAllowedValues(fetcher, changed = () => {}) {
|
|
|
180
186
|
const request = ++read;
|
|
181
187
|
state.loading = true;
|
|
182
188
|
state.error = "";
|
|
189
|
+
state.notice = "";
|
|
183
190
|
changed();
|
|
184
191
|
try {
|
|
185
192
|
const response = await fetcher(ALLOWED_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
@@ -190,9 +197,9 @@ export function createAllowedValues(fetcher, changed = () => {}) {
|
|
|
190
197
|
state.revision = body.revision;
|
|
191
198
|
state.values = body.values;
|
|
192
199
|
state.ready = true;
|
|
193
|
-
} else state.error = "
|
|
200
|
+
} else state.error = t("securityInbox.listChanged");
|
|
194
201
|
} catch {
|
|
195
|
-
if (epoch === generation && request === read) state.error = "
|
|
202
|
+
if (epoch === generation && request === read) state.error = t("securityInbox.allowedReadFailed");
|
|
196
203
|
} finally {
|
|
197
204
|
if (epoch === generation && request === read) {
|
|
198
205
|
state.loading = false;
|
|
@@ -228,10 +235,172 @@ export function createAllowedValues(fetcher, changed = () => {}) {
|
|
|
228
235
|
wanted = Math.max(wanted, body.revision);
|
|
229
236
|
await refresh();
|
|
230
237
|
} catch {
|
|
231
|
-
if (epoch === generation) state.error = "
|
|
238
|
+
if (epoch === generation) state.error = t("securityInbox.deleteUnconfirmed");
|
|
232
239
|
} finally {
|
|
233
240
|
if (epoch === generation) { state.busy = false; changed(); }
|
|
234
241
|
}
|
|
235
242
|
},
|
|
243
|
+
/** One PATCH against the shown revision. Resolves true only when the server applied it; 409 and 404 re-read the newest list. */
|
|
244
|
+
setScope: async (id, repo) => {
|
|
245
|
+
// ready implies online: disconnect clears it, and a read applies it only inside its own generation.
|
|
246
|
+
if (!state.ready || state.busy || state.loading || state.error) return false;
|
|
247
|
+
state.busy = true;
|
|
248
|
+
state.notice = "";
|
|
249
|
+
const epoch = generation;
|
|
250
|
+
changed();
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetcher(`${ALLOWED_PATH}/${encodeURIComponent(id)}`, {
|
|
253
|
+
method: "PATCH", headers: { "content-type": "application/json" },
|
|
254
|
+
body: JSON.stringify({ expectedRevision: state.revision, scopes: scopesFor(repo) }), signal: AbortSignal.timeout(30_000),
|
|
255
|
+
});
|
|
256
|
+
const body = await response.json();
|
|
257
|
+
if (epoch !== generation) return false;
|
|
258
|
+
if (response.status === 400) {
|
|
259
|
+
state.notice = t("securityInbox.scopeRejected", { error: body?.error ?? "scopes_invalid" });
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const stale = response.status === 409 || response.status === 404;
|
|
263
|
+
if (!response.ok && !stale) throw new Error("scope_unconfirmed");
|
|
264
|
+
if (Number.isSafeInteger(body?.revision)) wanted = Math.max(wanted, body.revision);
|
|
265
|
+
await refresh();
|
|
266
|
+
if (stale && epoch === generation) state.notice = t("securityInbox.listRefreshed");
|
|
267
|
+
return response.ok;
|
|
268
|
+
} catch {
|
|
269
|
+
if (epoch === generation) state.error = t("securityInbox.scopeUnconfirmed");
|
|
270
|
+
return false;
|
|
271
|
+
} finally {
|
|
272
|
+
if (epoch === generation) { state.busy = false; changed(); }
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Whether a remembered value also passes the repository scan; a row saved without scopes carries the transport default only. */
|
|
279
|
+
export function repoAllowed(value) {
|
|
280
|
+
return Array.isArray(value.scopes) && value.scopes.includes("repo");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The scopes the toggle writes: on adds "repo" beside the transport default, off returns to the default alone. */
|
|
284
|
+
export function scopesFor(repo) {
|
|
285
|
+
return repo ? ["transport", "repo"] : ["transport"];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The repository scan's term normalization (src/security/terms.ts normalizeTerm), so the screen judges like the export. */
|
|
289
|
+
const normalizeTerm = (value) => value.normalize("NFC").toLowerCase();
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Why this value's repository toggle stays disabled, or "" when it may switch. The judgment is the export's
|
|
293
|
+
* (src/security-baseline.ts isAutomaticBaselineValue): the home path and the account name stay protected, and an
|
|
294
|
+
* unread automatic list fails closed.
|
|
295
|
+
*/
|
|
296
|
+
export function repoScopeBlock(value, auto) {
|
|
297
|
+
if (!auto) return t("securityInbox.autoUnavailable");
|
|
298
|
+
const normalized = normalizeTerm(value.trim());
|
|
299
|
+
if (normalized === normalizeTerm(auto.home) || normalized === normalizeTerm(auto.user)) {
|
|
300
|
+
return t("securityInbox.autoProtected");
|
|
301
|
+
}
|
|
302
|
+
return "";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The screen's words for a server rejection; the raw reason stays beside them. */
|
|
306
|
+
const TERM_REJECTIONS = { empty: "securityInbox.rejectEmpty", too_short: "securityInbox.rejectShort", loopback: "securityInbox.rejectLoopback" };
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The repository baseline of this installation: its terms, the read-only automatic items and the exported file's state.
|
|
310
|
+
* Terms share the allowed values' revision, so a stale write gets 409 and re-reads instead of retrying on top of it.
|
|
311
|
+
*/
|
|
312
|
+
export function createSecurityBaseline(fetcher, changed = () => {}) {
|
|
313
|
+
const state = {
|
|
314
|
+
revision: -1, terms: [], auto: null, exportedAt: null, exportError: null,
|
|
315
|
+
online: false, busy: false, loading: false, error: "", notice: "", ready: false,
|
|
316
|
+
};
|
|
317
|
+
let generation = 0;
|
|
318
|
+
let read = 0;
|
|
319
|
+
let wanted = -1;
|
|
320
|
+
async function refresh() {
|
|
321
|
+
if (!state.online) return;
|
|
322
|
+
const epoch = generation;
|
|
323
|
+
const request = ++read;
|
|
324
|
+
state.loading = true;
|
|
325
|
+
state.error = "";
|
|
326
|
+
state.notice = "";
|
|
327
|
+
changed();
|
|
328
|
+
try {
|
|
329
|
+
const response = await fetcher(BASELINE_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
330
|
+
const body = await response.json();
|
|
331
|
+
if (epoch !== generation || request !== read) return;
|
|
332
|
+
if (!response.ok || !Number.isSafeInteger(body.revision) || !Array.isArray(body.terms)) throw new Error("list_unavailable");
|
|
333
|
+
if (body.revision >= state.revision && body.revision >= wanted) {
|
|
334
|
+
state.revision = body.revision;
|
|
335
|
+
state.terms = body.terms;
|
|
336
|
+
state.auto = body.auto ?? null;
|
|
337
|
+
state.exportedAt = body.exportedAt ?? null;
|
|
338
|
+
state.exportError = body.exportError ?? null;
|
|
339
|
+
state.ready = true;
|
|
340
|
+
} else state.error = t("securityInbox.listChanged");
|
|
341
|
+
} catch {
|
|
342
|
+
if (epoch === generation && request === read) state.error = t("securityInbox.baselineReadFailed");
|
|
343
|
+
} finally {
|
|
344
|
+
if (epoch === generation && request === read) {
|
|
345
|
+
state.loading = false;
|
|
346
|
+
changed();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** One write against the shown revision. Resolves true only when the server applied it; 409 and 404 re-read the newest list. */
|
|
351
|
+
async function write(path, method, payload, unconfirmed) {
|
|
352
|
+
// ready implies online: disconnect clears it, and a read applies it only inside its own generation.
|
|
353
|
+
if (!state.ready || state.busy || state.loading || state.error) return false;
|
|
354
|
+
state.busy = true;
|
|
355
|
+
state.notice = "";
|
|
356
|
+
const epoch = generation;
|
|
357
|
+
changed();
|
|
358
|
+
try {
|
|
359
|
+
const response = await fetcher(path, {
|
|
360
|
+
method, headers: { "content-type": "application/json" },
|
|
361
|
+
body: JSON.stringify({ ...payload, expectedRevision: state.revision }), signal: AbortSignal.timeout(30_000),
|
|
362
|
+
});
|
|
363
|
+
const body = await response.json();
|
|
364
|
+
if (epoch !== generation) return false;
|
|
365
|
+
if (response.status === 400) {
|
|
366
|
+
state.notice = body?.error === "term_duplicate" ? t("securityInbox.termDuplicate")
|
|
367
|
+
: t("securityInbox.termRejected", { reason: body?.reason ?? body?.error, detail: TERM_REJECTIONS[body?.reason] ? t(TERM_REJECTIONS[body.reason]) : t("securityInbox.rejectDefault") });
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const stale = response.status === 409 || response.status === 404;
|
|
371
|
+
if (!response.ok && !stale) throw new Error("write_unconfirmed");
|
|
372
|
+
if (Number.isSafeInteger(body?.revision)) wanted = Math.max(wanted, body.revision);
|
|
373
|
+
await refresh();
|
|
374
|
+
if (stale && epoch === generation) state.notice = t("securityInbox.listRefreshed");
|
|
375
|
+
return response.ok;
|
|
376
|
+
} catch {
|
|
377
|
+
if (epoch === generation) state.error = unconfirmed;
|
|
378
|
+
return false;
|
|
379
|
+
} finally {
|
|
380
|
+
if (epoch === generation) { state.busy = false; changed(); }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
state, refresh,
|
|
385
|
+
connect: () => { generation++; state.online = true; return refresh(); },
|
|
386
|
+
disconnect: () => {
|
|
387
|
+
generation++; state.online = false; state.busy = false; state.loading = false; state.ready = false; changed();
|
|
388
|
+
},
|
|
389
|
+
updated: (revision) => {
|
|
390
|
+
if (revision <= Math.max(wanted, state.revision)) return;
|
|
391
|
+
wanted = revision;
|
|
392
|
+
void refresh();
|
|
393
|
+
},
|
|
394
|
+
add: async (value) => {
|
|
395
|
+
const term = typeof value === "string" ? value.trim() : "";
|
|
396
|
+
if (term === "") {
|
|
397
|
+
state.notice = t("securityInbox.enterTerm");
|
|
398
|
+
changed();
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
return write(BASELINE_PATH, "POST", { value: term }, t("securityInbox.addUnconfirmed"));
|
|
402
|
+
},
|
|
403
|
+
remove: (id) => write(`${BASELINE_PATH}/${encodeURIComponent(id)}`, "DELETE", {},
|
|
404
|
+
t("securityInbox.deleteUnconfirmed")),
|
|
236
405
|
};
|
|
237
406
|
}
|
package/web/slash.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
import { registerStrings, t } from "./i18n.js";
|
|
2
|
+
import strings from "./i18n/slash.js";
|
|
3
|
+
|
|
4
|
+
registerStrings(strings);
|
|
5
|
+
|
|
1
6
|
export const WEB_COMMANDS = [
|
|
2
|
-
{ name: "tts on", description
|
|
3
|
-
{ name: "tts off", description
|
|
4
|
-
{ name: "wake on", description
|
|
5
|
-
{ name: "wake off", description
|
|
6
|
-
{ name: "clap on", description
|
|
7
|
-
{ name: "clap off", description
|
|
8
|
-
{ name: "voice on", description
|
|
9
|
-
{ name: "voice off", description
|
|
10
|
-
{ name: "옵션", description
|
|
11
|
-
{ name: "read", description
|
|
12
|
-
{ name: "기기", description
|
|
13
|
-
{ name: "기기 삭제", description
|
|
7
|
+
{ name: "tts on", get description() { return t("slash.ttsOn"); }, source: "web" },
|
|
8
|
+
{ name: "tts off", get description() { return t("slash.ttsOff"); }, source: "web" },
|
|
9
|
+
{ name: "wake on", get description() { return t("slash.wakeOn"); }, source: "web" },
|
|
10
|
+
{ name: "wake off", get description() { return t("slash.wakeOff"); }, source: "web" },
|
|
11
|
+
{ name: "clap on", get description() { return t("slash.clapOn"); }, source: "web" },
|
|
12
|
+
{ name: "clap off", get description() { return t("slash.clapOff"); }, source: "web" },
|
|
13
|
+
{ name: "voice on", get description() { return t("slash.voiceOn"); }, source: "web" },
|
|
14
|
+
{ name: "voice off", get description() { return t("slash.voiceOff"); }, source: "web" },
|
|
15
|
+
{ name: "옵션", get description() { return t("slash.options"); }, source: "web" },
|
|
16
|
+
{ name: "read", get description() { return t("slash.read"); }, source: "web" },
|
|
17
|
+
{ name: "기기", get description() { return t("slash.devices"); }, source: "web" },
|
|
18
|
+
{ name: "기기 삭제", get description() { return t("slash.deviceDelete"); }, source: "web" },
|
|
14
19
|
];
|
|
15
20
|
|
|
16
|
-
export const SOURCE_LABELS = {
|
|
21
|
+
export const SOURCE_LABELS = {
|
|
22
|
+
get skill() { return t("slash.skill"); },
|
|
23
|
+
get extension() { return t("slash.command"); },
|
|
24
|
+
get prompt() { return t("slash.template"); },
|
|
25
|
+
get web() { return t("slash.webZig"); },
|
|
26
|
+
};
|
|
17
27
|
const SOURCE_ORDER = ["skill", "extension", "prompt", "web"];
|
|
18
28
|
|
|
19
29
|
export function slashQuery(text) {
|