dsh-background-by-model 0.2.4 → 0.3.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.md +236 -142
- package/README.zh.md +236 -142
- package/example_img/rule-editor.webp +0 -0
- package/example_img/wallpaper-deepseek.webp +0 -0
- package/example_img/wallpaper-kimi.webp +0 -0
- package/lib/client.js +2277 -3085
- package/lib/client.js.map +1 -1
- package/lib/index.js +203 -361
- package/package.json +4 -3
package/lib/index.js
CHANGED
|
@@ -1,51 +1,42 @@
|
|
|
1
|
-
import { access, mkdir, readFile, rename, rm,
|
|
2
|
-
import { createReadStream, createWriteStream } from "node:fs";
|
|
1
|
+
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
2
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
4
3
|
//#region src/index.ts
|
|
5
4
|
/**
|
|
6
|
-
* Node half of dsh-background-by-model: file-backed
|
|
5
|
+
* Node half of dsh-background-by-model: file-backed rule persistence.
|
|
7
6
|
*
|
|
8
7
|
* Owns the `~/.dsh/.dsh-background-by-model-data/` store and exposes a small RPC
|
|
9
8
|
* surface on the dedicated `/dsh-background-by-model` channel (never the shared
|
|
10
9
|
* `/api`, so slash commands stay intact).
|
|
11
10
|
*
|
|
12
|
-
* theme-config.json settings
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* theme-config.json the ordered rule list + the global interface settings
|
|
12
|
+
* modelbg-<slot> one background image per rule (raw bytes, no extension;
|
|
13
|
+
* the MIME is sniffed from the magic bytes when served)
|
|
14
|
+
*
|
|
15
|
+
* Images travel as data URLs over the RPC channel — there is no separate HTTP
|
|
16
|
+
* image route. A legacy single-wallpaper / video-background store is migrated
|
|
17
|
+
* to rule 1 on first read.
|
|
18
18
|
*/
|
|
19
19
|
const name = "dsh-background-by-model";
|
|
20
20
|
const inject = ["connection", "webServer"];
|
|
21
21
|
const DATA_DIR = ".dsh-background-by-model-data";
|
|
22
22
|
const CONFIG_FILE = "theme-config.json";
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const UPLOAD_TMP = "wallpaper.upload.tmp";
|
|
27
|
-
const WALLPAPER_FETCH_MAX = 26214400;
|
|
28
|
-
const WALLPAPER_FETCH_TIMEOUT = 2e4;
|
|
29
|
-
function videoFileName(mime) {
|
|
30
|
-
switch (mime) {
|
|
31
|
-
case "video/mp4": return "wallpaper.mp4";
|
|
32
|
-
case "video/webm": return "wallpaper.webm";
|
|
33
|
-
case "video/ogg": return "wallpaper.ogv";
|
|
34
|
-
case "video/quicktime": return "wallpaper.mov";
|
|
35
|
-
case "video/x-matroska": return "wallpaper.mkv";
|
|
36
|
-
default: return "wallpaper.video";
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
const VIDEO_CANDIDATES = [
|
|
23
|
+
const IMAGE_PREFIX = "modelbg-";
|
|
24
|
+
const LEGACY_WALLPAPER = "wallpaper.jpg";
|
|
25
|
+
const LEGACY_FILES = [
|
|
40
26
|
"wallpaper.mp4",
|
|
41
27
|
"wallpaper.webm",
|
|
42
28
|
"wallpaper.ogv",
|
|
43
29
|
"wallpaper.mov",
|
|
44
30
|
"wallpaper.mkv",
|
|
45
|
-
"wallpaper.video"
|
|
31
|
+
"wallpaper.video",
|
|
32
|
+
"wallpaper.upload.tmp"
|
|
46
33
|
];
|
|
34
|
+
/** Slot names reach the filesystem, so they are strictly whitelisted. */
|
|
35
|
+
const SLOT_RE = /^[A-Za-z0-9_-]{1,32}$/;
|
|
36
|
+
const WALLPAPER_FETCH_MAX = 26214400;
|
|
37
|
+
const WALLPAPER_FETCH_TIMEOUT = 2e4;
|
|
47
38
|
const DEFAULT_CONFIG = {
|
|
48
|
-
|
|
39
|
+
rules: [],
|
|
49
40
|
opacities: {
|
|
50
41
|
bg: .85,
|
|
51
42
|
sidebar: .93,
|
|
@@ -62,34 +53,13 @@ const DEFAULT_CONFIG = {
|
|
|
62
53
|
input: 0
|
|
63
54
|
},
|
|
64
55
|
settingsOpacity: 1,
|
|
65
|
-
wallpaperOpacity: 1,
|
|
66
|
-
blur: 0,
|
|
67
|
-
bgState: {
|
|
68
|
-
zoom: 1,
|
|
69
|
-
x: 0,
|
|
70
|
-
y: 0,
|
|
71
|
-
iw: 0,
|
|
72
|
-
ih: 0
|
|
73
|
-
},
|
|
74
|
-
videoBgState: {
|
|
75
|
-
zoom: 1,
|
|
76
|
-
x: 0,
|
|
77
|
-
y: 0,
|
|
78
|
-
iw: 0,
|
|
79
|
-
ih: 0
|
|
80
|
-
},
|
|
81
|
-
backgroundType: "image",
|
|
82
|
-
bgMode: "fit",
|
|
83
|
-
videoMime: null,
|
|
84
|
-
generatedBg: null,
|
|
85
|
-
regenerateOnReload: false,
|
|
86
56
|
chatTextOpacity: 0,
|
|
87
57
|
trajectoryOpacity: 1
|
|
88
58
|
};
|
|
89
59
|
const dataDir = () => dshHomePath(DATA_DIR);
|
|
90
60
|
const configPath = () => dshHomePath(DATA_DIR, CONFIG_FILE);
|
|
91
|
-
const
|
|
92
|
-
const
|
|
61
|
+
const legacyWallpaperPath = () => dshHomePath(DATA_DIR, LEGACY_WALLPAPER);
|
|
62
|
+
const imagePath = (slot) => dshHomePath(DATA_DIR, `${IMAGE_PREFIX}${slot}`);
|
|
93
63
|
const exists = async (p) => {
|
|
94
64
|
try {
|
|
95
65
|
await access(p);
|
|
@@ -98,72 +68,55 @@ const exists = async (p) => {
|
|
|
98
68
|
return false;
|
|
99
69
|
}
|
|
100
70
|
};
|
|
101
|
-
/** Locate the stored video: the recorded MIME decides the expected name; a
|
|
102
|
-
* legacy extensionless wallpaper.video is renamed on first access. */
|
|
103
|
-
async function findVideoFile() {
|
|
104
|
-
const cfg = await readConfig();
|
|
105
|
-
const expected = videoPathFor(cfg.videoMime);
|
|
106
|
-
if (await exists(expected)) return {
|
|
107
|
-
path: expected,
|
|
108
|
-
mime: cfg.videoMime
|
|
109
|
-
};
|
|
110
|
-
for (const name of VIDEO_CANDIDATES) {
|
|
111
|
-
const p = dshHomePath(DATA_DIR, name);
|
|
112
|
-
if (!await exists(p)) continue;
|
|
113
|
-
if (cfg.videoMime !== null && name !== videoFileName(cfg.videoMime)) try {
|
|
114
|
-
await rename(p, expected);
|
|
115
|
-
return {
|
|
116
|
-
path: expected,
|
|
117
|
-
mime: cfg.videoMime
|
|
118
|
-
};
|
|
119
|
-
} catch {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
return {
|
|
123
|
-
path: p,
|
|
124
|
-
mime: cfg.videoMime
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
71
|
function clamp(n, lo, hi, def) {
|
|
130
72
|
return typeof n === "number" && isFinite(n) ? Math.min(hi, Math.max(lo, n)) : def;
|
|
131
73
|
}
|
|
74
|
+
const clamp01 = (n, def) => clamp(n, 0, 1, def);
|
|
132
75
|
function normalizeBgState(s) {
|
|
76
|
+
const v = s ?? {};
|
|
133
77
|
return {
|
|
134
|
-
zoom: clamp(
|
|
135
|
-
x: typeof
|
|
136
|
-
y: typeof
|
|
137
|
-
iw: typeof
|
|
138
|
-
ih: typeof
|
|
78
|
+
zoom: clamp(v.zoom, .1, 10, 1),
|
|
79
|
+
x: typeof v.x === "number" && isFinite(v.x) ? v.x : 0,
|
|
80
|
+
y: typeof v.y === "number" && isFinite(v.y) ? v.y : 0,
|
|
81
|
+
iw: typeof v.iw === "number" && v.iw > 0 ? v.iw : 0,
|
|
82
|
+
ih: typeof v.ih === "number" && v.ih > 0 ? v.ih : 0
|
|
139
83
|
};
|
|
140
84
|
}
|
|
141
|
-
/** Coerce
|
|
142
|
-
function
|
|
85
|
+
/** Coerce one persisted rule, or null when it lacks a usable id/slot. */
|
|
86
|
+
function normalizeRule(raw) {
|
|
143
87
|
const r = raw ?? {};
|
|
88
|
+
const id = typeof r.id === "string" && r.id !== "" ? r.id : null;
|
|
89
|
+
const slot = typeof r.slot === "string" && SLOT_RE.test(r.slot) ? r.slot : null;
|
|
90
|
+
if (id === null || slot === null) return null;
|
|
144
91
|
const c = r.color;
|
|
145
|
-
const color = Array.isArray(c) && c.length === 3 && c.every((
|
|
92
|
+
const color = Array.isArray(c) && c.length === 3 && c.every((n) => typeof n === "number" && isFinite(n)) ? [
|
|
146
93
|
clamp(c[0], 0, 360, 220),
|
|
147
94
|
clamp(c[1], 0, 1, .55),
|
|
148
95
|
clamp(c[2], 0, 1, .25)
|
|
149
96
|
] : null;
|
|
150
|
-
const
|
|
151
|
-
"image",
|
|
152
|
-
"video",
|
|
153
|
-
"mesh",
|
|
154
|
-
"shader",
|
|
155
|
-
"pattern"
|
|
156
|
-
].includes(r.backgroundType) ? r.backgroundType : DEFAULT_CONFIG.backgroundType;
|
|
157
|
-
const bgMode = [
|
|
97
|
+
const mode = [
|
|
158
98
|
"fit",
|
|
159
99
|
"fill",
|
|
160
100
|
"stretch",
|
|
161
101
|
"tile",
|
|
162
102
|
"center"
|
|
163
|
-
].includes(r.bgMode) ? r.bgMode :
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
103
|
+
].includes(r.bgMode) ? r.bgMode : "fit";
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
slot,
|
|
107
|
+
match: typeof r.match === "string" ? r.match : "",
|
|
108
|
+
enabled: r.enabled !== false,
|
|
109
|
+
color,
|
|
110
|
+
bgMode: mode,
|
|
111
|
+
wallpaperOpacity: clamp01(r.wallpaperOpacity, 1),
|
|
112
|
+
blur: clamp(r.blur, 0, 60, 0),
|
|
113
|
+
bgState: normalizeBgState(r.bgState)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
|
|
117
|
+
function normalizeConfig(raw) {
|
|
118
|
+
const r = raw ?? {};
|
|
119
|
+
const rules = Array.isArray(r.rules) ? r.rules.map(normalizeRule).filter((x) => x !== null) : [];
|
|
167
120
|
const ops = r.opacities ?? {};
|
|
168
121
|
const bl = r.blurs ?? {};
|
|
169
122
|
const blurs = {};
|
|
@@ -177,58 +130,69 @@ function normalizeConfig(raw) {
|
|
|
177
130
|
"input"
|
|
178
131
|
]) blurs[k] = clamp(bl[k], 0, 60, DEFAULT_CONFIG.blurs[k]);
|
|
179
132
|
return {
|
|
180
|
-
|
|
133
|
+
rules,
|
|
181
134
|
opacities: {
|
|
182
|
-
bg:
|
|
183
|
-
sidebar:
|
|
184
|
-
card:
|
|
185
|
-
input:
|
|
135
|
+
bg: clamp01(ops.bg, DEFAULT_CONFIG.opacities.bg),
|
|
136
|
+
sidebar: clamp01(ops.sidebar, DEFAULT_CONFIG.opacities.sidebar),
|
|
137
|
+
card: clamp01(ops.card, DEFAULT_CONFIG.opacities.card),
|
|
138
|
+
input: clamp01(ops.input, DEFAULT_CONFIG.opacities.input)
|
|
186
139
|
},
|
|
187
140
|
blurs,
|
|
188
|
-
settingsOpacity:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
bgState: normalizeBgState(r.bgState ?? {}),
|
|
192
|
-
videoBgState: normalizeBgState(r.videoBgState ?? {}),
|
|
193
|
-
backgroundType: bgType,
|
|
194
|
-
bgMode,
|
|
195
|
-
videoMime: typeof r.videoMime === "string" ? r.videoMime : null,
|
|
196
|
-
generatedBg,
|
|
197
|
-
regenerateOnReload: typeof r.regenerateOnReload === "boolean" ? r.regenerateOnReload : DEFAULT_CONFIG.regenerateOnReload,
|
|
198
|
-
chatTextOpacity: clamp(r.chatTextOpacity, 0, 1, DEFAULT_CONFIG.chatTextOpacity),
|
|
199
|
-
trajectoryOpacity: clamp(r.trajectoryOpacity, 0, 1, DEFAULT_CONFIG.trajectoryOpacity)
|
|
141
|
+
settingsOpacity: clamp01(r.settingsOpacity, DEFAULT_CONFIG.settingsOpacity),
|
|
142
|
+
chatTextOpacity: clamp01(r.chatTextOpacity, DEFAULT_CONFIG.chatTextOpacity),
|
|
143
|
+
trajectoryOpacity: clamp01(r.trajectoryOpacity, DEFAULT_CONFIG.trajectoryOpacity)
|
|
200
144
|
};
|
|
201
145
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
146
|
+
/**
|
|
147
|
+
* One-shot migration of the pre-0.3 store.
|
|
148
|
+
*
|
|
149
|
+
* Anything without a `rules` array is an old config: its single wallpaper
|
|
150
|
+
* becomes rule 1 (`m1`, empty match = pure fallback) carrying the old color,
|
|
151
|
+
* layout mode, opacity, blur and framing, and the remaining legacy files
|
|
152
|
+
* (background video, upload temp) are removed. Idempotent: the rewritten config
|
|
153
|
+
* carries `rules`, so the next read short-circuits.
|
|
154
|
+
*/
|
|
155
|
+
async function migrateLegacy(raw) {
|
|
156
|
+
const r = raw ?? {};
|
|
157
|
+
if (Array.isArray(r.rules)) return r;
|
|
158
|
+
const hasWallpaper = await exists(legacyWallpaperPath());
|
|
159
|
+
if (Object.keys(r).length === 0 && !hasWallpaper) return { rules: [] };
|
|
160
|
+
if (hasWallpaper) {
|
|
161
|
+
const target = imagePath("m1");
|
|
162
|
+
try {
|
|
163
|
+
await rm(target, { force: true });
|
|
164
|
+
await rename(legacyWallpaperPath(), target);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.warn("dsh-background-by-model: could not adopt the legacy wallpaper", e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const f of LEGACY_FILES) try {
|
|
170
|
+
await rm(dshHomePath(DATA_DIR, f), { force: true });
|
|
171
|
+
} catch {}
|
|
172
|
+
const next = {
|
|
173
|
+
rules: [{
|
|
174
|
+
id: "m1",
|
|
175
|
+
slot: "m1",
|
|
176
|
+
match: "",
|
|
177
|
+
enabled: true,
|
|
178
|
+
color: Array.isArray(r.color) ? r.color : null,
|
|
179
|
+
bgMode: typeof r.bgMode === "string" ? r.bgMode : "fit",
|
|
180
|
+
wallpaperOpacity: typeof r.wallpaperOpacity === "number" ? r.wallpaperOpacity : 1,
|
|
181
|
+
blur: typeof r.blur === "number" ? r.blur : 0,
|
|
182
|
+
bgState: r.bgState ?? {}
|
|
183
|
+
}],
|
|
184
|
+
opacities: r.opacities,
|
|
185
|
+
blurs: r.blurs,
|
|
186
|
+
settingsOpacity: r.settingsOpacity,
|
|
187
|
+
chatTextOpacity: r.chatTextOpacity,
|
|
188
|
+
trajectoryOpacity: r.trajectoryOpacity
|
|
230
189
|
};
|
|
231
|
-
|
|
190
|
+
try {
|
|
191
|
+
await writeFile(configPath(), JSON.stringify(next, null, 2), "utf8");
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.warn("dsh-background-by-model: could not rewrite the migrated config", e);
|
|
194
|
+
}
|
|
195
|
+
return next;
|
|
232
196
|
}
|
|
233
197
|
async function ensureDir() {
|
|
234
198
|
try {
|
|
@@ -239,12 +203,13 @@ async function ensureDir() {
|
|
|
239
203
|
}
|
|
240
204
|
async function readConfig() {
|
|
241
205
|
await ensureDir();
|
|
206
|
+
let raw = {};
|
|
242
207
|
try {
|
|
243
|
-
|
|
244
|
-
return normalizeConfig(JSON.parse(raw));
|
|
208
|
+
raw = JSON.parse(await readFile(configPath(), "utf8"));
|
|
245
209
|
} catch {
|
|
246
|
-
|
|
210
|
+
raw = {};
|
|
247
211
|
}
|
|
212
|
+
return normalizeConfig(await migrateLegacy(raw));
|
|
248
213
|
}
|
|
249
214
|
async function writeConfig(config) {
|
|
250
215
|
await ensureDir();
|
|
@@ -256,61 +221,72 @@ async function writeConfig(config) {
|
|
|
256
221
|
return false;
|
|
257
222
|
}
|
|
258
223
|
}
|
|
259
|
-
|
|
224
|
+
/** Sniff an image's MIME from its leading magic bytes (defaults to JPEG). */
|
|
225
|
+
function sniffImageMime(buf) {
|
|
226
|
+
if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
|
|
227
|
+
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
|
|
228
|
+
if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
|
|
229
|
+
if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) return "image/webp";
|
|
230
|
+
if (buf.length >= 2 && buf[0] === 66 && buf[1] === 77) return "image/bmp";
|
|
231
|
+
return "image/jpeg";
|
|
232
|
+
}
|
|
233
|
+
/** Read one rule image as a data URL, or null when the slot is empty. */
|
|
234
|
+
async function readImage(slot) {
|
|
260
235
|
try {
|
|
261
|
-
|
|
236
|
+
const buf = await readFile(imagePath(slot));
|
|
237
|
+
return `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
|
|
262
238
|
} catch {
|
|
263
239
|
return null;
|
|
264
240
|
}
|
|
265
241
|
}
|
|
266
|
-
/** Persist
|
|
267
|
-
async function
|
|
242
|
+
/** Persist one rule image (null removes it); false keeps the previous file. */
|
|
243
|
+
async function writeImage(slot, dataUrl) {
|
|
268
244
|
await ensureDir();
|
|
269
245
|
try {
|
|
270
246
|
if (dataUrl === null) {
|
|
271
|
-
await rm(
|
|
247
|
+
await rm(imagePath(slot), { force: true });
|
|
272
248
|
return true;
|
|
273
249
|
}
|
|
274
250
|
const m = /^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
275
251
|
if (!m) return false;
|
|
276
|
-
await writeFile(
|
|
252
|
+
await writeFile(imagePath(slot), Buffer.from(m[1], "base64"));
|
|
277
253
|
return true;
|
|
278
254
|
} catch (e) {
|
|
279
|
-
console.error(`dsh-background-by-model: failed to write "${
|
|
255
|
+
console.error(`dsh-background-by-model: failed to write the image for slot "${slot}"`, e);
|
|
280
256
|
return false;
|
|
281
257
|
}
|
|
282
258
|
}
|
|
283
|
-
/**
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
259
|
+
/** Every image slot currently stored on disk. */
|
|
260
|
+
async function listSlots() {
|
|
261
|
+
try {
|
|
262
|
+
return (await readdir(dataDir())).filter((name) => name.startsWith(IMAGE_PREFIX)).map((name) => name.slice(8)).filter((slot) => SLOT_RE.test(slot));
|
|
263
|
+
} catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
290
266
|
}
|
|
291
|
-
/**
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
|
|
295
|
-
async function
|
|
267
|
+
/**
|
|
268
|
+
* Download an image from a network URL into one slot (replacing its bytes).
|
|
269
|
+
* Returns { ok, dataUrl?, error? }; never throws.
|
|
270
|
+
*/
|
|
271
|
+
async function fetchImageUrl(slot, url) {
|
|
296
272
|
if (url === null) {
|
|
297
|
-
const ok = await
|
|
273
|
+
const ok = await writeImage(slot, null);
|
|
298
274
|
return {
|
|
299
275
|
ok,
|
|
300
276
|
dataUrl: null,
|
|
301
277
|
error: ok ? void 0 : "remove failed"
|
|
302
278
|
};
|
|
303
279
|
}
|
|
304
|
-
let
|
|
280
|
+
let parsed;
|
|
305
281
|
try {
|
|
306
|
-
|
|
282
|
+
parsed = new URL(url);
|
|
307
283
|
} catch {
|
|
308
284
|
return {
|
|
309
285
|
ok: false,
|
|
310
286
|
error: "invalid url"
|
|
311
287
|
};
|
|
312
288
|
}
|
|
313
|
-
if (
|
|
289
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
|
|
314
290
|
ok: false,
|
|
315
291
|
error: "unsupported scheme"
|
|
316
292
|
};
|
|
@@ -360,7 +336,7 @@ async function writeWallpaperFromUrl(url) {
|
|
|
360
336
|
};
|
|
361
337
|
}
|
|
362
338
|
const dataUrl = `data:${sniffImageMime(buf)};base64,${buf.toString("base64")}`;
|
|
363
|
-
return await
|
|
339
|
+
return await writeImage(slot, dataUrl) ? {
|
|
364
340
|
ok: true,
|
|
365
341
|
dataUrl
|
|
366
342
|
} : {
|
|
@@ -368,206 +344,82 @@ async function writeWallpaperFromUrl(url) {
|
|
|
368
344
|
error: "write failed"
|
|
369
345
|
};
|
|
370
346
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
/** Persist a video from a data URL (null removes every variant); only used
|
|
375
|
-
* for removal and small legacy/import payloads. */
|
|
376
|
-
async function writeVideo(dataUrl) {
|
|
377
|
-
await ensureDir();
|
|
378
|
-
try {
|
|
379
|
-
if (dataUrl === null) {
|
|
380
|
-
for (const name of VIDEO_CANDIDATES) await rm(dshHomePath(DATA_DIR, name), { force: true });
|
|
381
|
-
return true;
|
|
382
|
-
}
|
|
383
|
-
const m = /^data:(video\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
384
|
-
if (!m) return false;
|
|
385
|
-
const target = videoPathFor(m[1]);
|
|
386
|
-
for (const name of VIDEO_CANDIDATES) {
|
|
387
|
-
const p = dshHomePath(DATA_DIR, name);
|
|
388
|
-
if (p !== target) await rm(p, { force: true });
|
|
389
|
-
}
|
|
390
|
-
await writeFile(target, Buffer.from(m[2], "base64"));
|
|
391
|
-
return true;
|
|
392
|
-
} catch (e) {
|
|
393
|
-
console.error("dsh-background-by-model: failed to write the background video", e);
|
|
394
|
-
return false;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
/** Stream the stored video: correct MIME, no caching, Range answers so the
|
|
398
|
-
* browser can seek (snapshot capture does). */
|
|
399
|
-
async function serveVideo(req, res) {
|
|
400
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
401
|
-
res.writeHead(405, { "Content-Type": "application/json" });
|
|
402
|
-
res.end(JSON.stringify({
|
|
403
|
-
ok: false,
|
|
404
|
-
error: "video route only serves GET/HEAD; uploads need the plugin upload route — restart the web server to load it"
|
|
405
|
-
}));
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
try {
|
|
409
|
-
const found = await findVideoFile();
|
|
410
|
-
if (found === null) {
|
|
411
|
-
res.writeHead(404);
|
|
412
|
-
res.end("no background video stored");
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const st = await stat(found.path);
|
|
416
|
-
const baseHeaders = {
|
|
417
|
-
"Content-Type": found.mime ?? "application/octet-stream",
|
|
418
|
-
"Accept-Ranges": "bytes",
|
|
419
|
-
"Cache-Control": "no-store"
|
|
420
|
-
};
|
|
421
|
-
const range = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
|
|
422
|
-
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
423
|
-
if (m !== null && (m[1] !== "" || m[2] !== "")) {
|
|
424
|
-
let start;
|
|
425
|
-
let end;
|
|
426
|
-
if (m[1] === "") {
|
|
427
|
-
const suffix = parseInt(m[2], 10);
|
|
428
|
-
start = Math.max(0, st.size - suffix);
|
|
429
|
-
end = st.size - 1;
|
|
430
|
-
} else {
|
|
431
|
-
start = parseInt(m[1], 10);
|
|
432
|
-
end = m[2] !== "" ? Math.min(parseInt(m[2], 10), st.size - 1) : st.size - 1;
|
|
433
|
-
}
|
|
434
|
-
if (start >= st.size || start > end) {
|
|
435
|
-
res.writeHead(416, { "Content-Range": `bytes */${st.size}` });
|
|
436
|
-
res.end();
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
res.writeHead(206, {
|
|
440
|
-
...baseHeaders,
|
|
441
|
-
"Content-Range": `bytes ${start}-${end}/${st.size}`,
|
|
442
|
-
"Content-Length": end - start + 1
|
|
443
|
-
});
|
|
444
|
-
if (req.method === "HEAD") {
|
|
445
|
-
res.end();
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
createReadStream(found.path, {
|
|
449
|
-
start,
|
|
450
|
-
end
|
|
451
|
-
}).pipe(res);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
res.writeHead(200, {
|
|
455
|
-
...baseHeaders,
|
|
456
|
-
"Content-Length": st.size
|
|
457
|
-
});
|
|
458
|
-
if (req.method === "HEAD") {
|
|
459
|
-
res.end();
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
createReadStream(found.path).pipe(res);
|
|
463
|
-
} catch (e) {
|
|
464
|
-
console.error("dsh-background-by-model: failed to serve the background video", e);
|
|
465
|
-
try {
|
|
466
|
-
res.writeHead(500);
|
|
467
|
-
res.end();
|
|
468
|
-
} catch {}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
/** Accept a raw video upload (POST): pipe the body into a temp file, then
|
|
472
|
-
* rename it into the MIME-derived slot. Aborted transfers clean up. */
|
|
473
|
-
async function handleVideoUpload(req, res) {
|
|
474
|
-
if (req.method !== "POST") {
|
|
475
|
-
res.writeHead(405);
|
|
476
|
-
res.end();
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
const mime = (typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "").split(";")[0].trim();
|
|
480
|
-
if (!mime.startsWith("video/")) {
|
|
481
|
-
req.resume();
|
|
482
|
-
res.writeHead(415, { "Content-Type": "application/json" });
|
|
483
|
-
res.end(JSON.stringify({
|
|
484
|
-
ok: false,
|
|
485
|
-
error: "unsupported media type, expected video/*"
|
|
486
|
-
}));
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
347
|
+
/** The host's default model selection — the client's fallback when the
|
|
348
|
+
* per-session model services are unavailable. */
|
|
349
|
+
function defaultModel(ctx) {
|
|
489
350
|
try {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
let failed = false;
|
|
495
|
-
const fail = () => {
|
|
496
|
-
if (failed) return;
|
|
497
|
-
failed = true;
|
|
498
|
-
out.destroy();
|
|
499
|
-
rm(tmp, { force: true });
|
|
351
|
+
const selection = (typeof ctx.get === "function" ? ctx.get("agentDefaultModel") : void 0)?.currentSelection?.();
|
|
352
|
+
if (selection && typeof selection.provider === "string" && typeof selection.model === "string") return {
|
|
353
|
+
provider: selection.provider,
|
|
354
|
+
model: selection.model
|
|
500
355
|
};
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
out.on("error", () => {
|
|
504
|
-
fail();
|
|
505
|
-
try {
|
|
506
|
-
res.writeHead(500);
|
|
507
|
-
res.end();
|
|
508
|
-
} catch {}
|
|
509
|
-
});
|
|
510
|
-
req.pipe(out);
|
|
511
|
-
out.on("finish", async () => {
|
|
512
|
-
if (failed) return;
|
|
513
|
-
try {
|
|
514
|
-
for (const name of VIDEO_CANDIDATES) {
|
|
515
|
-
const p = dshHomePath(DATA_DIR, name);
|
|
516
|
-
if (p !== target) await rm(p, { force: true });
|
|
517
|
-
}
|
|
518
|
-
await rm(target, { force: true });
|
|
519
|
-
await rename(tmp, target);
|
|
520
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
521
|
-
res.end(JSON.stringify({ ok: true }));
|
|
522
|
-
} catch (e) {
|
|
523
|
-
console.error("dsh-background-by-model: failed to finalize the uploaded video", e);
|
|
524
|
-
rm(tmp, { force: true });
|
|
525
|
-
try {
|
|
526
|
-
res.writeHead(500);
|
|
527
|
-
res.end();
|
|
528
|
-
} catch {}
|
|
529
|
-
}
|
|
530
|
-
});
|
|
531
|
-
} catch (e) {
|
|
532
|
-
console.error("dsh-background-by-model: failed to accept the video upload", e);
|
|
533
|
-
try {
|
|
534
|
-
res.writeHead(500);
|
|
535
|
-
res.end();
|
|
536
|
-
} catch {}
|
|
537
|
-
}
|
|
356
|
+
} catch {}
|
|
357
|
+
return null;
|
|
538
358
|
}
|
|
539
359
|
const NS = "dshBackgroundByModel";
|
|
540
360
|
const RPC_CHANNEL = "/dsh-background-by-model";
|
|
541
361
|
const RPC_BODY_MAX = 314572800;
|
|
542
362
|
/** Dispatch one decoded RPC method to the matching persistence routine and
|
|
543
363
|
* return the wire `result` half of the server-response envelope. */
|
|
544
|
-
async function handleRpcMethod(endpoint, payload) {
|
|
364
|
+
async function handleRpcMethod(ctx, endpoint, payload) {
|
|
545
365
|
const method = endpoint.slice(`${NS}/`.length);
|
|
366
|
+
const rawSlot = payload?.slot;
|
|
367
|
+
const slot = typeof rawSlot === "string" && SLOT_RE.test(rawSlot) ? rawSlot : null;
|
|
546
368
|
try {
|
|
547
369
|
switch (method) {
|
|
548
370
|
case "read": return {
|
|
549
371
|
ok: true,
|
|
550
372
|
value: {
|
|
551
373
|
config: await readConfig(),
|
|
552
|
-
|
|
553
|
-
videoUrl: await videoUrl()
|
|
374
|
+
slots: await listSlots()
|
|
554
375
|
}
|
|
555
376
|
};
|
|
556
377
|
case "writeConfig": return {
|
|
557
378
|
ok: true,
|
|
558
379
|
value: await writeConfig(payload?.config ?? {})
|
|
559
380
|
};
|
|
560
|
-
case "
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
381
|
+
case "readImage":
|
|
382
|
+
if (slot === null) return {
|
|
383
|
+
ok: true,
|
|
384
|
+
value: { dataUrl: null }
|
|
385
|
+
};
|
|
386
|
+
return {
|
|
387
|
+
ok: true,
|
|
388
|
+
value: { dataUrl: await readImage(slot) }
|
|
389
|
+
};
|
|
390
|
+
case "writeImage":
|
|
391
|
+
if (slot === null) return {
|
|
392
|
+
ok: true,
|
|
393
|
+
value: false
|
|
394
|
+
};
|
|
395
|
+
return {
|
|
396
|
+
ok: true,
|
|
397
|
+
value: await writeImage(slot, payload?.dataUrl ?? null)
|
|
398
|
+
};
|
|
399
|
+
case "deleteImage":
|
|
400
|
+
if (slot === null) return {
|
|
401
|
+
ok: true,
|
|
402
|
+
value: false
|
|
403
|
+
};
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
value: await writeImage(slot, null)
|
|
407
|
+
};
|
|
408
|
+
case "fetchImageUrl":
|
|
409
|
+
if (slot === null) return {
|
|
410
|
+
ok: true,
|
|
411
|
+
value: {
|
|
412
|
+
ok: false,
|
|
413
|
+
error: "bad slot"
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
ok: true,
|
|
418
|
+
value: await fetchImageUrl(slot, payload?.url ?? null)
|
|
419
|
+
};
|
|
420
|
+
case "defaultModel": return {
|
|
569
421
|
ok: true,
|
|
570
|
-
value:
|
|
422
|
+
value: defaultModel(ctx)
|
|
571
423
|
};
|
|
572
424
|
default: return {
|
|
573
425
|
ok: false,
|
|
@@ -676,7 +528,7 @@ function apply(ctx) {
|
|
|
676
528
|
}));
|
|
677
529
|
return;
|
|
678
530
|
}
|
|
679
|
-
const result = await handleRpcMethod(endpoint, env.payload);
|
|
531
|
+
const result = await handleRpcMethod(ctx, endpoint, env.payload);
|
|
680
532
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
681
533
|
res.end(JSON.stringify({
|
|
682
534
|
type: "server-response",
|
|
@@ -685,16 +537,6 @@ function apply(ctx) {
|
|
|
685
537
|
}));
|
|
686
538
|
}
|
|
687
539
|
}), "dsh-background-by-model: rpc channel");
|
|
688
|
-
webCtx.effect(() => webCtx.webServer.register({
|
|
689
|
-
kind: "prefix",
|
|
690
|
-
path: VIDEO_ROUTE,
|
|
691
|
-
handler: serveVideo
|
|
692
|
-
}), "dsh-background-by-model: video route");
|
|
693
|
-
webCtx.effect(() => webCtx.webServer.register({
|
|
694
|
-
kind: "exact",
|
|
695
|
-
path: UPLOAD_ROUTE,
|
|
696
|
-
handler: handleVideoUpload
|
|
697
|
-
}), "dsh-background-by-model: upload route");
|
|
698
540
|
});
|
|
699
541
|
}
|
|
700
542
|
//#endregion
|