dsh-design-studio 0.4.1 → 0.5.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/lib/index.js CHANGED
@@ -2,27 +2,38 @@
2
2
  * dsh-design-studio(服务端)
3
3
  *
4
4
  * preview_page 工具 模型做界面时调一次,右侧栏「设计工作台」打开这个页面(可以带类型:网页 / App / 海报 …)
5
+ * check_design 工具 模型写完 / 改完后调:无头浏览器按真实尺寸量每一屏(设计体检,可指定几种尺寸),测量数字和问题清单交回给模型;
6
+ * 电脑上没有 Chrome / Edge 时让右侧栏在显示着的页面上量(browser.js、look.js)
7
+ * look_design 工具 无头浏览器按真实尺寸截每一屏(顶上 / 滚到底 / 从上到下 / 某个元素),图直接回给模型
5
8
  * 自动发现 模型写了 HTML、或命令输出里出现 localhost:端口(开发服务器起来了),自动预览
6
9
  * ui-design 技能 设计流程、设计原则、中文排版、各类型要点、避免 AI 味、自查清单(skills/ui-design/,DSH 有技能服务时才挂上)
7
10
  *
8
11
  * 拆成几行,每行在 DSH「插件」页里有自己的开关(见 cordis.patch.yml):
9
12
  * dsh-design-studio 工作台本体:右侧栏、接口、预览服务器、跟踪会话(这个文件的 apply)
10
13
  * dsh-design-studio/auto 自动打开预览(auto.js)
11
- * dsh-design-studio/tool preview_page 工具(tool.js)
14
+ * dsh-design-studio/tool preview_page、check_design、look_design 三个工具(tool.js)
12
15
  * dsh-design-studio/skill ui-design 技能(skill.js)
13
16
  * preset-design 设计模式:标准模式的全套工具 + 上面两行,人设换成做设计的
14
17
  * 工具和技能在顶层挂一份(所有模式都能用),设计模式里再挂一份(只关顶层那份,设计模式照样有)。
15
18
  * 接口(DSH 自己的地址,要登录态):
16
- * GET /dsh-design-studio/api/state?session= 右侧栏轮询:预览什么、有哪几屏、要不要弹出来、模型在不在干活
19
+ * GET /dsh-design-studio/api/state?session= 右侧栏轮询:预览什么、有哪几屏、要不要弹出来、模型在不在干活、
20
+ * 模型要不要体检(checkReq)
21
+ * POST /dsh-design-studio/api/check 体检结果交给 check_design { session, id, text, measured } 或 { session, id, error }
22
+ * (measured = 0:一屏都没量到,先不收,等别的右侧栏)
17
23
  * POST /dsh-design-studio/api/open 地址栏输入新地址 { session, target }
18
24
  * POST /dsh-design-studio/api/view 改类型 / 尺寸 / 视图,或换到另一屏 { session, kind?, size?, view?, focus? }
19
25
  * POST /dsh-design-studio/api/close 清掉预览 { session }
20
26
  * GET /dsh-design-studio/api/history?session= 设计版本(每轮对话前后各存一版),新的在前
21
27
  * POST /dsh-design-studio/api/restore 回到某一版 { session, n }(现在的先存一版)
22
28
  * POST /dsh-design-studio/api/export 存导出的图片 { session, name, png: base64 } → 设计目录的 exports/
29
+ * POST /dsh-design-studio/api/doc 几屏拼成文档 { session, format: 'pdf' | 'pptx', name, title, bg, pages: [{ jpg: base64, w, h, title, links }] }
30
+ * → 设计目录的 exports/(每屏一页;links: [{ x, y, w, h, to }] 点了跳到第 to 页)
23
31
  * POST /dsh-design-studio/api/ref 存参考图 { session, name, data: base64 } → 设计目录的 refs/(没有设计目录就存插件缓存)
24
32
  * POST /dsh-design-studio/api/reveal 在访达 / 资源管理器里显示刚导出的图片 { path }
25
33
  * POST /dsh-design-studio/api/edit 预览里直接改字 { session, key, before, after, nth } → 写回那一屏的 HTML(改前改后各存一版)
34
+ * POST /dsh-design-studio/api/style 预览里调样式 { session, key, loc, css, name } → 写进那个元素在源码里的 style 属性(改前改后各存一版)
35
+ * POST /dsh-design-studio/api/locate 样式规则在源码第几行 { session, items: [{ href, sel, n }] } → { items: [{ file, line } | null] }
36
+ * GET /dsh-design-studio/api/bundle?session=&key=&v=&parent= 远程看:这一屏连同样式、图片打成一份 → { html, deps }(放进沙盒 iframe;deps 改了再取)
26
37
  * POST /dsh-design-studio/api/phone 手机上看:{ session, on: true, path } 开局域网入口(给链接和二维码)、
27
38
  * { on: false } 关掉、{ follow: path } 电脑上换了一屏手机跟着跳
28
39
  *
@@ -34,11 +45,15 @@ import path from 'node:path'
34
45
  import { fileURLToPath } from 'node:url'
35
46
  import os from 'node:os'
36
47
  import { execFile } from 'node:child_process'
37
- import { createHub, webOrigins, trustedOrigins } from './serve.js'
38
- import { createTracker, parseTarget, KINDS } from './core.js'
48
+ import { createHub, webOrigins, trustedOrigins, resolveInRoot, ERR_CATCHER } from './serve.js'
49
+ import { bundle, inlinePicker, MAX_TOTAL as BUNDLE_MAX } from './bundle.js'
50
+ import { createTracker, parseTarget, findRule, KINDS } from './core.js'
39
51
  import { createHistory } from './history.js'
40
52
  import { createPhone } from './phone.js'
41
53
  import { qrMatrix } from './qr.js'
54
+ import { makePdf, makePptx, jpegInfo } from './doc.js'
55
+ import { createBrowser } from './browser.js'
56
+ import { designOf, metaOfFile, sizeOf, pickScreens, measureAll, shootAll, cropIssues, formatMeasure, issueSnapshot, formatShots, whereOf, MAX_SCREENS, MAX_SIZES, MAX_SHOTS, MAX_CROPS } from './look.js'
42
57
 
43
58
  export const name = 'dsh-design-studio'
44
59
  export const inject = ['webServer']
@@ -48,16 +63,23 @@ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
48
63
  const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
49
64
  const MAX_BODY = 64 * 1024
50
65
  const MAX_EXPORT = 48 * 1024 * 1024 // 导出图片的请求体(base64)
66
+ const MAX_DOC = 160 * 1024 * 1024 // 导出 PDF / PPT 的请求体(几十屏的 JPEG,base64)
67
+ const MAX_PAGES = 60
68
+ const CHECK_WAIT = 30000 // check_design 等右侧栏量完的最长时间(右侧栏每屏最多等 6 秒载入 + 15 秒量完,几屏一起量)
51
69
  const HISTORY_DIR = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio', 'history')
52
70
  // 每个会话预览的是什么,存这里(DSH 重启后接着显示)
53
71
  // 参考图:没有本地设计目录(开发服务器 / 还没开始)时存这里
54
72
  const REFS_DIR = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio', 'refs')
55
73
  const STORE_FILE = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio.json')
56
74
  const SKILL_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'ui-design')
75
+ const PICKER_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker.js')
57
76
  const SKILL_RANK = 600 // 和 DSH 自带的技能同一档;用户 / 项目里同名的技能优先
58
77
 
59
- /** 几行之间靠这个互通(同一个包里的模块 Node 只加载一次):工作台起来后放 tracker;「自动打开预览」那行开着时 autoOpen > 0 */
60
- export const studio = { tracker: null, autoOpen: 0 }
78
+ /**
79
+ * 几行之间靠这个互通(同一个包里的模块 Node 只加载一次):工作台起来后放 tracker、无头浏览器(browser);
80
+ * 「自动打开预览」那行开着时 autoOpen > 0
81
+ */
82
+ export const studio = { tracker: null, autoOpen: 0, check: null, browser: null, log: () => {} }
61
83
 
62
84
  /** 设计技能:模型做界面设计时加载(DSH 的 skill 工具),也能让用户手动调 */
63
85
  export const designSkill = {
@@ -139,6 +161,235 @@ export const previewTool = {
139
161
  },
140
162
  }
141
163
 
164
+ /** 这个会话在预览的设计:哪几屏、哪类、多大(工具在无头浏览器里量 / 截图用) */
165
+ function designFor(id) {
166
+ const snap = studio.tracker.snapshot(id)
167
+ const t = snap.target
168
+ const sc0 = (snap.screens || [])[0]
169
+ const first = t && t.kind === 'file' ? path.join(path.dirname(t.file), (sc0 && sc0.key) || path.basename(t.file)) : ''
170
+ const focus = t && t.kind === 'file' ? t.file : ''
171
+ return { snap, ...designOf(snap, () => metaOfFile(focus) || (first && first !== focus ? metaOfFile(first) : '')) }
172
+ }
173
+
174
+ /** 工具参数里的尺寸:'375x667' / 375 / ['375', '430x932'] → [[w, h]],认不出的记下来 */
175
+ function sizesFrom(v, kind) {
176
+ const list = Array.isArray(v) ? v : v == null || v === '' ? [] : [v]
177
+ const out = []
178
+ const bad = []
179
+ for (const x of list.slice(0, 12)) {
180
+ const s = sizeOf(x, kind)
181
+ if (!s) bad.push(String(x).slice(0, 20))
182
+ else if (!out.some(([w, h]) => w === s[0] && h === s[1])) out.push(s)
183
+ }
184
+ return { sizes: out.slice(0, MAX_SIZES), bad, more: Math.max(0, out.length - MAX_SIZES) }
185
+ }
186
+
187
+ const toolSession = (exec, what) => {
188
+ const session = exec && exec.agent && exec.agent.session
189
+ const id = session && session.id
190
+ if (!id) throw new Error(`拿不到当前会话,没法${what}`)
191
+ if (!studio.tracker) throw new Error(`「设计工作台」在 DSH 的插件页里关着,打开后再${what}`)
192
+ return id
193
+ }
194
+
195
+ /** check_design 工具:在无头浏览器里按真实尺寸量每一屏(没有浏览器就让右侧栏量),问题清单和测量数字交回给模型 */
196
+ export const checkTool = {
197
+ name: 'check_design',
198
+ description:
199
+ '量一下右侧「设计工作台」里的设计(默认每一屏、按设计的尺寸):文字被截断 / 超出框、文字叠在一起、对比度不够、点击区太小、字太小、字号太杂、超出屏幕、图片被拉变形 / 没加载出来、一行太长、短字断行、' +
200
+ '固定的顶栏 / 底栏压住内容(滚到底也看不全)或贴得太近、页面脚本报错、样式表 / 脚本 / 字体没载出来、跳到不存在的页面。' +
201
+ '在电脑上用无头浏览器按真实尺寸打开每一屏量,和右侧栏怎么显示无关;返回量了哪几屏、每种尺寸下页面多高、固定栏离内容多远,和问题清单(哪一屏、哪个元素的 CSS 选择器、具体数字)。' +
202
+ '写完一批屏、或改完之后调一次,照清单改源文件;要看小屏 / 大屏就给 sizes(如 ["375x667", "430x932"],或只写宽度)。比另开浏览器截图自查准,也省得多;要看长什么样用 look_design。' +
203
+ `严重的问题(被栏压住、文字叠在一起 / 被截断 / 超出框、超出屏幕、图片没出来、对比度很低)自动附上那一块的局部截图(最多 ${MAX_CROPS} 张,红框圈出元素)。` +
204
+ '要先用 preview_page 打开过页面。',
205
+ parameters: specToJsonSchema({
206
+ screens: { type: 'array', items: { type: 'string' }, description: '量哪几屏:文件名(home.html 或 home)或开发服务器的路由;不填量全部' },
207
+ sizes: { type: 'array', items: { type: 'string' }, description: '按哪几种尺寸量,如 ["375x667", "390x844", "430x932"],或只写宽度 ["320", "375"](高按机型);不填按设计的尺寸。最多 4 种' },
208
+ dark: { type: 'boolean', description: '切到深色量(页面带深色样式时)' },
209
+ images: { type: 'boolean', description: `严重问题附局部截图(默认附,最多 ${MAX_CROPS} 张);false 只要文字` },
210
+ }),
211
+ output: {
212
+ schema: {
213
+ type: 'object',
214
+ additionalProperties: false,
215
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
216
+ required: ['text'],
217
+ },
218
+ render: (_args, value) => withImages(value),
219
+ },
220
+ async execute(args, exec, ctx) {
221
+ const id = toolSession(exec, '体检')
222
+ const a = args || {}
223
+ if (!studio.tracker.snapshot(id).url) return { text: '还没有在预览的页面:先用 preview_page 打开设计文件,再体检。' }
224
+ const { snap, kind, size } = designFor(id)
225
+ const want = sizesFrom(a.sizes, kind)
226
+ const b = studio.browser
227
+ if (b && b.available()) {
228
+ try {
229
+ const pick = pickScreens(snap, a.screens, MAX_SCREENS)
230
+ if (!pick.list.length) return { text: `没找到要量的屏:${pick.missing.join('、')}。现在有:${(snap.screens || []).map((x) => x.key).join('、') || snap.focus}` }
231
+ const sizes = want.sizes.length ? want.sizes : [size]
232
+ const rows = await measureAll(b, { screens: pick.list, sizes, kind, dark: !!a.dark, locate: (items) => locateRules(studio.tracker.stateOf(id), items) })
233
+ if (rows.some((r) => !r.error)) {
234
+ // 同一个会话上次量的结果:这次比一比修好了几个、新出了几个
235
+ const prev = lastChecks.get(id) || null
236
+ // 严重问题的局部截图(截不成就不附;附件库用不了的老版本 DSH 不截,清单照样给)
237
+ let crops = []
238
+ if (a.images !== false && ctx && typeof ctx.get === 'function' && ctx.get('attachments')) {
239
+ try { crops = await cropIssues(b, rows, { screens: pick.list, dark: !!a.dark }) } catch (e) { studio.log(e) }
240
+ }
241
+ const kept = crops.length ? await keepImages(ctx, id, crops) : null
242
+ if (kept && !kept.images) crops = [] // 存进附件库失败:不提图
243
+ let text = formatMeasure(rows, { sizes, where: whereOf(snap), missing: pick.missing, more: pick.more, dark: !!a.dark, prev, crops })
244
+ const next = new Map(prev || [])
245
+ for (const [k, v] of issueSnapshot(rows, !!a.dark)) next.set(k, v)
246
+ lastChecks.delete(id)
247
+ lastChecks.set(id, next)
248
+ while (lastChecks.size > 20) lastChecks.delete(lastChecks.keys().next().value)
249
+ if (want.bad.length) text += `\n认不出的尺寸(没量):${want.bad.join('、')},写成 375x667 或 375`
250
+ if (want.more) text += `\n尺寸太多,只量了前 ${MAX_SIZES} 种`
251
+ return kept && kept.images ? { text, images: kept.images } : { text }
252
+ }
253
+ studio.log(new Error(`check_design 无头浏览器没量成:${rows[0] && rows[0].error}`))
254
+ } catch (e) {
255
+ studio.log(e)
256
+ }
257
+ }
258
+ // 没有无头浏览器(或者它没量成):让右侧栏在显示着的页面上量
259
+ if (!studio.check) return { text: '这台电脑上没找到能用的 Chrome / Edge,「设计工作台」也没开着,量不了。照 ui-design 技能里的「自查」清单自己过一遍。' }
260
+ const r = await studio.check(id)
261
+ const note = want.sizes.length || a.dark ? '(这台电脑上没找到能用的 Chrome / Edge,没法按指定尺寸 / 深色量;下面是右侧栏按它现在显示的尺寸量的)\n' : ''
262
+ if (!r) return { text: '右侧栏「设计工作台」现在没开着,量不了。照 ui-design 技能里的「自查」清单自己过一遍。' }
263
+ if (!r.error && !r.measured) return { text: '右侧栏开着,但预览页面没回应,这次没量成(可能还在加载)。过一会儿再调一次,或照 ui-design 技能里的「自查」清单自己过一遍。' }
264
+ return { text: note + (r.error || r.text) }
265
+ },
266
+ }
267
+
268
+ /**
269
+ * 浏览器给的规则(样式表地址 + 选择器 + 同一文件里第几条)→ 设计目录里的文件和行号。
270
+ * 只认这个会话正在预览的本地目录里的文件(≤ 2MB);别的地址、插件自己的、跑出目录的给 null
271
+ */
272
+ function locateRules(st, items) {
273
+ const t = st && st.target
274
+ if (!t || t.kind !== 'file' || !st.origin) return items.map(() => null)
275
+ const texts = new Map()
276
+ return items.map((it) => {
277
+ try {
278
+ const u = new URL(String((it && it.href) || ''))
279
+ if (u.origin !== st.origin || u.pathname.startsWith('/__dsh_design__/')) return null
280
+ const hit = resolveInRoot(t.root, u.pathname)
281
+ if (!hit || !hit.real) return null
282
+ if (!texts.has(hit.real)) {
283
+ const stat = fs.statSync(hit.real)
284
+ texts.set(hit.real, stat.isFile() && stat.size <= 2 * 1024 * 1024 ? fs.readFileSync(hit.real, 'utf8') : '')
285
+ }
286
+ const rel = path.relative(t.root, hit.abs).split(path.sep).join('/')
287
+ return { file: rel, line: findRule(texts.get(hit.real), String(it.sel || ''), Math.max(0, Math.floor(Number(it.n) || 0))) }
288
+ } catch {
289
+ return null
290
+ }
291
+ })
292
+ }
293
+
294
+ /** check_design 每个会话上次量出来的问题(复查时比修好了几个、新出了几个);只记最近 20 个会话 */
295
+ const lastChecks = new Map()
296
+
297
+ /** 存成文件的截图(老版本 DSH)只留一天 */
298
+ function pruneLookDir(maxAge = 24 * 3600 * 1000) {
299
+ let dirs = []
300
+ try { dirs = fs.readdirSync(LOOK_DIR) } catch { return }
301
+ const old = Date.now() - maxAge
302
+ for (const d of dirs) {
303
+ const dir = path.join(LOOK_DIR, d)
304
+ try {
305
+ for (const f of fs.readdirSync(dir)) {
306
+ const p = path.join(dir, f)
307
+ if (fs.statSync(p).mtimeMs < old) fs.rmSync(p, { force: true })
308
+ }
309
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
310
+ } catch { /* 删不掉下次再说 */ }
311
+ }
312
+ }
313
+
314
+ /** look_design 截的图存进 DSH 的附件库;附件引用(可序列化)放在工具结果里,render 直接读 */
315
+ const LOOK_DIR = path.join(os.tmpdir(), 'dsh-design-look')
316
+
317
+ /** look_design 工具:在无头浏览器里按真实尺寸打开每一屏截图,图直接回给模型看 */
318
+ export const lookTool = {
319
+ name: 'look_design',
320
+ description:
321
+ '看一眼右侧「设计工作台」里的设计长什么样:在电脑上用无头浏览器按真实尺寸打开每一屏,截图直接回给你(真实渲染的像素)。' +
322
+ '默认每屏截顶上一屏;at=bottom 滚到底(看底栏有没有压住内容)、at=full 从上到下每屏一张;selector 只截一个元素;size 换尺寸(如 375x667 或 375);dark 看深色。' +
323
+ `一次最多 ${MAX_SHOTS} 张。自己检查视觉效果(层次、留白、配色、图片、整体观感)用这个;要问题清单和数字用 check_design。要先用 preview_page 打开过页面。`,
324
+ parameters: specToJsonSchema({
325
+ screens: { type: 'array', items: { type: 'string' }, description: `截哪几屏:文件名(home.html 或 home)或开发服务器的路由;不填截全部(最多 ${MAX_SHOTS} 屏)` },
326
+ size: { type: 'string', description: '尺寸,如 375x667,或只写宽度 375(高按机型);不填按设计的尺寸' },
327
+ at: { type: 'string', enum: ['top', 'bottom', 'full'], description: 'top 顶上一屏(默认)、bottom 滚到底、full 从上到下每屏一张' },
328
+ selector: { type: 'string', description: '只截这个元素(CSS 选择器),如 .tabbar、#hero' },
329
+ dark: { type: 'boolean', description: '切到深色看(页面带深色样式时)' },
330
+ }),
331
+ output: {
332
+ schema: {
333
+ type: 'object',
334
+ additionalProperties: false,
335
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
336
+ required: ['text'],
337
+ },
338
+ render: (_args, value) => withImages(value),
339
+ },
340
+ async execute(args, exec, ctx) {
341
+ const id = toolSession(exec, '截图')
342
+ const a = args || {}
343
+ if (!studio.tracker.snapshot(id).url) return { text: '还没有在预览的页面:先用 preview_page 打开设计文件,再看。' }
344
+ const b = studio.browser
345
+ if (!b || !b.available()) return { text: '这台电脑上没找到 Chrome / Edge(无头浏览器截图要用),看不了。用 check_design 拿测量数字,或者让用户看右侧栏。' }
346
+ const { snap, kind, size } = designFor(id)
347
+ const want = a.size ? sizeOf(a.size, kind) : size
348
+ if (!want) return { text: `认不出尺寸「${String(a.size).slice(0, 20)}」:写成 375x667,或只写宽度 375。` }
349
+ const at = ['top', 'bottom', 'full'].includes(a.at) ? a.at : 'top'
350
+ const pick = pickScreens(snap, a.screens, MAX_SHOTS)
351
+ if (!pick.list.length) return { text: `没找到要截的屏:${pick.missing.join('、')}。现在有:${(snap.screens || []).map((x) => x.key).join('、') || snap.focus}` }
352
+ const shots = await shootAll(b, { screens: pick.list, size: want, at, selector: a.selector, dark: !!a.dark, max: MAX_SHOTS })
353
+ const good = shots.filter((s) => s.jpg).slice(0, MAX_SHOTS)
354
+ const text = formatShots(shots, { where: whereOf(snap), missing: pick.missing, more: pick.more })
355
+ if (!good.length) return { text }
356
+ const kept = await keepImages(ctx, id, good, true)
357
+ if (kept.images) return { text, images: kept.images }
358
+ return { text: `${text}\n(这个 DSH 版本没法把图直接给你,存成了文件:\n${kept.files.join('\n')})` }
359
+ },
360
+ }
361
+
362
+ /** 工具结果显示成:文字 + 一张张图 */
363
+ function withImages(value) {
364
+ const blocks = [{ type: 'text', text: value.text }]
365
+ for (const ref of Array.isArray(value.images) ? value.images : []) blocks.push({ type: 'image', attachment: ref })
366
+ return blocks
367
+ }
368
+
369
+ /**
370
+ * 截的图交给 DSH:存进附件库,附件引用(可序列化)放进工具结果,render 直接读 → { images }。
371
+ * 附件库用不了(老版本 DSH):toFile 时存成文件 → { files }(一天前的顺手删掉),否则 {}
372
+ */
373
+ async function keepImages(ctx, id, list, toFile = false) {
374
+ const attachments = ctx && typeof ctx.get === 'function' ? ctx.get('attachments') : null
375
+ if (attachments) {
376
+ try {
377
+ const refs = await attachments.saveImages(list.map((s, i) => ({ data: s.jpg, mediaType: 'image/jpeg', name: `design-${String(s.key).replace(/[^\w-]+/g, '-')}-${i + 1}.jpg` })))
378
+ return { images: [...refs] }
379
+ } catch (e) { studio.log(e) }
380
+ }
381
+ if (!toFile) return {}
382
+ pruneLookDir()
383
+ const dir = path.join(LOOK_DIR, String(id).replace(/[^\w-]+/g, '_'))
384
+ fs.mkdirSync(dir, { recursive: true })
385
+ const files = list.map((s, i) => {
386
+ const f = path.join(dir, `${Date.now().toString(36)}-${i + 1}-${String(s.key).replace(/[^\w.-]+/g, '-')}.jpg`)
387
+ fs.writeFileSync(f, s.jpg)
388
+ return f
389
+ })
390
+ return { files }
391
+ }
392
+
142
393
  const fileStore = {
143
394
  load: () => JSON.parse(fs.readFileSync(STORE_FILE, 'utf8')),
144
395
  save: (data) => {
@@ -221,8 +472,8 @@ export function apply(ctx) {
221
472
  const conn = connectionOf(ctx)
222
473
  return [...own, ...trustedOrigins(conn && conn.trustedHosts)]
223
474
  },
224
- onChange: (key) => {
225
- if (tracker && key.startsWith('file:')) tracker.changed(key.slice(5))
475
+ onChange: (key, file, files) => {
476
+ if (tracker && key.startsWith('file:')) tracker.changed(key.slice(5), files)
226
477
  },
227
478
  })
228
479
  const open = async (target, opts) => {
@@ -247,8 +498,18 @@ export function apply(ctx) {
247
498
  ctx.effect(() => () => hub.closeAll(), 'dsh-design-studio: preview servers')
248
499
  ctx.effect(() => {
249
500
  studio.tracker = tracker
250
- return () => { if (studio.tracker === tracker) studio.tracker = null }
501
+ studio.log = log
502
+ return () => { if (studio.tracker === tracker) { studio.tracker = null; studio.log = () => {} } }
251
503
  }, 'dsh-design-studio: tracker')
504
+ // check_design / look_design 用的无头浏览器:用到时才开,闲置自己关,这一行停用时关掉
505
+ ctx.effect(() => {
506
+ const browser = createBrowser({ log })
507
+ studio.browser = browser
508
+ return () => {
509
+ browser.destroy()
510
+ if (studio.browser === browser) studio.browser = null
511
+ }
512
+ }, 'dsh-design-studio: headless browser')
252
513
  const phone = createPhone({ upstream: (session) => tracker.snapshot(session).origin || '', log })
253
514
  ctx.effect(() => () => phone.stopAll(), 'dsh-design-studio: phone links')
254
515
 
@@ -271,11 +532,55 @@ export function apply(ctx) {
271
532
  return true
272
533
  }
273
534
 
535
+ // ── 模型调 check_design:轮询里带上 checkReq,右侧栏量完 POST /api/check 交回来;右侧栏没开就等到超时 ──
536
+ const checks = new Map()
537
+ let checkSeq = 0
538
+ function requestCheck(session, ms = CHECK_WAIT) {
539
+ const had = checks.get(session)
540
+ if (had) return had.promise
541
+ const c = { id: `c${++checkSeq}-${Date.now().toString(36)}` }
542
+ c.promise = new Promise((resolve) => {
543
+ c.done = (r) => {
544
+ clearTimeout(c.timer)
545
+ if (checks.get(session) === c) checks.delete(session)
546
+ resolve(r)
547
+ }
548
+ c.timer = setTimeout(() => c.done(c.fallback || null), ms)
549
+ })
550
+ checks.set(session, c)
551
+ return c.promise
552
+ }
553
+ ctx.effect(() => {
554
+ studio.check = requestCheck
555
+ return () => {
556
+ if (studio.check === requestCheck) studio.check = null
557
+ for (const c of [...checks.values()]) c.done(null)
558
+ }
559
+ }, 'dsh-design-studio: check')
560
+
274
561
  function state(req, res) {
275
562
  if (!admit(req, res)) return
276
563
  const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
277
564
  const ph = phone.status(session)
278
- json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 } })
565
+ const c = checks.get(session)
566
+ json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 }, ...(c ? { checkReq: c.id } : {}) })
567
+ }
568
+
569
+ async function checkRoute(req, res) {
570
+ if (!admit(req, res)) return
571
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
572
+ let body
573
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
574
+ const c = checks.get(String(body.session || ''))
575
+ if (!c || c.id !== body.id) return json(res, 200, { ok: false })
576
+ const r = { text: String(body.text || '').slice(0, 20000), error: String(body.error || '').slice(0, 500), measured: Number(body.measured) || 0 }
577
+ // 一屏都没量到:先记着,等别的右侧栏(比如电脑上的)交真结果;到点还没有就用它
578
+ if (!r.error && !r.measured) {
579
+ c.fallback = r
580
+ return json(res, 200, { ok: true, held: true })
581
+ }
582
+ c.done(r)
583
+ json(res, 200, { ok: true })
279
584
  }
280
585
 
281
586
  async function openRoute(req, res) {
@@ -353,6 +658,73 @@ export function apply(ctx) {
353
658
  }
354
659
  }
355
660
 
661
+ /** 预览里调样式:写进那个元素在源码里的 style 属性;找不到它就告诉右侧栏(它会改成让模型去改) */
662
+ async function styleRoute(req, res) {
663
+ if (!admit(req, res)) return
664
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
665
+ let body
666
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
667
+ try {
668
+ json(res, 200, tracker.editStyle(String(body.session || ''), { key: body.key, loc: body.loc, css: body.css, name: body.name }))
669
+ } catch (e) {
670
+ json(res, 500, { error: e.message })
671
+ }
672
+ }
673
+
674
+ /**
675
+ * 调样式交给模型前:每项样式胜出的那条规则在源码第几行(picker 给 { href, sel, n },这里在设计目录的文件里找)。
676
+ * 只认这个会话预览的本地目录里的文件;开发服务器的样式多半是编译出来的,不找
677
+ */
678
+ async function locateRoute(req, res) {
679
+ if (!admit(req, res)) return
680
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
681
+ let body
682
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
683
+ const items = Array.isArray(body.items) ? body.items.slice(0, 12) : []
684
+ json(res, 200, { items: locateRules(tracker.stateOf(String(body.session || '')), items) })
685
+ }
686
+
687
+ /**
688
+ * 远程看(手机走外网 / 别的电脑打开 DSH 网页,连不上本机的预览端口):这一屏连同样式、图片打成一份 HTML(见 bundle.js),
689
+ * 右侧栏放进沙盒 iframe。回 JSON(不直接给 HTML:在 DSH 的地址下直接打开时,页面脚本不能拿 DSH 的源跑)。
690
+ * parent:右侧栏所在的源,picker 只和它说话。v:看旧版本。
691
+ */
692
+ function bundleRoute(req, res) {
693
+ if (!admit(req, res)) return
694
+ const q = new URL(req.url, 'http://x').searchParams
695
+ const st = tracker.stateOf(q.get('session') || '')
696
+ const t = st && st.target
697
+ if (!t || t.kind !== 'file') return json(res, 404, { error: 'not-file' })
698
+ const key = q.get('key') || path.basename(t.file)
699
+ if (!/^[^\\]+\.html?$/i.test(key) || key.split('/').some((p) => !p || p === '.' || p === '..')) return json(res, 400, { error: 'bad key' })
700
+ const v = Math.max(0, Math.floor(Number(q.get('v')) || 0))
701
+ let parent = ''
702
+ try { const u = new URL(q.get('parent') || ''); if (u.protocol === 'http:' || u.protocol === 'https:') parent = u.origin } catch { parent = '' }
703
+ const deps = new Set() // 这一屏用到的文件(含没找到的):右侧栏看到这些改了才重新打包
704
+ const read = (rel) => {
705
+ deps.add(rel)
706
+ // 先看大小,太大的不读(打包总量也封顶,见 bundle.js)
707
+ const small = (f) => { const s = fs.statSync(f); return s.isFile() && s.size <= BUNDLE_MAX }
708
+ if (v) {
709
+ const f = history.fileOf(t.root, v, rel)
710
+ if (f) return small(f) ? fs.readFileSync(f) : null
711
+ }
712
+ const hit = resolveInRoot(t.root, '/' + rel.split('/').map(encodeURIComponent).join('/'))
713
+ if (!hit || !hit.real || !small(hit.real)) return null
714
+ return fs.readFileSync(hit.real)
715
+ }
716
+ try {
717
+ const page = read(key)
718
+ if (!page) return json(res, 404, { error: 'no-screen' })
719
+ const href = st.origin ? `${st.origin}/${v ? `__dsh_design__/v/${v}/` : ''}${key.split('/').map(encodeURIComponent).join('/')}` : ''
720
+ const html = bundle(page.toString('utf8'), { key, read, base: href, lead: ERR_CATCHER, tail: inlinePicker(fs.readFileSync(PICKER_FILE, 'utf8'), parent, href) })
721
+ // 用到的文件太多就不列(null = 右侧栏不按文件筛,目录里一有改动就重新打包)
722
+ json(res, 200, { html, deps: deps.size > 200 ? null : [...deps] })
723
+ } catch (e) {
724
+ json(res, 500, { error: e.message })
725
+ }
726
+ }
727
+
356
728
  const exported = new Set() // 这次运行里导出过的图片(只许在访达里显示这些)
357
729
 
358
730
  /** 导出的图片存哪:本地文件预览存设计目录的 exports/,开发服务器存「下载」 */
@@ -362,26 +734,61 @@ export function apply(ctx) {
362
734
  return path.join(os.homedir(), 'Downloads')
363
735
  }
364
736
 
737
+ /** 存进导出目录:文件名只留字母数字中文和 -,同名不覆盖(加 -2、-3 …) */
738
+ function saveExport(res, session, name, ext, buf) {
739
+ const base = String(name || 'design').replace(new RegExp(`\\.${ext}$`, 'i'), '').replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'design'
740
+ const dir = exportDir(session)
741
+ try {
742
+ fs.mkdirSync(dir, { recursive: true })
743
+ let file = path.join(dir, `${base}.${ext}`)
744
+ for (let i = 2; fs.existsSync(file) && i < 1000; i++) file = path.join(dir, `${base}-${i}.${ext}`)
745
+ fs.writeFileSync(file, buf)
746
+ exported.add(file)
747
+ json(res, 200, { path: file })
748
+ } catch (e) {
749
+ json(res, 500, { error: e.message })
750
+ }
751
+ }
752
+
365
753
  async function exportRoute(req, res) {
366
754
  if (!admit(req, res)) return
367
755
  if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
368
756
  let body
369
757
  try { body = await readBody(req, MAX_EXPORT) } catch (e) { return json(res, 400, { error: e.message }) }
370
- const session = String(body.session || '')
371
758
  const buf = Buffer.from(String(body.png || ''), 'base64')
372
759
  if (buf.length < 8 || buf.readUInt32BE(0) !== 0x89504e47) return json(res, 400, { error: '不是 PNG' })
373
- const base = String(body.name || 'design').replace(/\.png$/i, '').replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'design'
374
- const dir = exportDir(session)
760
+ saveExport(res, String(body.session || ''), body.name, 'png', buf)
761
+ }
762
+
763
+ /** 几屏拼成 PDF / PPT:每屏一页,页面大小按那一屏的 CSS 尺寸 */
764
+ async function docRoute(req, res) {
765
+ if (!admit(req, res)) return
766
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
767
+ let body
768
+ try { body = await readBody(req, MAX_DOC) } catch (e) { return json(res, 400, { error: e.message }) }
769
+ const format = body.format === 'pdf' || body.format === 'pptx' ? body.format : ''
770
+ if (!format) return json(res, 400, { error: 'format 只能是 pdf / pptx' })
771
+ const pages = []
772
+ for (const p of Array.isArray(body.pages) ? body.pages.slice(0, MAX_PAGES) : []) {
773
+ const jpg = Buffer.from(String((p && p.jpg) || ''), 'base64')
774
+ const info = jpegInfo(jpg)
775
+ if (!info) return json(res, 400, { error: '不是 JPEG' })
776
+ // 页面大小(CSS 像素):没给或不像话就按图片的一半算(截图是 2 倍)
777
+ const ok = (v) => Number.isFinite(+v) && +v >= 16 && +v <= 30000
778
+ const w = ok(p.w) && ok(p.h) ? +p.w : info.width / 2
779
+ const h = ok(p.w) && ok(p.h) ? +p.h : info.height / 2
780
+ // 跳转链接原样带过去,doc.js 里挑掉不合法的
781
+ pages.push({ jpg, w, h, title: String(p.title || '').slice(0, 80), links: Array.isArray(p.links) ? p.links.slice(0, 50) : [] })
782
+ }
783
+ if (!pages.length) return json(res, 400, { error: '没有页面' })
784
+ const title = String(body.title || '').slice(0, 120)
785
+ let buf
375
786
  try {
376
- fs.mkdirSync(dir, { recursive: true })
377
- let file = path.join(dir, base + '.png')
378
- for (let i = 2; fs.existsSync(file) && i < 1000; i++) file = path.join(dir, `${base}-${i}.png`)
379
- fs.writeFileSync(file, buf)
380
- exported.add(file)
381
- json(res, 200, { path: file })
787
+ buf = format === 'pdf' ? makePdf(pages, { title }) : makePptx(pages, { title, bg: String(body.bg || '') })
382
788
  } catch (e) {
383
- json(res, 500, { error: e.message })
789
+ return json(res, 400, { error: e.message })
384
790
  }
791
+ saveExport(res, String(body.session || ''), body.name, format, buf)
385
792
  }
386
793
 
387
794
  /** 图片格式看文件头,不信文件名 */
@@ -450,7 +857,7 @@ export function apply(ctx) {
450
857
  json(res, 200, st.on ? { ...st, qr: qrMatrix(st.url).map((row) => row.map((c) => (c ? 1 : 0)).join('')) } : st)
451
858
  }
452
859
 
453
- const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, ref: refRoute, export: exportRoute, reveal: revealRoute, phone: phoneRoute }
860
+ const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, style: styleRoute, bundle: bundleRoute, ref: refRoute, export: exportRoute, doc: docRoute, reveal: revealRoute, phone: phoneRoute, check: checkRoute, locate: locateRoute }
454
861
  for (const [key, handler] of Object.entries(routes)) {
455
862
  ctx.effect(() => ctx.webServer.register({
456
863
  kind: 'exact',