@songsid/agend 2.2.0 → 2.3.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.
Files changed (271) hide show
  1. package/dist/agent-cli.js +248 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +2 -0
  4. package/dist/agent-endpoint.js +25 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +30 -0
  7. package/dist/agent-file-read.js +129 -0
  8. package/dist/agent-file-read.js.map +1 -0
  9. package/dist/agent-whats-new.js +13 -0
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/atomic-write.js +9 -1
  12. package/dist/atomic-write.js.map +1 -1
  13. package/dist/auth-api.js +17 -0
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/backend/antigravity.js +3 -3
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/launch-effort.js +1 -1
  18. package/dist/backend/launch-effort.js.map +1 -1
  19. package/dist/backend/muse.js +10 -2
  20. package/dist/backend/muse.js.map +1 -1
  21. package/dist/channel/adapters/discord.d.ts +2 -0
  22. package/dist/channel/adapters/discord.js +17 -2
  23. package/dist/channel/adapters/discord.js.map +1 -1
  24. package/dist/channel/adapters/telegram.d.ts +8 -1
  25. package/dist/channel/adapters/telegram.js +74 -4
  26. package/dist/channel/adapters/telegram.js.map +1 -1
  27. package/dist/channel/mcp-tools.js +34 -0
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/channel/types.d.ts +15 -0
  30. package/dist/config-validator.js +17 -1
  31. package/dist/config-validator.js.map +1 -1
  32. package/dist/daemon.js +244 -20
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/fleet-context.d.ts +5 -0
  35. package/dist/fleet-manager.d.ts +89 -0
  36. package/dist/fleet-manager.js +401 -25
  37. package/dist/fleet-manager.js.map +1 -1
  38. package/dist/general-knowledge/skills/web-appearance/SKILL.md +258 -0
  39. package/dist/instance-avatar.js +3 -0
  40. package/dist/instance-avatar.js.map +1 -1
  41. package/dist/instance-config-impact.js +3 -0
  42. package/dist/instance-config-impact.js.map +1 -1
  43. package/dist/instance-lifecycle.js +9 -4
  44. package/dist/instance-lifecycle.js.map +1 -1
  45. package/dist/needs-you-hub.d.ts +4 -0
  46. package/dist/needs-you-hub.js +31 -6
  47. package/dist/needs-you-hub.js.map +1 -1
  48. package/dist/needs-you.d.ts +1 -13
  49. package/dist/needs-you.js +3 -37
  50. package/dist/needs-you.js.map +1 -1
  51. package/dist/outbound-handlers.d.ts +5 -0
  52. package/dist/outbound-handlers.js +30 -9
  53. package/dist/outbound-handlers.js.map +1 -1
  54. package/dist/outbound-schemas.d.ts +64 -0
  55. package/dist/outbound-schemas.js +50 -0
  56. package/dist/outbound-schemas.js.map +1 -1
  57. package/dist/public-web-gateway.js +14 -0
  58. package/dist/public-web-gateway.js.map +1 -1
  59. package/dist/repo-worktree-guard.d.ts +36 -0
  60. package/dist/repo-worktree-guard.js +143 -0
  61. package/dist/repo-worktree-guard.js.map +1 -0
  62. package/dist/scheduler/scheduler.d.ts +58 -1
  63. package/dist/scheduler/scheduler.js +176 -10
  64. package/dist/scheduler/scheduler.js.map +1 -1
  65. package/dist/settings-api.js +74 -1
  66. package/dist/settings-api.js.map +1 -1
  67. package/dist/settings-change.js +8 -0
  68. package/dist/settings-change.js.map +1 -1
  69. package/dist/settings-effect.js +16 -2
  70. package/dist/settings-effect.js.map +1 -1
  71. package/dist/settings-http-confirmation.js +5 -0
  72. package/dist/settings-http-confirmation.js.map +1 -1
  73. package/dist/tool-permissions.js +50 -0
  74. package/dist/tool-permissions.js.map +1 -1
  75. package/dist/topic-commands.d.ts +10 -6
  76. package/dist/topic-commands.js +21 -17
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/topic-link.d.ts +128 -0
  79. package/dist/topic-link.js +269 -0
  80. package/dist/topic-link.js.map +1 -0
  81. package/dist/types.d.ts +13 -0
  82. package/dist/ui/app-effects.js +187 -0
  83. package/dist/ui/chat-effect-posters.js +42 -0
  84. package/dist/ui/chat-effects.js +72 -0
  85. package/dist/ui/chat-live-replies.js +58 -0
  86. package/dist/ui/chat-render.js +4 -4
  87. package/dist/ui/chat-store.js +9 -3
  88. package/dist/ui/chat-strings.js +15 -0
  89. package/dist/ui/chat-thread.js +47 -21
  90. package/dist/ui/chat-tour.js +1 -1
  91. package/dist/ui/effect-trial-controller.js +119 -0
  92. package/dist/ui/effect-trial-offer.js +31 -0
  93. package/dist/ui/effect-trial-panel.js +37 -0
  94. package/dist/ui/image-lightbox.js +1 -1
  95. package/dist/ui/panel-chat.js +85 -17
  96. package/dist/ui/panel-details.js +18 -5
  97. package/dist/ui/panel-fleet.js +3 -3
  98. package/dist/ui/panel-log.js +6 -6
  99. package/dist/ui/panel-needs.js +3 -3
  100. package/dist/ui/panel-settings.js +8 -5
  101. package/dist/ui/preview-panel.js +25 -9
  102. package/dist/ui/settings-appearance.js +325 -0
  103. package/dist/ui/settings-effects.js +201 -0
  104. package/dist/ui/settings-mods.js +257 -0
  105. package/dist/ui/settings-owner-effects.js +80 -0
  106. package/dist/ui/settings-strings.js +245 -0
  107. package/dist/ui/shared/agend-auth.js +27 -3
  108. package/dist/ui/shared/agent-widget.js +248 -0
  109. package/dist/ui/shared/app-i18n.js +2 -0
  110. package/dist/ui/shared/app-prefs.js +30 -10
  111. package/dist/ui/shared/app-route.js +23 -1
  112. package/dist/ui/shared/app-session.js +1 -1
  113. package/dist/ui/shared/app-shell.js +29 -16
  114. package/dist/ui/shared/app-store.js +2 -0
  115. package/dist/ui/shared/app-stream.js +12 -7
  116. package/dist/ui/shared/app-themes.js +188 -0
  117. package/dist/ui/shared/app.css +214 -1
  118. package/dist/ui/shared/app.js +6 -0
  119. package/dist/ui/shared/card-strings.js +28 -0
  120. package/dist/ui/shared/effect-app-runtime.js +248 -0
  121. package/dist/ui/shared/effect-baseline.js +64 -0
  122. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  123. package/dist/ui/shared/effect-cursor.js +125 -0
  124. package/dist/ui/shared/effect-frame.js +127 -0
  125. package/dist/ui/shared/effect-geometry.js +122 -0
  126. package/dist/ui/shared/effect-host-style.js +10 -0
  127. package/dist/ui/shared/effect-owner-regions.js +74 -0
  128. package/dist/ui/shared/effect-owner-source.js +115 -0
  129. package/dist/ui/shared/effect-policy.js +137 -0
  130. package/dist/ui/shared/effect-poster-reader.js +98 -0
  131. package/dist/ui/shared/effect-posters.js +117 -0
  132. package/dist/ui/shared/effect-profiler.js +162 -0
  133. package/dist/ui/shared/effect-profiles.js +65 -0
  134. package/dist/ui/shared/effect-registry.js +496 -0
  135. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  136. package/dist/ui/shared/effect-safety.js +153 -0
  137. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  138. package/dist/ui/shared/effect-stock-trust.js +165 -0
  139. package/dist/ui/shared/effect-trial-source.js +103 -0
  140. package/dist/ui/shared/effect-trial-stage.js +147 -0
  141. package/dist/ui/shared/header-tools.js +1 -1
  142. package/dist/ui/shared/identity-card.js +202 -0
  143. package/dist/ui/shared/identity-color.js +261 -0
  144. package/dist/ui/shared/instance-nav.js +22 -14
  145. package/dist/ui/shared/owner-css.js +221 -0
  146. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  147. package/dist/ui/shared/panel-view.js +81 -8
  148. package/dist/ui/shared/signin.js +2 -0
  149. package/dist/ui/shared/ui-dialog.js +1 -1
  150. package/dist/ui/shared/ui-icons.js +2 -0
  151. package/dist/ui/shared/ui-menu.js +1 -1
  152. package/dist/ui/shared/ui-toast.js +1 -1
  153. package/dist/ui/shared/view-strings.js +30 -0
  154. package/dist/ui/signin.html +1 -0
  155. package/dist/view-api.d.ts +29 -0
  156. package/dist/view-api.js +188 -8
  157. package/dist/view-api.js.map +1 -1
  158. package/dist/web-api.d.ts +42 -0
  159. package/dist/web-api.js +896 -36
  160. package/dist/web-api.js.map +1 -1
  161. package/dist/web-appearance-limits.d.ts +82 -0
  162. package/dist/web-appearance-limits.js +83 -0
  163. package/dist/web-appearance-limits.js.map +1 -0
  164. package/dist/web-appearance-policy.d.ts +66 -0
  165. package/dist/web-appearance-policy.js +94 -0
  166. package/dist/web-appearance-policy.js.map +1 -0
  167. package/dist/web-appearance-tools.d.ts +50 -0
  168. package/dist/web-appearance-tools.js +105 -0
  169. package/dist/web-appearance-tools.js.map +1 -0
  170. package/dist/web-appearance-view.d.ts +99 -0
  171. package/dist/web-appearance-view.js +163 -0
  172. package/dist/web-appearance-view.js.map +1 -0
  173. package/dist/web-appearance.d.ts +79 -0
  174. package/dist/web-appearance.js +298 -0
  175. package/dist/web-appearance.js.map +1 -0
  176. package/dist/web-asset-parts.d.ts +98 -0
  177. package/dist/web-asset-parts.js +133 -0
  178. package/dist/web-asset-parts.js.map +1 -0
  179. package/dist/web-auth.js +17 -1
  180. package/dist/web-auth.js.map +1 -1
  181. package/dist/web-card.d.ts +96 -0
  182. package/dist/web-card.js +232 -0
  183. package/dist/web-card.js.map +1 -0
  184. package/dist/web-chat-history.d.ts +13 -0
  185. package/dist/web-chat-history.js +23 -2
  186. package/dist/web-chat-history.js.map +1 -1
  187. package/dist/web-effect-bundle.d.ts +28 -0
  188. package/dist/web-effect-bundle.js +126 -0
  189. package/dist/web-effect-bundle.js.map +1 -0
  190. package/dist/web-effect-canonical.d.ts +8 -0
  191. package/dist/web-effect-canonical.js +27 -0
  192. package/dist/web-effect-canonical.js.map +1 -0
  193. package/dist/web-effect-library.d.ts +114 -0
  194. package/dist/web-effect-library.js +530 -0
  195. package/dist/web-effect-library.js.map +1 -0
  196. package/dist/web-effect-poster.d.ts +8 -0
  197. package/dist/web-effect-poster.js +52 -0
  198. package/dist/web-effect-poster.js.map +1 -0
  199. package/dist/web-effect-profiles.d.ts +118 -0
  200. package/dist/web-effect-profiles.js +180 -0
  201. package/dist/web-effect-profiles.js.map +1 -0
  202. package/dist/web-effect-service.d.ts +86 -0
  203. package/dist/web-effect-service.js +312 -0
  204. package/dist/web-effect-service.js.map +1 -0
  205. package/dist/web-effect-tools.d.ts +47 -0
  206. package/dist/web-effect-tools.js +126 -0
  207. package/dist/web-effect-tools.js.map +1 -0
  208. package/dist/web-effect-trials.d.ts +86 -0
  209. package/dist/web-effect-trials.js +204 -0
  210. package/dist/web-effect-trials.js.map +1 -0
  211. package/dist/web-effect-upload.d.ts +29 -0
  212. package/dist/web-effect-upload.js +190 -0
  213. package/dist/web-effect-upload.js.map +1 -0
  214. package/dist/web-effect-worker.d.ts +8 -0
  215. package/dist/web-effect-worker.js +170 -0
  216. package/dist/web-effect-worker.js.map +1 -0
  217. package/dist/web-effect.d.ts +89 -0
  218. package/dist/web-effect.js +132 -0
  219. package/dist/web-effect.js.map +1 -0
  220. package/dist/web-identity-color.d.ts +87 -0
  221. package/dist/web-identity-color.js +209 -0
  222. package/dist/web-identity-color.js.map +1 -0
  223. package/dist/web-identity-palette.d.ts +113 -0
  224. package/dist/web-identity-palette.js +84 -0
  225. package/dist/web-identity-palette.js.map +1 -0
  226. package/dist/web-image-meta.d.ts +25 -0
  227. package/dist/web-image-meta.js +200 -0
  228. package/dist/web-image-meta.js.map +1 -0
  229. package/dist/web-mod-agent.d.ts +50 -0
  230. package/dist/web-mod-agent.js +203 -0
  231. package/dist/web-mod-agent.js.map +1 -0
  232. package/dist/web-mod-bundle.d.ts +144 -0
  233. package/dist/web-mod-bundle.js +464 -0
  234. package/dist/web-mod-bundle.js.map +1 -0
  235. package/dist/web-mod-export.d.ts +59 -0
  236. package/dist/web-mod-export.js +79 -0
  237. package/dist/web-mod-export.js.map +1 -0
  238. package/dist/web-mod-store.d.ts +283 -0
  239. package/dist/web-mod-store.js +624 -0
  240. package/dist/web-mod-store.js.map +1 -0
  241. package/dist/web-org.d.ts +3 -3
  242. package/dist/web-org.js +11 -17
  243. package/dist/web-org.js.map +1 -1
  244. package/dist/web-owner-css-store.d.ts +180 -0
  245. package/dist/web-owner-css-store.js +384 -0
  246. package/dist/web-owner-css-store.js.map +1 -0
  247. package/dist/web-owner-css.d.ts +58 -0
  248. package/dist/web-owner-css.js +552 -0
  249. package/dist/web-owner-css.js.map +1 -0
  250. package/dist/web-prefs.d.ts +48 -0
  251. package/dist/web-prefs.js +209 -33
  252. package/dist/web-prefs.js.map +1 -1
  253. package/dist/web-preview.d.ts +9 -1
  254. package/dist/web-preview.js +24 -7
  255. package/dist/web-preview.js.map +1 -1
  256. package/dist/web-settings-patch.d.ts +18 -0
  257. package/dist/web-settings-patch.js +51 -0
  258. package/dist/web-settings-patch.js.map +1 -1
  259. package/dist/web-shell-routes.d.ts +1 -1
  260. package/dist/web-shell-routes.js +1 -1
  261. package/dist/web-shell-routes.js.map +1 -1
  262. package/dist/web-theme-store.d.ts +218 -0
  263. package/dist/web-theme-store.js +516 -0
  264. package/dist/web-theme-store.js.map +1 -0
  265. package/dist/web-theme.d.ts +121 -0
  266. package/dist/web-theme.js +407 -0
  267. package/dist/web-theme.js.map +1 -0
  268. package/dist/web-widget.d.ts +72 -0
  269. package/dist/web-widget.js +245 -0
  270. package/dist/web-widget.js.map +1 -0
  271. package/package.json +1 -1
@@ -0,0 +1,201 @@
1
+ // #1629 B2: library management only. This module never loads Worker code or creates a preview/frame.
2
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
3
+ import { register, t } from "/assets/app-i18n.js";
4
+ import { appStore, useStore } from "/assets/app-store.js";
5
+ import { confirmDialog } from "/assets/ui-confirm.js";
6
+ import { OwnerEffects } from "./settings-owner-effects.js";
7
+
8
+ register("effectSettings", {
9
+ en: {
10
+ title: "Effects", lead: "Manage effect files and save selections for a chat. Saving replaces that chat’s parameters with the values below.", folder: "Upload an effect folder", files: "Choose effect.json and worker.js, with optional raster assets. Up to 1 MiB after processing.",
11
+ inUse: "This effect is still in use and cannot be deleted.", quota: "Your library reached its 10-effect or 8 MiB limit. Delete unused copies first.", rateLimited: "Too many changes. Wait a moment, then try again.",
12
+ loading: "Loading effects…", retry: "Reload library", empty: "No effects in your library.", failed: "Couldn't load the library. Try again.", actionFailed: "Couldn't save this change. Try again.", savedRefresh: "Saved. Reload the library to see the result.",
13
+ uploadFailed: "Couldn't upload this folder. Check its files and size, then try again.", public: "Effect management isn't available on a temporary public link.", readOnly: "Read-only session: sign in as an admin to change effects.",
14
+ yours: "Your copy", shared: "Shared with you", share: "Share your copy with agents and people on this fleet", delete: "Delete your copy", deleteConfirm: "Delete your unused copy of this effect? Other people's copies are kept.",
15
+ chat: "Chat", chooseChat: "Choose a chat", apply: "Save for this chat", remove: "Remove from this chat", saved: "Saved.", history: "Profile history", unmeasured: "Not measured yet", unavailable: "Profile unavailable", reported: "Reported host observations; they don't prove an effect's cost.",
16
+ fps: "Host FPS p50 / p10", tasks: "Host long task p95", noTasks: "Not observed", standard: "Standard device", constrained: "Constrained device", params: "Parameters", badParams: "Check the parameter values.", more: "Show more", count: "{0} effects", one: "1 effect", profileFailed: "Couldn't read profile history. Try again.",
17
+ },
18
+ "zh-TW": {
19
+ title: "效果", lead: "管理效果檔案,並儲存聊天的效果選擇。儲存時會以下方數值取代該聊天的效果參數。", folder: "上傳效果資料夾", files: "選擇 effect.json、worker.js,以及可選的點陣圖素材;處理後最多 1 MiB。",
20
+ inUse: "這個效果仍在使用中,無法刪除。", quota: "你的程式庫已達 10 個效果或 8 MiB 上限,請先刪除未使用的副本。", rateLimited: "變更太頻繁,請稍候再試。",
21
+ loading: "正在載入效果…", retry: "重新載入程式庫", empty: "程式庫中還沒有效果。", failed: "無法載入程式庫,請重試。", actionFailed: "無法儲存變更,請重試。", savedRefresh: "已儲存,請重新載入程式庫查看結果。",
22
+ uploadFailed: "無法上傳這個資料夾,請檢查檔案與大小後重試。", public: "臨時公開連結不提供效果管理。", readOnly: "目前為唯讀登入,請以管理員身分登入才能修改效果。",
23
+ yours: "你持有的副本", shared: "他人與你共享", share: "將你的副本共享給這個 fleet 的 agent 與使用者", delete: "刪除你的副本", deleteConfirm: "刪除你未使用的效果副本?其他人的副本會保留。",
24
+ chat: "聊天", chooseChat: "選擇聊天", apply: "儲存到這個聊天", remove: "從這個聊天移除", saved: "已儲存。", history: "效能回報紀錄", unmeasured: "尚未測量", unavailable: "無法取得效能回報", reported: "這是主機觀測回報,不能證明效果本身的資源用量。",
25
+ fps: "主機 FPS p50/p10", tasks: "主機長任務 p95", noTasks: "未觀測到", standard: "一般裝置", constrained: "受限裝置", params: "參數", badParams: "請檢查參數值。", more: "顯示更多", count: "{0} 個效果", one: "1 個效果", profileFailed: "無法讀取效能回報紀錄,請重試。",
26
+ },
27
+ });
28
+ const tn = (key, ...args) => t(`effectSettings.${key}`, ...args);
29
+ const REFUSALS = Object.freeze({ effect_in_use: "inUse", effect_quota: "quota", rate_limited: "rateLimited" });
30
+ const refusal = (body, fallback) => tn(typeof body?.error === "string" && Object.hasOwn(REFUSALS, body.error) ? REFUSALS[body.error] : fallback);
31
+ const ID = /^[0-9a-f]{32}$/;
32
+ const PATH = /^(?:effect\.json|worker\.js|poster\.png|cursor\.png|assets\/[a-z0-9][a-z0-9_-]{0,63}\.(?:png|jpe?g|webp|gif))$/;
33
+ const RAW_MAX = 8 * 1024 * 1024, WIRE_MAX = 12 * 1024 * 1024;
34
+ const plain = (v) => !!v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype;
35
+ const publicLink = () => typeof document !== "undefined" && document.body?.dataset.publicLink === "1";
36
+
37
+ /** File metadata bounds are checked before reading. No directory path reaches the server. */
38
+ export async function encodeEffectFolder(input, signal) {
39
+ const files = Array.from(input || []);
40
+ if (files.length < 2 || files.length > 20) throw Error("effect_files");
41
+ const nested = files.some(f => !!f?.webkitRelativePath), paths = new Set(); let root = null, raw = 0, assets = 0;
42
+ const rows = files.map(file => {
43
+ if (!file || typeof file.name !== "string" || !Number.isSafeInteger(file.size) || file.size < 0 || (raw += file.size) > RAW_MAX || typeof file.arrayBuffer !== "function") throw Error("effect_files");
44
+ let path = file.name;
45
+ if (nested) {
46
+ const relative = file.webkitRelativePath;
47
+ if (typeof relative !== "string" || relative.startsWith("/") || relative.includes("\\")) throw Error("effect_files");
48
+ const at = relative.indexOf("/"); if (at < 1) throw Error("effect_files");
49
+ const prefix = relative.slice(0, at); if (root === null) root = prefix; if (root !== prefix) throw Error("effect_files");
50
+ path = relative.slice(at + 1);
51
+ }
52
+ if (!PATH.test(path) || paths.has(path) || (path.startsWith("assets/") && ++assets > 16)) throw Error("effect_files");
53
+ paths.add(path); return { file, path };
54
+ });
55
+ if (!paths.has("effect.json") || !paths.has("worker.js")) throw Error("effect_files");
56
+ const encoded = [];
57
+ for (const { file, path } of rows) {
58
+ if (signal?.aborted) throw Error("cancelled");
59
+ const buffer = await file.arrayBuffer(); if (signal?.aborted) throw Error("cancelled");
60
+ if (!(buffer instanceof ArrayBuffer) || buffer.byteLength !== file.size) throw Error("effect_files");
61
+ const bytes = new Uint8Array(buffer), chunks = [];
62
+ for (let i = 0; i < bytes.length; i += 8192) chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));
63
+ encoded.push({ path, data_b64: btoa(chunks.join("")) });
64
+ }
65
+ const body = JSON.stringify(encoded); if (new TextEncoder().encode(body).byteLength > WIRE_MAX) throw Error("effect_files");
66
+ return body;
67
+ }
68
+ export function effectCatalog(body) {
69
+ if (!plain(body) || typeof body.canWrite !== "boolean" || !Array.isArray(body.effects)) return null;
70
+ const seen = new Set(), effects = [];
71
+ for (const e of body.effects) {
72
+ if (!plain(e) || typeof e.id !== "string" || !ID.test(e.id) || seen.has(e.id) || !plain(e.manifest) || typeof e.manifest.title !== "string" || e.manifest.title.length < 1 || e.manifest.title.length > 40 || !Number.isInteger(e.bytes) || e.bytes < 1 || e.bytes > 1048576 || typeof e.ownClaim !== "boolean" || typeof e.sharedByYou !== "boolean" || !e.ownClaim && e.sharedByYou) return null;
73
+ if (effectParamOverrides(e.manifest) === null) return null;
74
+ seen.add(e.id); effects.push(e);
75
+ }
76
+ return { canWrite: body.canWrite, effects };
77
+ }
78
+ /** UI draft conversion only; the host independently checks the manifest and every override on apply. */
79
+ export function effectParamOverrides(manifest, draft = {}) {
80
+ if (!plain(manifest) || !plain(draft) || manifest.params !== undefined && !plain(manifest.params)) return null;
81
+ const defs = manifest.params || {}, out = Object.create(null);
82
+ if (Object.keys(defs).length > 16 || Object.keys(draft).some(k => !Object.hasOwn(defs, k))) return null;
83
+ for (const [key, d] of Object.entries(defs)) {
84
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || !plain(d)) return null;
85
+ const supplied = Object.hasOwn(draft, key); let v = supplied ? draft[key] : d.default;
86
+ if (d.type === "int" || d.type === "number") {
87
+ if (supplied && typeof v === "string" && v.trim() !== "") v = Number(v);
88
+ if (typeof v !== "number" || !Number.isFinite(v) || !Number.isFinite(d.min) || !Number.isFinite(d.max) || v < d.min || v > d.max || d.type === "int" && !Number.isSafeInteger(v)) return null;
89
+ if (d.type === "number" && d.step !== undefined) {
90
+ const n = (v - d.min) / d.step;
91
+ if (!(d.step > 0) || !Number.isFinite(n) || Math.abs(n - Math.round(n)) > 1e-8) return null;
92
+ }
93
+ } else if (d.type === "bool") { if (typeof v !== "boolean") return null; }
94
+ else if (d.type === "enum") { if (!Array.isArray(d.values) || d.values.length > 16 || typeof v !== "string" || !d.values.includes(v)) return null; }
95
+ else if (d.type === "color") { if (typeof v !== "string" || !/^#[0-9a-fA-F]{6}$/.test(v)) return null; v = v.toLowerCase(); }
96
+ else return null;
97
+ if (v !== d.default) out[key] = v;
98
+ }
99
+ return out;
100
+ }
101
+ function Params({ manifest, draft, update, disabled }) {
102
+ const defs = plain(manifest.params) ? Object.entries(manifest.params).slice(0, 16) : [];
103
+ if (!defs.length) return null;
104
+ return html`<fieldset class="s-fx-params" disabled=${disabled}><legend>${tn("params")}</legend>${defs.map(([key, d]) => {
105
+ const value = Object.hasOwn(draft, key) ? draft[key] : d.default;
106
+ if (d.type === "bool") return html`<label key=${key} class="check"><input data-param=${key} type="checkbox" checked=${value} onChange=${e => update(key, e.target.checked)} />${key}</label>`;
107
+ if (d.type === "enum") return html`<label key=${key}>${key}<select data-param=${key} value=${value} onChange=${e => update(key, e.target.value)}>${d.values.map(v => html`<option key=${v} value=${v}>${v}</option>`)}</select></label>`;
108
+ return html`<label key=${key}>${key}<input data-param=${key} type=${d.type === "color" ? "color" : "number"} min=${d.min} max=${d.max} step=${d.type === "int" ? 1 : d.step || "any"} value=${value} onInput=${e => update(key, e.target.value)} /></label>`;
109
+ })}</fieldset>`;
110
+ }
111
+ async function call(path, init, signal) {
112
+ try {
113
+ const res = await fetch(path, { credentials: "same-origin", ...init, signal });
114
+ let body = null; try { body = await res.json(); } catch { /* unknown response refuses */ }
115
+ return { ok: res.ok && !body?.error, body };
116
+ } catch { return { ok: false, body: null }; }
117
+ }
118
+
119
+ function Profile({ value }) {
120
+ if (!value || value.error || !Array.isArray(value.records)) return html`<p class="sub">${tn("unavailable")}</p>`;
121
+ if (value.status === "unmeasured" || !value.records.length) return html`<p class="sub">${tn("unmeasured")}</p>`;
122
+ return html`<div class="s-fx-profile"><p class="sub">${tn("reported")}</p>${value.records.map((p, i) => {
123
+ const valid = p && ["standard", "constrained"].includes(p.device) && [p.fps_p50, p.fps_p10].every(n => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1000);
124
+ if (!valid) return html`<p key=${i} class="sub">${tn("unavailable")}</p>`;
125
+ return html`<div key=${i}><strong>${tn(p.device)}</strong><p class="sub">${tn("fps")}: ${p.fps_p50} / ${p.fps_p10} · ${tn("tasks")}: ${typeof p.long_task_p95 === "number" && Number.isFinite(p.long_task_p95) ? `${p.long_task_p95} ms` : tn("noTasks")}</p></div>`;
126
+ })}</div>`;
127
+ }
128
+
129
+ /** Admin changes remain authoritative on the host; canWrite is a display hint only. */
130
+ export function EffectsCard() {
131
+ const app = useStore(appStore), [view, setView] = useState(null), [error, setError] = useState(null), [busy, setBusy] = useState(false), [message, setMessage] = useState(null);
132
+ const [target, setTarget] = useState(""), [limit, setLimit] = useState(50), [history, setHistory] = useState({}), [drafts, setDrafts] = useState({});
133
+ const uploadInput = useRef(null);
134
+ const life = useRef({ alive: false, seq: 0, controller: null, pending: false });
135
+ const current = (seq) => life.current.alive && seq === life.current.seq;
136
+ const begin = () => { if (!life.current.alive || life.current.pending || publicLink()) return null; life.current.pending = true; life.current.controller?.abort(); const controller = new AbortController(), seq = ++life.current.seq; life.current.controller = controller; return { seq, signal: controller.signal }; };
137
+ const finish = seq => { if (current(seq)) { life.current.pending = false; setBusy(false); } };
138
+ const load = async (op) => {
139
+ const r = await call("/ui/effects", {}, op.signal); if (!current(op.seq)) return false;
140
+ const data = r.ok ? effectCatalog(r.body) : null; setView(data); setHistory({}); setError(data ? null : tn("failed")); return !!data;
141
+ };
142
+ const reload = async () => { const op = begin(); if (!op) return; setBusy(true); setMessage(null); await load(op); finish(op.seq); };
143
+ useEffect(() => {
144
+ life.current.alive = true;
145
+ if (!publicLink()) reload();
146
+ return () => { life.current.alive = false; life.current.seq++; life.current.controller?.abort(); };
147
+ }, []);
148
+ if (publicLink()) return html`<section class="card s-appearance s-effects"><h3>${tn("title")}</h3><p class="sub">${tn("public")}</p></section>`;
149
+ const names = app.ready ? [...new Set((app.instances || []).map(i => i?.name).filter(n => typeof n === "string" && n.length > 0 && n.length <= 128))] : [];
150
+ const selected = names.includes(target) ? target : "";
151
+ const change = async (path, body, method = "POST") => {
152
+ if (busy || !view?.canWrite) return;
153
+ const op = begin(); if (!op) return; setBusy(true); setMessage(null);
154
+ const r = await call(path, { method, ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) }, op.signal);
155
+ if (!current(op.seq)) return;
156
+ if (!r.ok) setMessage(refusal(r.body, "actionFailed")); else { const refreshed = await load(op); if (current(op.seq)) setMessage(tn(refreshed ? "saved" : "savedRefresh")); }
157
+ finish(op.seq);
158
+ };
159
+ const upload = async (event) => {
160
+ const input = event.currentTarget, files = Array.from(input.files || []); input.value = "";
161
+ if (busy || !view?.canWrite || !files.length) return;
162
+ const op = begin(); if (!op) return; setBusy(true); setMessage(null);
163
+ try {
164
+ const body = await encodeEffectFolder(files, op.signal); if (!current(op.seq)) return;
165
+ const r = await call("/ui/effects/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body }, op.signal);
166
+ if (!current(op.seq)) return;
167
+ if (!r.ok) setMessage(refusal(r.body, "uploadFailed")); else { const refreshed = await load(op); if (current(op.seq)) setMessage(tn(refreshed ? "saved" : "savedRefresh")); }
168
+ } catch { if (current(op.seq)) setMessage(tn("uploadFailed")); }
169
+ finish(op.seq);
170
+ };
171
+ const profile = async (id) => {
172
+ if (busy) return; const op = begin(); if (!op) return; setBusy(true);
173
+ const r = await call(`/ui/effects/profile/${id}`, {}, op.signal); if (!current(op.seq)) return;
174
+ if (r.ok && r.body && Array.isArray(r.body.records) && r.body.records.length <= 40) setHistory(h => ({ ...h, [id]: r.body })); else setMessage(tn("profileFailed"));
175
+ finish(op.seq);
176
+ };
177
+ const askDelete = async id => {
178
+ if (!view?.canWrite) return; const op = begin(); if (!op) return; setBusy(true);
179
+ const yes = await confirmDialog({ message: tn("deleteConfirm"), confirmLabel: tn("delete"), danger: true });
180
+ if (!current(op.seq)) return; finish(op.seq);
181
+ if (yes) await change(`/ui/effects/${id}`, undefined, "DELETE");
182
+ };
183
+ return html`<section class="card s-appearance s-effects"><h3>${tn("title")}</h3><p class="sub">${tn("lead")}</p>
184
+ <div class="s-fx-actions"><button type="button" class="btn" disabled=${busy} onClick=${reload}>${tn("retry")}</button>
185
+ ${view?.canWrite ? html`<button class="btn s-fx-choose" type="button" disabled=${busy} onClick=${() => uploadInput.current?.click()}>${tn("folder")}</button><input ref=${uploadInput} class="offscreen s-fx-upload" aria-label=${tn("folder")} tabindex="-1" type="file" webkitdirectory=${true} directory=${true} multiple disabled=${busy} onChange=${upload} />` : null}</div>
186
+ ${busy ? html`<p class="sub" role="status">${tn("loading")}</p>` : null}${error ? html`<p class="err" role="status">${error}</p>` : null}${message ? html`<p class="sub" role="status">${message}</p>` : null}
187
+ ${view ? html`<p class="sub">${tn(view.effects.length === 1 ? "one" : "count", view.effects.length)}${!view.canWrite ? ` · ${tn("readOnly")}` : ""}</p>` : null}
188
+ ${view?.canWrite ? html`<p class="sub">${tn("files")}</p><label class="s-fx-chat">${tn("chat")}<select class="s-fx-target" value=${selected} disabled=${busy} onChange=${e => setTarget(e.target.value)}><option value="">${tn("chooseChat")}</option>${names.map(n => html`<option key=${n} value=${n}>${n}</option>`)}</select></label>` : null}
189
+ ${view && !view.effects.length ? html`<p class="sub">${tn("empty")}</p>` : null}
190
+ <${OwnerEffects} catalog=${view} busy=${busy} />
191
+ <div class="s-fx-list">${(view?.effects || []).slice(0, limit).map(e => html`<article class="s-fx-item" key=${e.id} data-effect-id=${e.id}><h4>${e.manifest.title}</h4><p class="sub"><code>${e.id.slice(0, 8)}</code> · ${Math.ceil(e.bytes / 1024)} KiB · ${tn(e.ownClaim ? "yours" : "shared")}</p>
192
+ ${e.manifest.poster === "poster.png" ? html`<img class="s-fx-poster" src=${`/ui/effects/image/${e.id}/poster.png`} alt="" loading="lazy" />` : null}
193
+ <${Profile} value=${history[e.id] || e.profiles} />
194
+ ${view.canWrite ? html`<${Params} manifest=${e.manifest} draft=${drafts[e.id] || {}} disabled=${busy} update=${(key, value) => setDrafts(s => ({ ...s, [e.id]: { ...(s[e.id] || {}), [key]: value } }))} />` : null}<div class="s-fx-actions"><button class="btn" type="button" disabled=${busy} onClick=${() => profile(e.id)}>${tn("history")}</button>
195
+ ${view.canWrite ? html`<button class="btn s-fx-apply" type="button" disabled=${busy || !selected} onClick=${() => { const params = effectParamOverrides(e.manifest, drafts[e.id] || {}); if (params === null) setMessage(tn("badParams")); else change("/ui/effects/apply", { instance: selected, id: e.id, params }); }}>${tn("apply")}</button><button class="btn s-fx-remove" type="button" disabled=${busy || !selected} onClick=${() => change("/ui/effects/remove", { instance: selected, id: e.id })}>${tn("remove")}</button>` : null}
196
+ ${view.canWrite && e.ownClaim ? html`<button class="btn s-fx-delete" type="button" disabled=${busy} onClick=${() => askDelete(e.id)}>${tn("delete")}</button>` : null}</div>
197
+ ${view.canWrite && e.ownClaim ? html`<label class="check"><input class="s-fx-share" type="checkbox" checked=${e.sharedByYou} disabled=${busy} onChange=${event => change("/ui/effects/share", { id: e.id, shared: event.target.checked })} />${tn("share")}</label>` : null}
198
+ </article>`)}</div>
199
+ ${view && limit < view.effects.length ? html`<button class="btn" type="button" onClick=${() => setLimit(n => n + 50)}>${tn("more")}</button>` : null}
200
+ </section>`;
201
+ }
@@ -0,0 +1,257 @@
1
+ // #1629 r4 §4 (r4-5): Settings → Appearance → Mods. A mod is one file that carries a look: an agent's theme with its
2
+ // pictures, its card text, widgets, effects, and a person's own CSS. Importing is a person's action in two steps:
3
+ // the file is checked whole and its plan shown in full (every part, the custom CSS's whole text) — then only the
4
+ // parts the person ticks are applied, all or nothing. Custom CSS from a file is never ticked by default. Installed
5
+ // mods can be downloaded again or uninstalled (what they applied stays: Reset and the CSS controls work on it).
6
+ // Export builds a file from what is in force now.
7
+ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
8
+ import { t } from "/assets/app-i18n.js";
9
+ import { appStore, useStore } from "/assets/app-store.js";
10
+ import { Icon } from "/assets/ui-icons.js";
11
+ import { toast } from "/assets/ui-toast.js";
12
+ import { confirmDialog } from "/assets/ui-confirm.js";
13
+ import { startOwnerCssTrial } from "/assets/owner-css.js";
14
+ import { AgentWidget, checkWidget, fleetBinds, instanceBinds } from "/assets/agent-widget.js";
15
+ import "./settings-strings.js";
16
+
17
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
18
+ const kib = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MiB` : `${Math.max(1, Math.round(n / 1024))} KiB`);
19
+
20
+ async function call(path, init) {
21
+ let res;
22
+ try { res = await fetch(path, { credentials: "same-origin", ...init }); } catch { return { ok: false, status: 0, body: null }; }
23
+ let body = null;
24
+ try { body = await res.json(); } catch { /* not JSON */ }
25
+ return { ok: res.ok, status: res.status, body, res };
26
+ }
27
+ const postJson = (path, body) => call(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
28
+ function download(text, name) {
29
+ const url = URL.createObjectURL(new Blob([text], { type: "application/octet-stream" }));
30
+ const a = document.createElement("a"); a.href = url; a.download = name; a.className = "offscreen";
31
+ document.body.appendChild(a); a.click(); a.remove();
32
+ URL.revokeObjectURL(url);
33
+ }
34
+ /** Why the host refused, in words (the first problems of a bundle, each with where it is). */
35
+ function why(body) {
36
+ if (!body) return tn("modFailed", "?");
37
+ if (Array.isArray(body.problems) && body.problems.length) return body.problems.slice(0, 5).map((p) => `${p.path || "file"}: ${p.problem}`).join("\n");
38
+ return tn("modFailed", body.detail ? `${body.error}: ${body.detail}` : body.error || "?");
39
+ }
40
+
41
+ /** The parts a plan offers, each with its default: everything but custom CSS. */
42
+ export function defaultChoice(plan) {
43
+ const c = { theme: false, identity: false, effects: false, ownerCss: false, widgets: {}, replace: false };
44
+ for (const it of plan.items) {
45
+ if (it.part === "theme" || it.part === "identity") c[it.part] = true;
46
+ else if (it.part === "widget") c.widgets[it.slot] = true;
47
+ else if (it.part === "effect") c.effects = true;
48
+ // ownerCss stays false: "read it before turning it on" (r4 §4).
49
+ }
50
+ return c;
51
+ }
52
+
53
+ const COLOR = /^#[0-9a-f]{6}$/;
54
+ /** A colour as a swatch (its fill set through the CSSOM — never a style attribute) and its hex code. */
55
+ function Swatch({ color }) {
56
+ const ref = useRef(null);
57
+ useLayoutEffect(() => { if (ref.current && ref.current.style && COLOR.test(color)) ref.current.style.setProperty("--sw", color); }, [color]);
58
+ return html`<span class="s-mod-sw"><span class="s-mod-sw-chip" ref=${ref} aria-hidden="true"></span><code>${color}</code></span>`;
59
+ }
60
+ /** Every field of the checked theme, as it will be applied — nothing left unseen (#1897 review). */
61
+ export function ThemeDetail({ theme, pictures, effects }) {
62
+ const th = theme || {};
63
+ const row = (k, v) => html`<div class="s-mod-kv"><span class="k">${k}</span><span class="v">${v}</span></div>`;
64
+ const pal = (mode) => {
65
+ const p = th.colors && th.colors[mode];
66
+ return p && Object.keys(p).length ? row(tn(mode === "dark" ? "modColorsDark" : "modColorsLight"), html`<span class="s-mod-sws">${Object.keys(p).map((k) => html`<span key=${k} class="s-mod-sw-item">${k} <${Swatch} color=${p[k]} /></span>`)}</span>`) : null;
67
+ };
68
+ const picOf = (id) => (pictures || []).find((p) => p.id === id);
69
+ const pic = (id, label) => {
70
+ const p = picOf(id);
71
+ return p ? row(label, html`<span class="s-mod-pic"><img src=${`/ui/mods/staged/${p.id}`} alt=${label} loading="lazy" /><span class="muted">${p.path} · ${kib(p.bytes)}</span></span>`) : null;
72
+ };
73
+ return html`<div class="s-mod-theme">
74
+ ${th.accent ? row(tn("modAccent"), html`<${Swatch} color=${th.accent} />`) : null}
75
+ ${pal("light")}${pal("dark")}
76
+ ${th.bubble ? row(tn("modBubble"), `${th.bubble.radius !== undefined ? `${th.bubble.radius}px` : ""}${th.bubble.radius !== undefined && th.bubble.border ? " · " : ""}${th.bubble.border || ""}`) : null}
77
+ ${th.font ? row(tn("modFont"), th.font) : null}
78
+ ${th.workingLabel ? row(tn("modWorkingLabel"), html`<span class="s-mod-text">${th.workingLabel}</span>`) : null}
79
+ ${th.background ? pic(th.background.image, tn("modBackground")) : null}
80
+ ${th.background ? row(tn("modBackgroundFit"), `${th.background.fit} · ${Math.round(th.background.opacity * 100)}%`) : null}
81
+ ${th.avatar ? pic(th.avatar, tn("modAvatar")) : null}
82
+ ${(effects || []).map((e, i) => html`<div key=${i}>${row(tn("modPlacesEffect"), html`<span><code>${e.effect}</code>${e.id ? html` <span class="muted">${e.id.slice(0, 8)}</span>` : null}${Object.keys(e.params || {}).length ? html` · <code>${JSON.stringify(e.params)}</code>` : null}</span>`)}</div>`)}
83
+ <details class="s-mod-raw"><summary>${tn("modAllFields")}</summary><pre class="s-mod-css-text">${JSON.stringify(th, null, 2)}</pre></details>
84
+ </div>`;
85
+ }
86
+
87
+ function PlanItem({ it, choice, setChoice, binds, fleet }) {
88
+ const tick = (label, on, set) => html`<label class="check s-mod-tick"><input type="checkbox" checked=${on} onChange=${(e) => set(e.target.checked)} /><span>${label}</span></label>`;
89
+ if (it.part === "theme") {
90
+ return html`<li class="s-mod-item">${tick(tn("modPartTheme"), choice.theme, (v) => setChoice({ ...choice, theme: v }))}
91
+ <div class="s-mod-detail"><${ThemeDetail} theme=${it.theme} pictures=${it.pictures} effects=${it.effects} /></div></li>`;
92
+ }
93
+ if (it.part === "identity") {
94
+ return html`<li class="s-mod-item">${tick(tn("modPartIdentity"), choice.identity, (v) => setChoice({ ...choice, identity: v }))}
95
+ <div class="s-mod-detail">${it.tagline ? html`<span class="s-mod-text">${it.tagline}</span>` : null}${it.bio ? html`<span class="s-mod-text">${it.bio}</span>` : null}</div></li>`;
96
+ }
97
+ if (it.part === "widget") {
98
+ const w = checkWidget(it.slot, { spec: it.spec, data: it.data });
99
+ return html`<li class="s-mod-item">${tick(tn(`modPartWidget_${it.slot}`), !!choice.widgets[it.slot], (v) => setChoice({ ...choice, widgets: { ...choice.widgets, [it.slot]: v } }))}
100
+ <div class="s-mod-detail s-mod-widget">${w ? html`<${AgentWidget} slot=${it.slot} widget=${w} binds=${it.slot === "sidebar" ? fleet : binds} />` : null}</div></li>`;
101
+ }
102
+ if (it.part === "effect") {
103
+ return html`<li class="s-mod-item">${tick(tn("modPartEffect", it.title), choice.effects, (v) => setChoice({ ...choice, effects: v }))}
104
+ <div class="s-mod-detail"><span>${kib(it.bytes)} · ${tn("modRegions", it.regions.join(", "))}</span>${it.wants.length ? html`<span>${tn("modWants", it.wants.join(", "))}</span>` : null}</div></li>`;
105
+ }
106
+ if (it.part === "ownerCss") {
107
+ return html`<li class="s-mod-item s-mod-css">${tick(tn("modPartCss"), choice.ownerCss, (v) => setChoice({ ...choice, ownerCss: v }))}
108
+ <p class="s-mod-warn">${tn("modCssWarn")}</p>
109
+ <pre class="s-mod-css-text">${it.text}</pre>
110
+ ${it.assets.length ? html`<p class="sub">${tn("modCssAssets", it.assets.length, kib(it.assets.reduce((n, a) => n + a.bytes, 0)))}</p>` : null}</li>`;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function Plan({ staged, onDone }) {
116
+ const app = useStore(appStore);
117
+ const plan = staged.plan;
118
+ const [choice, setChoice] = useState(() => defaultChoice(plan));
119
+ const [target, setTarget] = useState("");
120
+ const [busy, setBusy] = useState(false);
121
+ const [error, setError] = useState("");
122
+ const toInstance = choice.theme || choice.identity || !!choice.widgets.chat_header || !!choice.widgets.details;
123
+ const names = (app.instances || []).map((i) => i.name);
124
+ const install = async () => {
125
+ setBusy(true); setError("");
126
+ const r = await postJson("/ui/mods/commit", { stage: staged.stage, plan_hash: staged.plan_hash, include: choice, ...(toInstance ? { target } : {}) });
127
+ setBusy(false);
128
+ // The stage this plan shows is gone or no longer this plan (replaced on another device, expired): read again —
129
+ // the page then shows whatever is staged now, with its own, fresh choices (keyed by stage).
130
+ if (!r.ok) { setError(why(r.body)); if (r.status === 404 || (r.body && r.body.error === "plan_changed")) onDone(); return; }
131
+ if (r.body.css) { startOwnerCssTrial(r.body.css.trial, r.body.css.sha, r.body.css.previous); toast(tn("cssTrying")); }
132
+ toast(tn("modInstalled", plan.mod.title));
133
+ onDone();
134
+ };
135
+ const cancel = async () => {
136
+ const r = await postJson("/ui/mods/abort", { stage: staged.stage });
137
+ // Not this stage any more (another device staged a file meanwhile): say so, then show what is staged now.
138
+ if (!r.ok) toast(r.status === 404 ? tn("modStageGone") : why(r.body), false);
139
+ onDone();
140
+ };
141
+ const inst = (app.instances || []).find((i) => i.name === target) || null;
142
+ const binds = inst ? instanceBinds(inst, app.exec && app.exec[inst.name], app.awaiting && Object.prototype.hasOwnProperty.call(app.awaiting, inst.name) ? app.awaiting[inst.name] : null) : {};
143
+ const m = plan.mod;
144
+ return html`<div class="s-mod-plan" role="group" aria-label=${tn("modPlan")}>
145
+ <h4>${m.title} <span class="muted">${m.name} ${m.version}</span></h4>
146
+ ${m.author ? html`<p class="sub">${tn("modAuthor", m.author)}</p>` : null}
147
+ ${m.description ? html`<p class="sub s-mod-text">${m.description}</p>` : null}
148
+ <p class="sub">${tn("modSizeId", kib(m.bytes), m.id.slice(0, 8))}${m.installed ? ` · ${tn("modAlready")}` : ""}</p>
149
+ ${m.replaces ? html`<label class="check"><input type="checkbox" checked=${choice.replace} onChange=${(e) => setChoice({ ...choice, replace: e.target.checked })} /><span>${tn("modReplace", m.replaces.version)}</span></label>` : null}
150
+ <ul class="s-mod-items">${plan.items.map((it, i) => html`<${PlanItem} key=${i} it=${it} choice=${choice} setChoice=${setChoice} binds=${binds} fleet=${fleetBinds(app.instances, app.exec, app.awaiting)} />`)}</ul>
151
+ ${toInstance ? html`<label class="field"><span>${tn("modTarget")}</span>
152
+ <select value=${target} onChange=${(e) => setTarget(e.target.value)}><option value="">${tn("modTargetPick")}</option>${names.map((n) => html`<option key=${n} value=${n}>${n}</option>`)}</select></label>` : null}
153
+ ${toInstance && !target ? html`<p class="sub">${tn("modTargetNeeded")}</p>` : null}
154
+ ${error ? html`<p class="err s-mod-err" role="alert">${error}</p>` : null}
155
+ <div class="s-widget-actions">
156
+ <button type="button" class="btn btn-primary" disabled=${busy || (toInstance && !target) || (m.replaces && !choice.replace)} onClick=${install}><${Icon} name="check" size=${16} />${tn("modInstall")}</button>
157
+ <button type="button" class="btn" disabled=${busy} onClick=${cancel}>${tn("modCancel")}</button>
158
+ </div>
159
+ </div>`;
160
+ }
161
+
162
+ function ExportForm() {
163
+ const app = useStore(appStore);
164
+ const [f, setF] = useState({ instance: "", name: "", version: "1.0.0", title: "", theme: true, identity: true, header: true, details: true, sidebar: false, ownerCss: false });
165
+ const [error, setError] = useState("");
166
+ const set = (k) => (e) => setF({ ...f, [k]: e.target.type === "checkbox" ? e.target.checked : e.target.value });
167
+ const go = async () => {
168
+ setError("");
169
+ const body = { instance: f.instance || undefined, name: f.name, version: f.version, title: f.title,
170
+ include: { theme: f.theme, identity: f.identity, ownerCss: f.ownerCss, widgets: { chat_header: f.header, details: f.details, sidebar: f.sidebar } } };
171
+ let res;
172
+ try { res = await fetch("/ui/mods/export", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); }
173
+ catch { setError(tn("modFailed", "network")); return; }
174
+ if (!res.ok) { let b = null; try { b = await res.json(); } catch { /* none */ } setError(why(b)); return; }
175
+ download(await res.text(), `${f.name}-${f.version}.agendmod.json`);
176
+ // Effects that aren't this person's to carry stay out of the file (their own claims and shared ones travel).
177
+ const omitted = Number(res.headers && res.headers.get ? res.headers.get("X-AgEnD-Effects-Omitted") : 0);
178
+ if (omitted > 0) toast(tn("modExportOmitted", omitted), false);
179
+ };
180
+ const box = (k, label) => html`<label class="check"><input type="checkbox" checked=${f[k]} onChange=${set(k)} /><span>${label}</span></label>`;
181
+ return html`<details class="s-mod-export"><summary>${tn("modExport")}</summary>
182
+ <p class="sub">${tn("modExportLead")}</p>
183
+ <label class="field"><span>${tn("modExportInstance")}</span><select value=${f.instance} onChange=${set("instance")}><option value="">—</option>${(app.instances || []).map((i) => html`<option key=${i.name} value=${i.name}>${i.name}</option>`)}</select></label>
184
+ <div class="s-mod-boxes">${box("theme", tn("modPartTheme"))}${box("identity", tn("modPartIdentity"))}${box("header", tn("modPartWidget_chat_header"))}${box("details", tn("modPartWidget_details"))}${box("sidebar", tn("modPartWidget_sidebar"))}${box("ownerCss", tn("modPartCss"))}</div>
185
+ <label class="field"><span>${tn("modExportName")}</span><input value=${f.name} placeholder="my-look" onInput=${set("name")} /></label>
186
+ <label class="field"><span>${tn("modExportVersion")}</span><input value=${f.version} onInput=${set("version")} /></label>
187
+ <label class="field"><span>${tn("modExportTitle")}</span><input value=${f.title} onInput=${set("title")} /></label>
188
+ ${error ? html`<p class="err s-mod-err" role="alert">${error}</p>` : null}
189
+ <div class="s-widget-actions"><button type="button" class="btn" disabled=${!f.name || !f.title} onClick=${go}><${Icon} name="download" size=${16} />${tn("modExportGo")}</button></div>
190
+ </details>`;
191
+ }
192
+
193
+ export function ModsCard() {
194
+ const [state, setState] = useState(null);
195
+ const [busy, setBusy] = useState(false);
196
+ const [error, setError] = useState("");
197
+ const file = useRef(null);
198
+ // Only an answer of the expected shape is shown (#1889's lesson): anything else is "couldn't load", never a crash.
199
+ const load = async () => {
200
+ const r = await call("/ui/mods");
201
+ const b = r.ok && r.body && typeof r.body === "object" ? r.body : null;
202
+ const staged = b && b.staged && typeof b.staged === "object" && typeof b.staged.stage === "string" && b.staged.plan && Array.isArray(b.staged.plan.items) ? b.staged : null;
203
+ const proposals = b && Array.isArray(b.proposals) ? b.proposals.filter((p) => p && typeof p.instance === "string" && typeof p.title === "string") : [];
204
+ setState(b && Array.isArray(b.mods) ? { mods: b.mods.filter((m) => m && typeof m.id === "string"), staged, proposals } : { failed: true });
205
+ };
206
+ useEffect(() => { load(); }, []);
207
+ if (!state) return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3><p class="sub">…</p></section>`;
208
+ if (state.failed) return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3><p class="err">${tn("modsLoadFailed")}</p></section>`;
209
+ const stage = async () => {
210
+ const f = file.current && file.current.files && file.current.files[0];
211
+ if (!f) return;
212
+ setBusy(true); setError("");
213
+ const r = await call("/ui/mods/stage", { method: "POST", headers: { "Content-Type": "application/json" }, body: f });
214
+ setBusy(false);
215
+ if (file.current) file.current.value = "";
216
+ if (!r.ok) { setError(why(r.body)); return; }
217
+ setState({ ...state, staged: r.body });
218
+ };
219
+ const review = async (p) => {
220
+ const r = await postJson("/ui/mods/proposal/stage", { instance: p.instance });
221
+ if (!r.ok) { setError(why(r.body)); return; }
222
+ setState({ ...state, staged: r.body });
223
+ };
224
+ const dismiss = async (p) => {
225
+ const r = await postJson("/ui/mods/proposal/dismiss", { instance: p.instance });
226
+ if (!r.ok) { toast(why(r.body), false); return; }
227
+ load();
228
+ };
229
+ const uninstall = async (m) => {
230
+ if (!(await confirmDialog({ message: tn("modUninstallConfirm", m.title), confirmLabel: tn("modUninstall"), danger: true }))) return;
231
+ const r = await postJson("/ui/mods/uninstall", { id: m.id });
232
+ if (!r.ok) { toast(why(r.body), false); return; }
233
+ toast(tn("modUninstalled", m.title));
234
+ load();
235
+ };
236
+ const fetchFile = async (m) => {
237
+ const r = await fetch(`/ui/mods/file/${m.id}`, { credentials: "same-origin" }).catch(() => null);
238
+ if (!r || !r.ok) { toast(tn("modFailed", r ? r.status : "network"), false); return; }
239
+ download(await r.text(), `${m.name}-${m.version}.agendmod.json`);
240
+ };
241
+ return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3>
242
+ <p class="sub">${tn("modsLead")}</p>
243
+ ${state.staged ? html`<${Plan} key=${state.staged.stage} staged=${state.staged} onDone=${load} />` : html`<div class="s-mod-import">
244
+ <label class="btn"><${Icon} name="attach" size=${16} /><span>${tn("modChoose")}</span>
245
+ <input ref=${file} type="file" accept=".json,application/json" class="sr-only" disabled=${busy} onChange=${stage} /></label></div>`}
246
+ ${error ? html`<p class="err s-mod-err" role="alert">${error}</p>` : null}
247
+ ${!state.staged && (state.proposals || []).length ? html`<div class="s-mod-proposals"><h4>${tn("modProposals")}</h4><ul class="s-mod-list">${state.proposals.map((p) => html`<li key=${p.instance}>
248
+ <span class="grow">${tn("modProposedBy", p.instance)} <strong>${p.title}</strong> <span class="muted">${p.name} ${p.version}</span></span>
249
+ <button type="button" class="btn btn-sm" onClick=${() => review(p)}>${tn("modReview")}</button>
250
+ <button type="button" class="btn btn-sm" onClick=${() => dismiss(p)}>${tn("modDismiss")}</button></li>`)}</ul></div>` : null}
251
+ ${state.mods.length ? html`<ul class="s-mod-list">${state.mods.map((m) => html`<li key=${m.id}>
252
+ <span class="grow"><strong>${m.title}</strong> <span class="muted">${m.name} ${m.version}${m.target ? ` · ${tn("modAppliedTo", m.target)}` : ""}</span></span>
253
+ <button type="button" class="btn btn-sm" onClick=${() => fetchFile(m)}>${tn("modDownload")}</button>
254
+ <button type="button" class="btn btn-sm danger" onClick=${() => uninstall(m)}>${tn("modUninstall")}</button></li>`)}</ul>` : html`<p class="sub">${tn("modsNone")}</p>`}
255
+ <${ExportForm} />
256
+ </section>`;
257
+ }
@@ -0,0 +1,80 @@
1
+ // #1629: authoritative selectors and local slot controls; the app owns all playback and resource reads.
2
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
3
+ import { register, t } from "/assets/app-i18n.js";
4
+ import { useStore } from "/assets/app-store.js";
5
+ import { OWNER_EFFECT_PREF_KEYS } from "/assets/app-prefs.js";
6
+ import { getAppEffects } from "./app-effects.js";
7
+ import { ownerEffectsStore, watchOwnerEffects, refreshOwnerEffects, saveOwnerEffects } from "/assets/owner-effect-prefs.js";
8
+ register("ownerEffects", {
9
+ en: {
10
+ title: "Your effect choices", operatorTitle: "Host-wide effect choices", person: "These choices follow this platform sign-in. Discord and Telegram accounts stay separate.",
11
+ operator: "Host-wide choices: everyone signed in as the host operator shares these slots.",
12
+ sidebar: "Sidebar", header: "Header", page: "Page", box: "Box", cursor: "Cursor", none: "None", savedChoice: "Saved choice",
13
+ unknown: "Choices are unavailable. Reload to try again.", changed: "This page can no longer access these choices. Reload and sign in again.",
14
+ unavailable: "No longer available", clear: "Clear", reload: "Reload choices", saved: "Saved.", failed: "Couldn't save. Reload choices and try again.",
15
+ savedRefresh: "Saved, but the choices couldn't be refreshed. Reload to check.", readOnly: "Sign in as an admin to change these choices.",
16
+ playback: "Playback requires Effects and preview consent. The cursor follows the same consent and falls back to stock cursors when unavailable.",
17
+ pause: "Pause", resume: "Resume", running: "Running", cursorFailed: "The cursor could not be applied here. Clear or reload the choice to try again.", starting: "Starting", queued: "Waiting for a slot", paused: "Paused", off: "Playback is off or unavailable here.", budget: "Paused to protect page responsiveness. Resume keeps the three-pauses safety limit.", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.",
18
+ },
19
+ "zh-TW": {
20
+ title: "你的效果選擇", operatorTitle: "主機共用效果選擇", person: "選擇會跟隨這個平台的登入身分;Discord 與 Telegram 帳號各自獨立。",
21
+ operator: "主機共用選擇:所有以主機操作者身分登入的人,都會共用這些欄位。",
22
+ sidebar: "側邊欄", header: "頁首", page: "頁面", box: "區塊", cursor: "游標", none: "無", savedChoice: "已儲存的選擇",
23
+ unknown: "目前無法取得選擇,請重新載入再試。", changed: "這個頁面已無法存取選擇,請重新載入並登入。",
24
+ unavailable: "已無法使用", clear: "清除", reload: "重新載入選擇", saved: "已儲存。", failed: "無法儲存,請重新載入選擇再試。",
25
+ savedRefresh: "已儲存,但無法更新畫面;請重新載入確認。", readOnly: "請以管理員身分登入才能修改選擇。",
26
+ playback: "開啟效果並允許預覽後才會播放;游標也遵循相同的同意設定;無法使用時會還原一般游標。",
27
+ pause: "暫停", resume: "恢復", running: "播放中", cursorFailed: "這裡無法套用游標。可清除或重新載入選擇後再試。", starting: "啟動中", queued: "等待空位", paused: "已暫停", off: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。",
28
+ },
29
+ });
30
+ const text = key => t(`ownerEffects.${key}`);
31
+ export function ownerEffectFits(manifest, key) {
32
+ if (!manifest || typeof manifest !== "object" || !OWNER_EFFECT_PREF_KEYS.includes(key)) return false;
33
+ if (key === "fx.cursor") return !!manifest.cursor;
34
+ const region = key.slice(3);
35
+ return Array.isArray(manifest.regions) && manifest.regions.includes(region) && (region !== "box" || !!manifest.box);
36
+ }
37
+ export function OwnerEffects({ catalog, busy = false }) {
38
+ const state = useStore(ownerEffectsStore), [message, setMessage] = useState(null), life = useRef({ alive: false, seq: 0 }), [runtime, setRuntime] = useState(null);
39
+ useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
40
+ useEffect(() => {
41
+ let live = true; const entry = getAppEffects();
42
+ const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.previewReady && gates.optIn && !gates.safe && !gates.publicLink && !gates.reducedMotion && !gates.saveData }); };
43
+ const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
44
+ return () => { live = false; off?.(); end?.(); };
45
+ }, []);
46
+ const reload = () => { getAppEffects()?.api?.changedOwner?.(); return refreshOwnerEffects(); };
47
+ // Library reloads/own share changes can alter availability without changing the preference revision.
48
+ useEffect(() => { if (catalog) reload(); }, [catalog]);
49
+ if (state.scope === "none") return null;
50
+ const disabled = busy || state.busy || state.status !== "known" || state.revoked || !catalog?.canWrite;
51
+ const save = async (key, id) => {
52
+ if (disabled || !life.current.alive) return;
53
+ if (id !== null && !(catalog.effects || []).some(e => e.id === id && ownerEffectFits(e.manifest, key))) return;
54
+ const seq = ++life.current.seq; setMessage(null);
55
+ const result = await saveOwnerEffects({ [key]: id });
56
+ if (!life.current.alive || seq !== life.current.seq) return;
57
+ if (result.ok || result.saved) getAppEffects()?.api?.changedOwner?.();
58
+ setMessage(text(result.ok ? "saved" : result.saved ? "savedRefresh" : "failed"));
59
+ };
60
+ return html`<section class="s-fx-owner" aria-label=${text(state.scope === "operator" ? "operatorTitle" : "title")}><h4>${text(state.scope === "operator" ? "operatorTitle" : "title")}</h4><p class="sub">${text(state.scope === "operator" ? "operator" : "person")}</p>
61
+ <p class="sub">${text("playback")}</p>${catalog && !catalog.canWrite ? html`<p class="sub">${text("readOnly")}</p>` : null}
62
+ <button class="btn" type="button" disabled=${busy || state.busy || state.revoked} onClick=${reload}>${text("reload")}</button>
63
+ ${state.status !== "known" ? html`<p class="sub" role="status">${text(state.revoked ? "changed" : "unknown")}</p>` : OWNER_EFFECT_PREF_KEYS.map(key => {
64
+ const row = state.slots[key], options = (catalog?.effects || []).filter(e => ownerEffectFits(e.manifest, key));
65
+ const absent = !!row.id && !options.some(e => e.id === row.id);
66
+ const play = runtime?.snapshot?.rows?.find(r => r.slot === key && r.id === row.id);
67
+ const unavailable = row.status === "unavailable" || runtime?.snapshot?.slots?.[key]?.id === row.id && runtime.snapshot.slots[key].status === "unavailable";
68
+ const phase = !runtime?.available ? "off" : play?.reason === "paused" || play?.reason === "host_budget" ? "paused" : play?.phase === "running" ? "running" : play?.phase ? "starting" : play?.verdict === "queued" ? "queued" : "off";
69
+ return html`<div class="s-fx-owner-row" key=${key}><label>${text(key.slice(3))}<select class="s-fx-target" data-owner-slot=${key} disabled=${disabled} value=${row.id || ""} onChange=${e => save(key, e.target.value || null)}>
70
+ <option value="">${text("none")}</option>${absent ? html`<option value=${row.id} disabled>${text(row.status === "unavailable" ? "unavailable" : "savedChoice")}</option>` : null}
71
+ ${options.map(e => html`<option key=${e.id} value=${e.id}>${e.manifest.title}</option>`)}</select></label>
72
+ ${unavailable ? html`<span class="sub" role="status">${text("unavailable")}</span>` : null}
73
+ ${key === "fx.cursor" && row.status === "available" && row.id && !unavailable ? html`<span class="sub" role="status" data-owner-runtime=${key}>${text(!runtime?.available ? "off" : runtime.cursor?.id === row.id && runtime.cursor.status === "running" ? "running" : runtime.cursor?.id === row.id && runtime.cursor.status === "loading" ? "starting" : runtime.cursor?.id === row.id && runtime.cursor.status === "unavailable" ? "cursorFailed" : "off")}</span>` : null}
74
+ ${key !== "fx.cursor" && row.status === "available" && row.id && !unavailable ? html`<span class="sub" role="status" data-owner-runtime=${key}>${text(phase)}</span>
75
+ ${play?.reason === "host_budget" ? html`<span class="sub">${text("budget")}</span>` : null}
76
+ <button class="btn" type="button" data-owner-pause=${key} disabled=${!runtime?.available || !play} onClick=${() => runtime?.entry?.api?.pauseOwner?.(key)}>${text("pause")}</button>
77
+ <button class="btn" type="button" data-owner-resume=${key} disabled=${!runtime?.available || !play} onClick=${() => { if (!runtime?.entry?.api?.resumeOwner?.(key)) setMessage(text("resumeFailed")); }}>${text("resume")}</button>` : null}
78
+ ${row.id ? html`<button class="btn" type="button" data-owner-clear=${key} disabled=${disabled} onClick=${() => save(key, null)}>${text("clear")}</button>` : null}</div>`;
79
+ })}${message ? html`<p class="sub" role="status">${message}</p>` : null}</section>`;
80
+ }