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.
Files changed (70) hide show
  1. package/README.md +666 -29
  2. package/assets/canon/attn.webp +0 -0
  3. package/assets/canon/eat.webp +0 -0
  4. package/assets/canon/happy.webp +0 -0
  5. package/assets/canon/idle.webp +0 -0
  6. package/assets/canon/poked.webp +0 -0
  7. package/assets/canon/proud.webp +0 -0
  8. package/assets/canon/sleepy.webp +0 -0
  9. package/assets/canon/work.webp +0 -0
  10. package/assets/classic/attn.webp +0 -0
  11. package/assets/classic/eat.webp +0 -0
  12. package/assets/classic/happy.webp +0 -0
  13. package/assets/classic/idle.webp +0 -0
  14. package/assets/classic/poked.webp +0 -0
  15. package/assets/classic/proud.webp +0 -0
  16. package/assets/classic/work.webp +0 -0
  17. package/assets/deepsea/attn.webp +0 -0
  18. package/assets/deepsea/eat.webp +0 -0
  19. package/assets/deepsea/happy.webp +0 -0
  20. package/assets/deepsea/idle.webp +0 -0
  21. package/assets/deepsea/poked.webp +0 -0
  22. package/assets/deepsea/proud.webp +0 -0
  23. package/assets/deepsea/sleepy.webp +0 -0
  24. package/assets/deepsea/work.webp +0 -0
  25. package/assets/motion/idle.webp +0 -0
  26. package/assets/motion/sleepy.webm +0 -0
  27. package/assets/motion/sleepy.webp +0 -0
  28. package/assets/motion/swim_attn.webp +0 -0
  29. package/assets/motion/swim_eat.webp +0 -0
  30. package/assets/motion/swim_happy.webp +0 -0
  31. package/assets/motion/swim_idle.webp +0 -0
  32. package/assets/motion/swim_poked.webp +0 -0
  33. package/assets/motion/swim_proud.webp +0 -0
  34. package/assets/motion/swim_sleepy.webp +0 -0
  35. package/assets/motion/swim_work.webp +0 -0
  36. package/assets/night/attn.webp +0 -0
  37. package/assets/night/eat.webp +0 -0
  38. package/assets/night/happy.webp +0 -0
  39. package/assets/night/idle.webp +0 -0
  40. package/assets/night/poked.webp +0 -0
  41. package/assets/night/proud.webp +0 -0
  42. package/assets/night/sleepy.webp +0 -0
  43. package/assets/night/work.webp +0 -0
  44. package/assets/pajama/attn.webp +0 -0
  45. package/assets/pajama/eat.webp +0 -0
  46. package/assets/pajama/happy.webp +0 -0
  47. package/assets/pajama/idle.webp +0 -0
  48. package/assets/pajama/poked.webp +0 -0
  49. package/assets/pajama/proud.webp +0 -0
  50. package/assets/pajama/sleepy.webp +0 -0
  51. package/assets/pajama/work.webp +0 -0
  52. package/assets/swim/attn.webp +0 -0
  53. package/assets/swim/eat.webp +0 -0
  54. package/assets/swim/happy.webp +0 -0
  55. package/assets/swim/idle.webp +0 -0
  56. package/assets/swim/poked.webp +0 -0
  57. package/assets/swim/proud.webp +0 -0
  58. package/assets/swim/sleepy.webp +0 -0
  59. package/assets/swim/work.webp +0 -0
  60. package/audit/generation-audit.jsonl +57 -0
  61. package/build.mjs +130 -8
  62. package/cordis.patch.yml +5 -0
  63. package/lib/client.js +1014 -104
  64. package/lib/client.template.js +953 -65
  65. package/lib/index.js +311 -16
  66. package/package.json +1 -1
  67. package/tools/CHARACTER-PROMPT.md +65 -9
  68. package/tools/audit-log.mjs +11 -2
  69. package/tools/engine-probe.mjs +500 -2
  70. 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.42.0'
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
- * @returns the service, or undefined when this shell really has none.
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
- const late = ctx.get('web')
69
- if (late && typeof late.fetch === 'function') return late
70
- }
71
- } catch (error) { /* restricted context: fall through to the declared property */ }
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 declared = ctx && ctx.web
74
- if (declared && typeof declared.fetch === 'function') return declared
75
- } catch (error) { /* no such property */ }
76
- return undefined
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 || typeof web.fetch !== 'function') return { ok: false, reason: 'no-web-service' }
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 web = resolveWebService(ctx)
309
- const verdict = await readPublishedVersion(web, urls, undefined, CHECK_TIMEOUT_MS)
310
- return { ...verdict, current, checkedAt: new Date().toISOString() }
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.42.0",
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
- ## 【姿态】七选一(对应七个 sprite)
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. 按上面的参数与提示词,为四个情绪各生成一张绿幕图,存成 <mood>.png
300
- # 2. 绿幕 -> 抠图 -> 四档素材,一步到位
301
- node tools/assets.mjs --from <绿幕母版目录>
302
- # 3. 把素材打进客户端半包
303
- node build.mjs --tier=hi # hi 档 = 2048x3072,与母版同尺寸,仅重新编码
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
  ```
@@ -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
- const assets = log.filter((e) => e.kind === 'confirm' && e.output && e.output.path)
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 ${assets.length} / backfill ${log.filter((e) => e.source === 'registry-backfill').length})`)
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(', ')}`)