dsh-wisp 1.42.0 → 1.48.0
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 +666 -29
- package/assets/canon/attn.webp +0 -0
- package/assets/canon/eat.webp +0 -0
- package/assets/canon/happy.webp +0 -0
- package/assets/canon/idle.webp +0 -0
- package/assets/canon/poked.webp +0 -0
- package/assets/canon/proud.webp +0 -0
- package/assets/canon/sleepy.webp +0 -0
- package/assets/canon/work.webp +0 -0
- package/assets/classic/attn.webp +0 -0
- package/assets/classic/eat.webp +0 -0
- package/assets/classic/happy.webp +0 -0
- package/assets/classic/idle.webp +0 -0
- package/assets/classic/poked.webp +0 -0
- package/assets/classic/proud.webp +0 -0
- package/assets/classic/work.webp +0 -0
- package/assets/deepsea/attn.webp +0 -0
- package/assets/deepsea/eat.webp +0 -0
- package/assets/deepsea/happy.webp +0 -0
- package/assets/deepsea/idle.webp +0 -0
- package/assets/deepsea/poked.webp +0 -0
- package/assets/deepsea/proud.webp +0 -0
- package/assets/deepsea/sleepy.webp +0 -0
- package/assets/deepsea/work.webp +0 -0
- package/assets/motion/idle.webp +0 -0
- package/assets/motion/sleepy.webm +0 -0
- package/assets/motion/sleepy.webp +0 -0
- package/assets/motion/swim_attn.webp +0 -0
- package/assets/motion/swim_eat.webp +0 -0
- package/assets/motion/swim_happy.webp +0 -0
- package/assets/motion/swim_idle.webp +0 -0
- package/assets/motion/swim_poked.webp +0 -0
- package/assets/motion/swim_proud.webp +0 -0
- package/assets/motion/swim_sleepy.webp +0 -0
- package/assets/motion/swim_work.webp +0 -0
- package/assets/night/attn.webp +0 -0
- package/assets/night/eat.webp +0 -0
- package/assets/night/happy.webp +0 -0
- package/assets/night/idle.webp +0 -0
- package/assets/night/poked.webp +0 -0
- package/assets/night/proud.webp +0 -0
- package/assets/night/sleepy.webp +0 -0
- package/assets/night/work.webp +0 -0
- package/assets/pajama/attn.webp +0 -0
- package/assets/pajama/eat.webp +0 -0
- package/assets/pajama/happy.webp +0 -0
- package/assets/pajama/idle.webp +0 -0
- package/assets/pajama/poked.webp +0 -0
- package/assets/pajama/proud.webp +0 -0
- package/assets/pajama/sleepy.webp +0 -0
- package/assets/pajama/work.webp +0 -0
- package/assets/swim/attn.webp +0 -0
- package/assets/swim/eat.webp +0 -0
- package/assets/swim/happy.webp +0 -0
- package/assets/swim/idle.webp +0 -0
- package/assets/swim/poked.webp +0 -0
- package/assets/swim/proud.webp +0 -0
- package/assets/swim/sleepy.webp +0 -0
- package/assets/swim/work.webp +0 -0
- package/audit/generation-audit.jsonl +57 -0
- package/build.mjs +130 -8
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1014 -104
- package/lib/client.template.js +953 -65
- package/lib/index.js +311 -16
- package/package.json +1 -1
- package/tools/CHARACTER-PROMPT.md +65 -9
- package/tools/audit-log.mjs +11 -2
- package/tools/engine-probe.mjs +500 -2
- package/verify-wisp.mjs +1576 -51
package/lib/index.js
CHANGED
|
@@ -28,11 +28,200 @@
|
|
|
28
28
|
// "npm first, GitHub as fallback" would read the stale npm number and claim you
|
|
29
29
|
// were up to date.
|
|
30
30
|
|
|
31
|
+
/* `node:url` / `node:module` are imported statically: this file is loaded by
|
|
32
|
+
the Loader as an ordinary ES module, and both are needed on the module's
|
|
33
|
+
first line of work (resolving the package's own asset directory). */
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
35
|
+
import { createRequire } from 'node:module'
|
|
36
|
+
|
|
37
|
+
/* ---------------------------------------------------------------------------
|
|
38
|
+
MOTION CLIPS OVER HTTP (v1.47.0) — the delivery seam for the frame
|
|
39
|
+
animations.
|
|
40
|
+
|
|
41
|
+
WHY THIS EXISTS. Until 1.46.5 the animated WebPs were base64-inlined into
|
|
42
|
+
lib/client.js by build.mjs. Two 720p clips already cost 7.83 MB of base64;
|
|
43
|
+
the eight swimsuit clips would have added ~30 MB and taken the browser half
|
|
44
|
+
to roughly 45 MB. That is not a budget problem that can be squeezed — it is
|
|
45
|
+
the wrong place to put multi-megabyte video-ish assets.
|
|
46
|
+
|
|
47
|
+
HOW THE PACKAGE CAN STILL DELIVER THEM. A browser half that inlines nothing
|
|
48
|
+
has exactly two documented ways to reach bytes: `@deepseek-ai/dsh-client-
|
|
49
|
+
resources` (a `dsh-resource://` live-VALUE model for slot components — it
|
|
50
|
+
needs a React provider and yields values, not an image URL, so it is not an
|
|
51
|
+
asset route), and the platform's own HTTP carrier. `dsh-host-webserver` is
|
|
52
|
+
documented as "let the feature plugins claim their routes", `ctx.webServer`
|
|
53
|
+
and `ctx.fs` are both live Host services, and the Loader imports THIS file
|
|
54
|
+
as an ES module — so `import.meta.url` resolves the package directory. That
|
|
55
|
+
is a complete path from the package's own files to an `<img src>`:
|
|
56
|
+
|
|
57
|
+
<pkg>/assets/motion/<clip>.webp
|
|
58
|
+
-> GET <origin>/wisp-motion/<clip>.webp (this route)
|
|
59
|
+
-> new URL('wisp-motion/<clip>.webp', document.baseURI) (client half)
|
|
60
|
+
|
|
61
|
+
The browser half never calls `fetch` (it is a trapped global) and never
|
|
62
|
+
learns a filesystem path: it only asks the page's own origin for a URL.
|
|
63
|
+
|
|
64
|
+
WHAT HAPPENS WHEN IT IS NOT THERE. Every failure is silent and already
|
|
65
|
+
specified elsewhere in this plugin: an unregistered route (the Desktop
|
|
66
|
+
carrier serves the shell without an HTTP server), a 404, a decode error —
|
|
67
|
+
the `<img>` fires `error`, the client half hides the motion layer and hands
|
|
68
|
+
the picture back to the static sprite. Nothing throws, nothing is logged,
|
|
69
|
+
and there is still exactly one of her on screen. `__wisp.doctor().motion.
|
|
70
|
+
frame` reports it.
|
|
71
|
+
|
|
72
|
+
SCOPE. The handler is deliberately a closed list, not a file server: one
|
|
73
|
+
path segment, `<name>.webp`, no separators, no percent-decoding games,
|
|
74
|
+
nothing outside <pkg>/assets/motion/. Anything else is a 404.
|
|
75
|
+
--------------------------------------------------------------------------- */
|
|
76
|
+
|
|
77
|
+
/** URL prefix the browser half asks for. Kept in sync with MOTION_ROUTE in lib/client.template.js. */
|
|
78
|
+
export const MOTION_PATH = '/wisp-motion/'
|
|
79
|
+
|
|
80
|
+
/** Only these names are servable. A clip name never contains a separator. */
|
|
81
|
+
const MOTION_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*\.webp$/
|
|
82
|
+
|
|
83
|
+
/** Refuse to serve anything larger than this even if it lands in assets/motion/. */
|
|
84
|
+
export const MOTION_MAX_BYTES = 12 * 1024 * 1024
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Absolute path of one shipped clip, or null when the name is not servable.
|
|
88
|
+
*
|
|
89
|
+
* `import.meta.url` is the installed lib/index.js, so `../assets/motion/` is
|
|
90
|
+
* the package's own asset directory whether the profile installed this from
|
|
91
|
+
* npm or linked it from a checkout.
|
|
92
|
+
*
|
|
93
|
+
* @param name - the requested file name (already stripped of the route prefix).
|
|
94
|
+
* @returns the host path, or null when the name must not be served.
|
|
95
|
+
*/
|
|
96
|
+
export function motionFile(name) {
|
|
97
|
+
if (typeof name !== 'string' || !MOTION_NAME.test(name)) return null
|
|
98
|
+
try {
|
|
99
|
+
return fileURLToPath(new URL(`../assets/motion/${name}`, import.meta.url))
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the route handler. Split out from registration so the preflight can
|
|
107
|
+
* drive it with a fake request/response pair — the response shape is the part
|
|
108
|
+
* that must not drift from what the browser half expects.
|
|
109
|
+
*
|
|
110
|
+
* @param readBytes - `(absolutePath) => Uint8Array | null`, never throws.
|
|
111
|
+
* @returns a `node:http`-shaped handler.
|
|
112
|
+
*/
|
|
113
|
+
export function motionHandler(readBytes) {
|
|
114
|
+
return async function handleMotion(req, res) {
|
|
115
|
+
const send = (status, headers) => {
|
|
116
|
+
try { res.writeHead(status, headers) } catch (error) { /* headers already out */ }
|
|
117
|
+
try { res.end() } catch (error) { /* client vanished */ }
|
|
118
|
+
}
|
|
119
|
+
let pathname = '/'
|
|
120
|
+
try { pathname = new URL(String(req && req.url ? req.url : '/'), 'http://wisp.invalid').pathname } catch (error) { /* keep '/' */ }
|
|
121
|
+
if (pathname === MOTION_PATH.slice(0, -1) || pathname === MOTION_PATH) {
|
|
122
|
+
/* The bare prefix is not a list endpoint — there is nothing to enumerate. */
|
|
123
|
+
send(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (!pathname.startsWith(MOTION_PATH)) {
|
|
127
|
+
send(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const file = motionFile(pathname.slice(MOTION_PATH.length))
|
|
131
|
+
if (file === null) {
|
|
132
|
+
send(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const method = String((req && req.method) || 'GET').toUpperCase()
|
|
136
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
137
|
+
send(405, { allow: 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8' })
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
const bytes = readBytes(file)
|
|
141
|
+
if (bytes === null || bytes === undefined || bytes.byteLength === 0) {
|
|
142
|
+
send(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (bytes.byteLength > MOTION_MAX_BYTES) {
|
|
146
|
+
send(413, { 'content-type': 'text/plain; charset=utf-8' })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const headers = {
|
|
150
|
+
'content-type': 'image/webp',
|
|
151
|
+
'content-length': String(bytes.byteLength),
|
|
152
|
+
/* The clip name is the identity: a changed clip is a new file, not a new
|
|
153
|
+
body under the same URL. A day of cache keeps a reload cheap without
|
|
154
|
+
pinning a stale loop for the life of the page. */
|
|
155
|
+
'cache-control': 'public, max-age=86400',
|
|
156
|
+
'x-content-type-options': 'nosniff',
|
|
157
|
+
}
|
|
158
|
+
if (method === 'HEAD') { send(200, headers); return }
|
|
159
|
+
try {
|
|
160
|
+
res.writeHead(200, headers)
|
|
161
|
+
res.end(bytes)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
try { res.destroy() } catch (e) { /* already gone */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Register the motion route on the platform's HTTP carrier.
|
|
170
|
+
*
|
|
171
|
+
* Optional on purpose: `webServer` does not exist under every carrier, and a
|
|
172
|
+
* missing route is the documented silent-degradation path, not a load failure.
|
|
173
|
+
* `node:fs` is reached lazily through `createRequire` so importing this module
|
|
174
|
+
* never depends on it.
|
|
175
|
+
*
|
|
176
|
+
* @param ctx - the plugin context.
|
|
177
|
+
* @returns `{ registered, why }` — never throws.
|
|
178
|
+
*/
|
|
179
|
+
export function registerMotionRoute(ctx, options = {}) {
|
|
180
|
+
const read = options.readBytes ?? defaultReadBytes
|
|
181
|
+
try {
|
|
182
|
+
if (!ctx || typeof ctx.inject !== 'function') return { registered: false, why: 'no-ctx-inject' }
|
|
183
|
+
const path = typeof options.path === 'string' ? options.path : MOTION_PATH
|
|
184
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
185
|
+
if (!webCtx || typeof webCtx.webServer?.register !== 'function') return
|
|
186
|
+
try {
|
|
187
|
+
webCtx.effect(
|
|
188
|
+
() => webCtx.webServer.register({ kind: 'prefix', path, handler: motionHandler(read) }),
|
|
189
|
+
'dsh-wisp: motion clips over the host HTTP carrier',
|
|
190
|
+
)
|
|
191
|
+
} catch (error) {
|
|
192
|
+
/* 注册失败(例如路由被占)不能让整个插件倒掉:这条路由是**可选**的,
|
|
193
|
+
没有它画面就是静态立绘。 */
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
return { registered: true, path }
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return { registered: false, why: String(error && error.message ? error.message : error).slice(0, 120) }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read one clip from disk. Returns null instead of throwing: every caller
|
|
204
|
+
* treats "cannot read" as "serve 404", which the browser half already turns
|
|
205
|
+
* into the static sprite.
|
|
206
|
+
*
|
|
207
|
+
* @param absolutePath - the clip's host path.
|
|
208
|
+
* @returns the bytes, or null.
|
|
209
|
+
*/
|
|
210
|
+
export function defaultReadBytes(absolutePath) {
|
|
211
|
+
try {
|
|
212
|
+
const require = createRequire(import.meta.url)
|
|
213
|
+
const fs = require('node:fs')
|
|
214
|
+
return new Uint8Array(fs.readFileSync(absolutePath))
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
31
220
|
/** Cordis plugin name used by loader diagnostics. */
|
|
32
221
|
export const name = 'wisp'
|
|
33
222
|
|
|
34
223
|
/** Stamped into every balance request. Must match package.json — the preflight guards it. */
|
|
35
|
-
export const VERSION = '1.
|
|
224
|
+
export const VERSION = '1.48.0'
|
|
36
225
|
|
|
37
226
|
/**
|
|
38
227
|
* `web` and `deepseekAccount` are optional on purpose. A required inject that
|
|
@@ -50,8 +239,23 @@ export const DEFAULT_SOURCES = {
|
|
|
50
239
|
/** How long ONE source may take before it is reported as a timeout. */
|
|
51
240
|
const CHECK_TIMEOUT_MS = 8000
|
|
52
241
|
|
|
242
|
+
/** 这个包自己的名字 —— 兜底通道(问插件管理器)按包名查。 */
|
|
243
|
+
const UPDATE_PACKAGE_NAME = 'dsh-wisp'
|
|
244
|
+
|
|
245
|
+
/** 一眼看懂一个"服务"到底是什么(诊断用;永远不抛)。 */
|
|
246
|
+
function describeService(value) {
|
|
247
|
+
if (value === undefined) return 'undefined'
|
|
248
|
+
if (value === null) return 'null'
|
|
249
|
+
const type = typeof value
|
|
250
|
+
if (type !== 'object' && type !== 'function') return type
|
|
251
|
+
try {
|
|
252
|
+
const keys = Object.keys(value).slice(0, 6).join(',')
|
|
253
|
+
return type + '{' + keys + '}' + (typeof value.fetch === 'function' ? '+fetch' : '-fetch')
|
|
254
|
+
} catch (error) { return type + '(unreadable)' }
|
|
255
|
+
}
|
|
256
|
+
|
|
53
257
|
/**
|
|
54
|
-
* Resolve the `web` service LATE, on every call.
|
|
258
|
+
* Resolve the `web` service LATE, on every call — and say HOW, and what was found.
|
|
55
259
|
*
|
|
56
260
|
* `inject.optional: ['web']` hands us `ctx.web`, but only when the service was
|
|
57
261
|
* already provided at the moment this fiber materialized — and this half is
|
|
@@ -60,20 +264,75 @@ const CHECK_TIMEOUT_MS = 8000
|
|
|
60
264
|
* which would disable the update check forever. `ctx.get('web')` looks the
|
|
61
265
|
* service up at call time instead, so a startup race cannot wedge it.
|
|
62
266
|
*
|
|
63
|
-
*
|
|
267
|
+
* 真实运行里两种路都拿不到时,**必须能说清是哪一种**:`ctx.get` 有没有?
|
|
268
|
+
* 拿到的东西是什么形状?—— 没有这一层,"没有联网的通道"是一句无法追问的话。
|
|
269
|
+
*
|
|
270
|
+
* @returns `{ service, how, why }` — never throws.
|
|
271
|
+
* how: 'ctx.get' | 'ctx.web' | 'none' 拿到了没有、从哪拿的
|
|
272
|
+
* why: 一句话说明"看到了什么"(进了 doctor 与她的失败台词)
|
|
64
273
|
*/
|
|
65
274
|
export function resolveWebService(ctx) {
|
|
275
|
+
let late
|
|
276
|
+
let getThrew = null
|
|
66
277
|
try {
|
|
67
|
-
if (ctx && typeof ctx.get === 'function')
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
278
|
+
if (ctx && typeof ctx.get === 'function') late = ctx.get('web')
|
|
279
|
+
else getThrew = 'ctx.get 不是函数(' + describeService(ctx && ctx.get) + ')'
|
|
280
|
+
} catch (error) { getThrew = 'ctx.get 抛错:' + String(error && error.message ? error.message : error).slice(0, 60) }
|
|
281
|
+
if (late && typeof late.fetch === 'function') {
|
|
282
|
+
return { service: late, how: 'ctx.get', why: 'ctx.get("web")→' + describeService(late) }
|
|
283
|
+
}
|
|
284
|
+
let declared
|
|
285
|
+
let webThrew = null
|
|
286
|
+
try { declared = ctx && ctx.web } catch (error) { webThrew = String(error && error.message ? error.message : error).slice(0, 60) }
|
|
287
|
+
if (declared && typeof declared.fetch === 'function') {
|
|
288
|
+
return { service: declared, how: 'ctx.web', why: 'ctx.web→' + describeService(declared) }
|
|
289
|
+
}
|
|
290
|
+
const parts = []
|
|
291
|
+
parts.push('ctx.get("web")→' + describeService(late))
|
|
292
|
+
if (getThrew) parts.push(getThrew)
|
|
293
|
+
parts.push('ctx.web→' + describeService(declared))
|
|
294
|
+
if (webThrew) parts.push('ctx.web 读取抛错:' + webThrew)
|
|
295
|
+
/* 拿到了对象但没有 fetch 也要如实说 —— "有服务但没这个方法"和"根本没有服务"是两件事 */
|
|
296
|
+
const service = (late && typeof late === 'object') ? late : ((declared && typeof declared === 'object') ? declared : undefined)
|
|
297
|
+
if (service) {
|
|
298
|
+
return { service, how: 'partial', why: parts.join('; ') + '(无 fetch)' }
|
|
299
|
+
}
|
|
300
|
+
return { service: undefined, how: 'none', why: parts.join('; ') }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 兜底通道:向插件管理器问"这个包现在是什么版本"。
|
|
305
|
+
*
|
|
306
|
+
* 它本来就是**去 registry 问这个包**的那个人(安装前先 inspect),所以这条路
|
|
307
|
+
* 走的是应用自己那条网络出口、且尊重用户配的镜像源 —— 比我们自己去抓 packument
|
|
308
|
+
* 更接近"安装时会装到哪一版"。`web` 服务拿不到时,这是唯一还活着的更新通道。
|
|
309
|
+
*
|
|
310
|
+
* @returns `{ ok, version, registry }` 或 `{ ok: false, reason, detail }` — never throws.
|
|
311
|
+
*/
|
|
312
|
+
export async function readManagedVersion(ctx, name, timeoutMs) {
|
|
313
|
+
let manager
|
|
314
|
+
try {
|
|
315
|
+
if (!ctx || typeof ctx.get !== 'function') return { ok: false, reason: 'no-ctx-get' }
|
|
316
|
+
manager = ctx.get('pluginManager')
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return { ok: false, reason: 'manager-unreachable', detail: String(error && error.message ? error.message : error).slice(0, 80) }
|
|
319
|
+
}
|
|
320
|
+
if (!manager || typeof manager.inspect !== 'function') {
|
|
321
|
+
return { ok: false, reason: 'no-plugin-manager', detail: describeService(manager) }
|
|
322
|
+
}
|
|
72
323
|
try {
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
324
|
+
const info = await withTimeout(manager.inspect(name), timeoutMs)
|
|
325
|
+
if (info && info.status === 'accepted' && typeof info.version === 'string') {
|
|
326
|
+
return { ok: true, version: info.version, registry: info.registry ?? null }
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
reason: 'inspect-refused',
|
|
331
|
+
detail: info && info.reason ? String(info.reason).slice(0, 100) : describeService(info),
|
|
332
|
+
}
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return { ok: false, reason: 'inspect-failed', detail: String(error && error.message ? error.message : error).slice(0, 100) }
|
|
335
|
+
}
|
|
77
336
|
}
|
|
78
337
|
|
|
79
338
|
/**
|
|
@@ -84,7 +343,10 @@ export function resolveWebService(ctx) {
|
|
|
84
343
|
* @returns `{ ok, version }` or `{ ok: false, reason }` — never throws.
|
|
85
344
|
*/
|
|
86
345
|
export async function readVersion(web, url, signal) {
|
|
87
|
-
if (!web
|
|
346
|
+
if (!web) return { ok: false, reason: 'no-web-service' }
|
|
347
|
+
/* "有服务但没有 fetch"和"根本没有服务"是两件事 —— 台词的下一步动作不同
|
|
348
|
+
(一个是应用/平台的问题,一个是插件注入的问题),所以别合并成一句。 */
|
|
349
|
+
if (typeof web.fetch !== 'function') return { ok: false, reason: 'web-has-no-fetch', detail: describeService(web) }
|
|
88
350
|
try {
|
|
89
351
|
const res = await web.fetch({ url }, signal)
|
|
90
352
|
if (!res || typeof res.statusCode !== 'number') return { ok: false, reason: 'bad-result' }
|
|
@@ -305,9 +567,38 @@ export function registerHandlers(ctx, options = {}) {
|
|
|
305
567
|
const dispose = seat.handle('checkUpdate', async (args) => {
|
|
306
568
|
const current = args && typeof args.current === 'string' ? args.current : null
|
|
307
569
|
/* 服务**在调用时**解析,不用闭包里那个可能早就定格的 ctx.web(见 resolveWebService)。 */
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
570
|
+
const resolved = resolveWebService(ctx)
|
|
571
|
+
if (resolved.service) {
|
|
572
|
+
const verdict = await readPublishedVersion(resolved.service, urls, undefined, CHECK_TIMEOUT_MS)
|
|
573
|
+
return { ...verdict, current, diag: { via: 'web', how: resolved.how, why: resolved.why }, checkedAt: new Date().toISOString() }
|
|
574
|
+
}
|
|
575
|
+
/* 没有可用的 web 服务时的兜底:问插件管理器"这个包现在是什么版本"。
|
|
576
|
+
它走的是应用自己那条网络出口(安装前就要去 registry 问),所以这条路
|
|
577
|
+
是"安装时会装到哪一版"的权威答案 —— 而且不依赖插件能否拿到 web。 */
|
|
578
|
+
const managed = await readManagedVersion(ctx, UPDATE_PACKAGE_NAME, CHECK_TIMEOUT_MS)
|
|
579
|
+
const sources = { npm: managed.ok
|
|
580
|
+
? { ok: true, version: managed.version }
|
|
581
|
+
: { ok: false, reason: managed.reason, detail: managed.detail } }
|
|
582
|
+
if (managed.ok) {
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
latest: managed.version,
|
|
586
|
+
from: 'npm',
|
|
587
|
+
sources,
|
|
588
|
+
current,
|
|
589
|
+
diag: { via: 'pluginManager', how: resolved.how, why: resolved.why, registry: managed.registry ?? null },
|
|
590
|
+
checkedAt: new Date().toISOString(),
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
sources.github = { ok: false, reason: 'no-web-service', detail: resolved.why }
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
reason: 'no-web-service',
|
|
597
|
+
sources,
|
|
598
|
+
current,
|
|
599
|
+
diag: { via: 'none', how: resolved.how, why: resolved.why, manager: managed.reason + (managed.detail ? ':' + managed.detail : '') },
|
|
600
|
+
checkedAt: new Date().toISOString(),
|
|
601
|
+
}
|
|
311
602
|
})
|
|
312
603
|
/* The balance handler. `ctx.deepseekAccount` is undefined unless the platform
|
|
313
604
|
provides it — readBalance() turns that into `no-account-service` rather than
|
|
@@ -338,6 +629,10 @@ export function apply(ctx, config) {
|
|
|
338
629
|
if (typeof result.dispose === 'function') result.dispose()
|
|
339
630
|
}, 'dsh-wisp: host handlers')
|
|
340
631
|
}
|
|
632
|
+
/* v1.47.0: the frame-animation clips are files in this package, served to
|
|
633
|
+
the page over the host HTTP carrier. Optional and silent — see the block
|
|
634
|
+
above MOTION_PATH. */
|
|
635
|
+
registerMotionRoute(ctx, config || {})
|
|
341
636
|
}
|
|
342
637
|
|
|
343
638
|
export default { name, inject, apply }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wisp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.0",
|
|
4
4
|
"description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
|
|
58
58
|
【姿态】__按情绪替换,见下表__
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
【画面完整性】画面中只有一个角色。头发是深蓝色中长直发、自然披散,不要画成双马尾、不要马尾、不要盘发、不要白色头发;头顶的大圆环呆毛清楚可见、不能被省略;鲸尾必须可见且与身体连接;发尾干净完整;手指完整;五官清晰。
|
|
61
61
|
|
|
62
62
|
【背景】纯绿色背景(绿幕):整张背景是一整片均匀的纯绿色,没有任何渐变、纹理、阴影或其他物体;角色完全被纯绿色包围,不要在地面投下阴影。
|
|
63
63
|
|
|
64
64
|
【构图】全身直立、脚底贴住画面底边、角色居中。
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
##
|
|
67
|
+
## 【姿态】八选一(对应八个 sprite)
|
|
68
68
|
|
|
69
69
|
| 文件 | 姿态 |
|
|
70
70
|
|---|---|
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
| `attn.png` | 身体微微前倾,一只手抬到胸前朝前方招了招,像是在叫对方过来;眉头微蹙、认真地直视前方,带着一点催促。 |
|
|
76
76
|
| `proud.png` | 双手抱在胸前,下巴微微抬起,眼睛半闭着、嘴角上扬,神情带着一点不掩饰的得意;身体微微后仰,站得很稳。 |
|
|
77
77
|
| `poked.png` | 身体明显往后一缩、双肩微微耸起,一只手抬到胸前摆出「等一下」的姿势(掌心朝前);眼睛睁得比平时大、眉毛微挑,嘴巴小小地抿着,带着一点惊讶与抗议。 |
|
|
78
|
+
| `eat.png` | 双手捧着一碗盛得冒尖的白米饭,捧在胸腹前,闭着眼睛开心地笑,脸颊微红。 |
|
|
78
79
|
|
|
79
80
|
---
|
|
80
81
|
|
|
@@ -115,8 +116,12 @@
|
|
|
115
116
|
|
|
116
117
|
## 第三套皮肤:宵蓝礼服(`night`)
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
形象要素与母版**逐字相同**(深蓝中长直发、大圆环呆毛、鲸鳍耳、白色荷叶边女仆发箍 + 蓝色鲸鱼发夹、
|
|
120
|
+
鲸尾、细蓝颈饰、成年女性成熟身型、画风段、画面完整性段、构图段),**只替换【服装】段**:
|
|
121
|
+
|
|
122
|
+
> 这一段原来写的是「超长双马尾」(这一套**最早**那批的身份)。1.24.0 把四套旧皮肤按新规格
|
|
123
|
+
> **全部重出**之后,宵蓝礼服也是**直发 + 呆毛**那一版 —— 重出 `attn` 时按这个身份写,才对得上
|
|
124
|
+
> 已发布的七张。**以母版为准。**
|
|
120
125
|
|
|
121
126
|
```
|
|
122
127
|
【服装】单层深蓝色露肩礼服:一字露肩剪裁,低领口露出锁骨与肩线;裙身单层薄料、修身、裙摆及膝略短、
|
|
@@ -126,6 +131,17 @@
|
|
|
126
131
|
五张姿态照第 49 行那张表,一张不改。实测键控质量 0.45%–0.61% 软边(与前两套同级),
|
|
127
132
|
入包那档合计 821 KB。
|
|
128
133
|
|
|
134
|
+
> **`attn` 重出过一次**:那一张画坏了 —— 头/高只有 **0.113**(同套其余七张 0.19–0.25)、
|
|
135
|
+
> 举起的右手比脸还大、鲸鳍耳大得像一对翅膀,取景也只有 **93.0%**(其余七张 96.8%–99.7%),
|
|
136
|
+
> 于是切到「她在叫你过来」时会先缩一圈。重出时把「取景占满 97% 以上」「约 6.5–7 头身、
|
|
137
|
+
> 头不要画小」「抬起的手掌与脸相当、鳍耳约为头宽四分之一」写进末尾清单 → 新图头/高 **0.218**、
|
|
138
|
+
> 取景 **98.6%**,与同套 `idle` 的 0.217 对齐。母版在 `masters/night/attn.png`;
|
|
139
|
+
> 审计里 `night/attn.webp` 有一条重出记录(旧条目标为「已被取代」,不参与哈希比对)。
|
|
140
|
+
|
|
141
|
+
> **整套八张随后又重出过一次**:这一套(和另外四套)原本**八张都没有鲸尾**。
|
|
142
|
+
> 重出时把鲸尾写进末尾编号清单(见上面「写作技巧」那节的第二个例子),八张一次到位;
|
|
143
|
+
> 实测键控 0.34%–0.48% 软边、取景 0%–27px 留白。母版在 `masters/night/`。
|
|
144
|
+
|
|
129
145
|
> 三套皮肤的差别是"**同一件人之外的东西换了**":换掉服装而不是换掉身份,所以三张站稳的图
|
|
130
146
|
> 放在一起仍然认得出是同一个人。这是刻意的 —— 皮肤不是不同角色。
|
|
131
147
|
|
|
@@ -215,6 +231,37 @@
|
|
|
215
231
|
| 服装 | 单层吊带短裙(本插件的风格) | **长袖全套女仆装 + 白袜 + 深蓝圆头皮鞋**(社区规范) |
|
|
216
232
|
| 鲸鳍耳 | 有 | 有(同样向后下斜掠、双色、下缘锯齿) |
|
|
217
233
|
|
|
234
|
+
## 第六套皮肤:碧海泳装(`swim`)
|
|
235
|
+
|
|
236
|
+
形象要素与母版**逐字相同**(深蓝中长直发、大圆环呆毛、鲸鳍耳、白色荷叶边女仆发箍 + 蓝色鲸鱼发夹、
|
|
237
|
+
鲸尾、细蓝颈饰、成年女性成熟身型、画风段、画面完整性段、构图段),**只替换【服装】段**:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
【服装】性感的深蓝色比基尼泳装:上身为深蓝色三角比基尼,细系带绕过颈后与后背;下身同色系带式泳裤,
|
|
241
|
+
两侧腰际各系一个深蓝色小蝴蝶结;外面披一件极薄的白色薄纱罩衫,前襟敞开、长度到大腿中部、
|
|
242
|
+
随身体自然垂落;不穿长袜;脚穿白色细带高跟凉鞋;手腕戴一圈细蓝色腕饰。
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
八张姿态照上表,一张不改(`eat` 用表里补上的那一行)。实测键控 **0.33%–1.39%** 软边,
|
|
246
|
+
入包那档合计 **990 KB**。
|
|
247
|
+
|
|
248
|
+
参数与前面几套完全一致:`gpt` / `4K` / `2048x3072` / `2:3` / `high` / `thinking high` /
|
|
249
|
+
**不传参考图、不图生图**,一张一次调用。九次调用(含一次重出)已按哈希链记入
|
|
250
|
+
`audit/generation-audit.jsonl`,全部来自 `generate_image`。
|
|
251
|
+
|
|
252
|
+
> **`happy` 出过三版,两次都是末尾清单救回来的**:
|
|
253
|
+
> ① 第一版身材与泳装比其余七张瘦、上衣还画成了横条 → 补「⑧ 成熟丰满的成年女性身型:胸部曲线明显,
|
|
254
|
+
> 不要画成瘦削」「⑨ 泳装必须清楚可辨:三角罩杯 + 颈后与后背的细系带」;
|
|
255
|
+
> ② 第二版身材对了,但**取景偏小**:内容只占画布高度 **93.2%**(头顶留白 70px、脚底 35px),
|
|
256
|
+
> 而其余七张是 96.7%–99.0% —— 切到「开心」时她会**明显缩一圈** → 再补「⑩ 取景必须与其余各张
|
|
257
|
+
> 完全一致:占满画面高度 97% 以上,上下留白各不超过 2%」「⑪ 体型比例必须与其余各张一致:
|
|
258
|
+
> 约 6.5–7 头身,头不要画小、腿与躯干不要拉长」;③ 第三版(入包版)占 **99.7%**。
|
|
259
|
+
>
|
|
260
|
+
> **教训**:举手、跳跃这类姿势最容易把人物画小,而「切表情时她缩了一下」只有把八张**并排量过**
|
|
261
|
+
> 才看得出来 —— 单看一张永远是对的。三版母版都留着(`masters/swim/_happy_v1.png` / `_happy_v2.png`),
|
|
262
|
+
> 审计日志里 `happy.webp` 有两条重出记录(旧条目标为「已被取代」,不参与哈希比对)。
|
|
263
|
+
> **末尾清单这一招又一次生效**(同「呆毛」那次的教训:写在开头的会被忽略)。
|
|
264
|
+
|
|
218
265
|
## 两个工具的分工
|
|
219
266
|
|
|
220
267
|
| 工具 | 输入 | 什么时候用 |
|
|
@@ -265,6 +312,14 @@
|
|
|
265
312
|
|
|
266
313
|
清单里的每一条都要**可核对**:与其说"画得好看",不如说"明显高出头发轮廓、是独立的一缕、不能被省略"。
|
|
267
314
|
|
|
315
|
+
**第二个例子:鲸尾(1.46.x 才发现的)。** 六套皮肤里只有新做的 `swim` **每一格都有鲸尾**,
|
|
316
|
+
其余五套的 39 张**一张都没有** —— 而「身后从腰部长出一条蓝色鲸尾,尾鳍自然向下,必须在画面中
|
|
317
|
+
清楚可见」这句明明写在身份段里。差别只在末尾清单:新那套写成编号的
|
|
318
|
+
「④ 从腰后长出来的蓝色鲸尾必须清楚可见:尾柄从腰侧或腰后伸出、尾鳍自然向下、尖端正对下方,
|
|
319
|
+
在画面里是一块独立而明显的深蓝色到浅蓝渐变的形状,**不能被头发、裙摆或双腿挡住,不能被省略**」;
|
|
320
|
+
旧那五套的清单里只有一句「鲸尾必须可见且与身体连接」。
|
|
321
|
+
**同一个要求:写成编号、并且说清"不能被什么挡住",才会被执行。**
|
|
322
|
+
|
|
268
323
|
## 三个踩过的坑
|
|
269
324
|
|
|
270
325
|
### 鲸鱼鳍 ≠ 鱼鳍(写"鳍"就会得到鱼鳍)
|
|
@@ -296,11 +351,12 @@
|
|
|
296
351
|
## 全流程
|
|
297
352
|
|
|
298
353
|
```bash
|
|
299
|
-
# 1.
|
|
300
|
-
#
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
# 1. 按上面的参数与提示词,为**八个**情绪各生成一张绿幕图,存成 <mood>.png
|
|
355
|
+
# 一张一次调用,不要把多张塞进 batch —— 提示词不同,混着出会串味
|
|
356
|
+
# 2. 绿幕 -> 抠图 -> 默认档素材(1024x1536 入包档),一步到位
|
|
357
|
+
node tools/assets.mjs --from <绿幕母版目录> --skin <皮肤>
|
|
358
|
+
# 3. 把素材打进客户端半包(默认档就是入包的那一档)
|
|
359
|
+
node build.mjs
|
|
304
360
|
# 4. 预检
|
|
305
361
|
node verify-wisp.mjs
|
|
306
362
|
```
|
package/tools/audit-log.mjs
CHANGED
|
@@ -99,7 +99,14 @@ export function verifyAudit(root = here) {
|
|
|
99
99
|
if (chainHash(e) !== e.hash) problems.push('第 ' + e.index + ' 条内容被改动(hash 不匹配)')
|
|
100
100
|
prev = e.hash
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
/* 同一个入包文件被**重出**过时,日志里会有多条 confirm —— 历史必须留着(那正是这个日志
|
|
103
|
+
存在的意义),但要比对的是**最新**那一条:它才代表现在入包的东西。旧条目标为「已被取代」,
|
|
104
|
+
记录在案、不再参与哈希比对;否则任何一次重出都会让预检永久变红。 */
|
|
105
|
+
const confirms = log.filter((e) => e.kind === 'confirm' && e.output && e.output.path)
|
|
106
|
+
const latestOf = new Map()
|
|
107
|
+
for (const e of confirms) latestOf.set(e.output.path, e)
|
|
108
|
+
const assets = [...latestOf.values()]
|
|
109
|
+
const superseded = confirms.filter((e) => latestOf.get(e.output.path) !== e)
|
|
103
110
|
for (const e of assets) {
|
|
104
111
|
const p = resolve(root, e.output.path)
|
|
105
112
|
if (!existsSync(p)) { problems.push(e.output.path + ' 不见了'); continue }
|
|
@@ -109,6 +116,7 @@ export function verifyAudit(root = here) {
|
|
|
109
116
|
const declaredNone = log.filter((e) => e.kind === 'declare' && e.declared
|
|
110
117
|
&& Array.isArray(e.declared.reference_images) && e.declared.reference_images.length === 0)
|
|
111
118
|
return { entries: log.length, assets, problems, img2img, declaredNone: declaredNone.length,
|
|
119
|
+
superseded: superseded.length,
|
|
112
120
|
backfilled: log.filter((e) => e.source === 'registry-backfill').length }
|
|
113
121
|
}
|
|
114
122
|
|
|
@@ -131,8 +139,9 @@ if (flag('--verify')) {
|
|
|
131
139
|
}
|
|
132
140
|
const img2img = log.filter((e) => e.registry && e.registry.tool === 'edit_image')
|
|
133
141
|
const unknown = log.filter((e) => e.registry && e.registry.tool && !['generate_image', 'batch_generate_images', 'edit_image'].includes(e.registry.tool))
|
|
134
|
-
console.log(` 记录 ${log.length} 条(declare ${log.filter((e) => e.kind === 'declare').length} / confirm ${
|
|
142
|
+
console.log(` 记录 ${log.length} 条(declare ${log.filter((e) => e.kind === 'declare').length} / confirm ${log.filter((e) => e.kind === 'confirm').length} / backfill ${log.filter((e) => e.source === 'registry-backfill').length})`)
|
|
135
143
|
console.log(` 覆盖入包素材 ${assets.length} 个,全部存在且哈希一致:${missing === 0 && bad === 0 ? '是' : '否'}`)
|
|
144
|
+
if (res.superseded > 0) console.log(` 另有 ${res.superseded} 条是同一文件的更早版本(重出记录:历史保留、不参与比对)`)
|
|
136
145
|
console.log(` 代理登记表显示使用过 edit_image(图生图)的条目:${img2img.length}`)
|
|
137
146
|
if (img2img.length > 0) for (const e of img2img) console.log(` ⚠ ${e.name} ${e.registry.createdAt}`)
|
|
138
147
|
if (unknown.length > 0) console.log(` ⚠ 未识别的工具名:${unknown.map((e) => e.registry.tool).join(', ')}`)
|