dsh-design-studio 0.4.1 → 0.6.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,18 @@ 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'
57
+ import { SYSTEMS_DIR, extractSystem, listSystems, setDefault, skillSection } from './system.js'
58
+ import { readTweaks, applyTweaks, lintTweaks } from './tweaks.js'
59
+ import { FRAMEWORKS, detectProject, buildHandoff, compareScreens, implUrl, defaultDest, within } from './handoff.js'
42
60
 
43
61
  export const name = 'dsh-design-studio'
44
62
  export const inject = ['webServer']
@@ -48,24 +66,32 @@ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
48
66
  const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
49
67
  const MAX_BODY = 64 * 1024
50
68
  const MAX_EXPORT = 48 * 1024 * 1024 // 导出图片的请求体(base64)
69
+ const MAX_DOC = 160 * 1024 * 1024 // 导出 PDF / PPT 的请求体(几十屏的 JPEG,base64)
70
+ const MAX_PAGES = 60
71
+ const CHECK_WAIT = 30000 // check_design 等右侧栏量完的最长时间(右侧栏每屏最多等 6 秒载入 + 15 秒量完,几屏一起量)
51
72
  const HISTORY_DIR = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio', 'history')
52
73
  // 每个会话预览的是什么,存这里(DSH 重启后接着显示)
53
74
  // 参考图:没有本地设计目录(开发服务器 / 还没开始)时存这里
54
75
  const REFS_DIR = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio', 'refs')
55
76
  const STORE_FILE = path.join(os.homedir(), '.dsh', 'cache', 'dsh-design-studio.json')
56
77
  const SKILL_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'ui-design')
78
+ const PICKER_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker.js')
57
79
  const SKILL_RANK = 600 // 和 DSH 自带的技能同一档;用户 / 项目里同名的技能优先
58
80
 
59
- /** 几行之间靠这个互通(同一个包里的模块 Node 只加载一次):工作台起来后放 tracker;「自动打开预览」那行开着时 autoOpen > 0 */
60
- export const studio = { tracker: null, autoOpen: 0 }
81
+ /**
82
+ * 几行之间靠这个互通(同一个包里的模块 Node 只加载一次):工作台起来后放 tracker、无头浏览器(browser);
83
+ * 「自动打开预览」那行开着时 autoOpen > 0
84
+ */
85
+ export const studio = { tracker: null, autoOpen: 0, check: null, browser: null, log: () => {} }
61
86
 
62
87
  /** 设计技能:模型做界面设计时加载(DSH 的 skill 工具),也能让用户手动调 */
63
88
  export const designSkill = {
64
89
  name: 'ui-design',
65
90
  description:
66
91
  '做网页、App 界面、平板界面、海报 / 社媒图、幻灯片、邮件、组件这类界面设计之前先加载:' +
67
- '配合右侧「设计工作台」画布的做法(一屏一个文件、设计变量、类型标记)、现代设计原则、中文排版、各类型画幅要点、避免 AI 味、设计自查清单。',
68
- whenToUse: '用户要做或改任何看得见的界面 / 视觉设计时',
92
+ '配合右侧「设计工作台」画布的做法(一屏一个文件、设计变量、类型标记)、现代设计原则、中文排版、各类型画幅要点、避免 AI 味、设计自查清单。' +
93
+ '只是把回答做成一页给用户看或用(对比、表格、清单、图表、计算器这类)不算界面设计:有 show_page 工具就直接用它,不加载这个技能。',
94
+ whenToUse: '用户要做或改网站 / App / 海报 / 幻灯片这类界面或视觉设计时(把回答做成一页、有 show_page 工具时用 show_page)',
69
95
  invocation: { modelInvocable: true, userInvocable: true },
70
96
  provider: 'dsh-design-studio',
71
97
  source: 'bundled',
@@ -79,7 +105,10 @@ export const skillProvider = {
79
105
  list: () => Promise.resolve([designSkill]),
80
106
  async get() {
81
107
  const { name, description, whenToUse, invocation, provider, source, resourceBase } = designSkill
82
- return { name, description, whenToUse, invocation, provider, source, resourceBase, content: await fs.promises.readFile(designSkill.locator, 'utf8') }
108
+ // 用户的设计系统(默认用哪套、库里有哪些)每次现读,提了新的下次加载技能就知道
109
+ let extra = ''
110
+ try { extra = skillSection() } catch (e) { studio.log(e) }
111
+ return { name, description, whenToUse, invocation, provider, source, resourceBase, content: (await fs.promises.readFile(designSkill.locator, 'utf8')) + extra }
83
112
  },
84
113
  }
85
114
 
@@ -89,6 +118,7 @@ export const previewTool = {
89
118
  description:
90
119
  '在右侧栏「设计工作台」里实时打开一个页面,用户能边看边点选要改的地方。' +
91
120
  '做网页、App 界面、海报、幻灯片、邮件这类界面设计时用:写好第一个 HTML 文件、或启动开发服务器后调一次就行,之后改文件会自动刷新,不用再调。' +
121
+ '只是把回答做成一页(对比、表格、清单、图表、计算器)给用户看或用时,有 show_page 工具就用它,不用这里。' +
92
122
  '本地 HTML 写文件路径;开发服务器写地址或端口(如 http://localhost:5173)。只能预览本机页面。' +
93
123
  'kind 说明是哪类设计,右侧按它的画幅显示(App 套手机外框、幻灯片 16:9 …)。' +
94
124
  '动手设计前先加载 ui-design 技能(设计流程、规范和自查清单)。' +
@@ -139,6 +169,406 @@ export const previewTool = {
139
169
  },
140
170
  }
141
171
 
172
+ /** 这个会话在预览的设计:哪几屏、哪类、多大(工具在无头浏览器里量 / 截图用) */
173
+ function designFor(id) {
174
+ const snap = studio.tracker.snapshot(id)
175
+ const t = snap.target
176
+ const sc0 = (snap.screens || [])[0]
177
+ const first = t && t.kind === 'file' ? path.join(path.dirname(t.file), (sc0 && sc0.key) || path.basename(t.file)) : ''
178
+ const focus = t && t.kind === 'file' ? t.file : ''
179
+ return { snap, ...designOf(snap, () => metaOfFile(focus) || (first && first !== focus ? metaOfFile(first) : '')) }
180
+ }
181
+
182
+ /** 工具参数里的尺寸:'375x667' / 375 / ['375', '430x932'] → [[w, h]],认不出的记下来 */
183
+ function sizesFrom(v, kind) {
184
+ const list = Array.isArray(v) ? v : v == null || v === '' ? [] : [v]
185
+ const out = []
186
+ const bad = []
187
+ for (const x of list.slice(0, 12)) {
188
+ const s = sizeOf(x, kind)
189
+ if (!s) bad.push(String(x).slice(0, 20))
190
+ else if (!out.some(([w, h]) => w === s[0] && h === s[1])) out.push(s)
191
+ }
192
+ return { sizes: out.slice(0, MAX_SIZES), bad, more: Math.max(0, out.length - MAX_SIZES) }
193
+ }
194
+
195
+ const toolSession = (exec, what) => {
196
+ const session = exec && exec.agent && exec.agent.session
197
+ const id = session && session.id
198
+ if (!id) throw new Error(`拿不到当前会话,没法${what}`)
199
+ if (!studio.tracker) throw new Error(`「设计工作台」在 DSH 的插件页里关着,打开后再${what}`)
200
+ return id
201
+ }
202
+
203
+ /** check_design 工具:在无头浏览器里按真实尺寸量每一屏(没有浏览器就让右侧栏量),问题清单和测量数字交回给模型 */
204
+ export const checkTool = {
205
+ name: 'check_design',
206
+ description:
207
+ '量一下右侧「设计工作台」里的设计(默认每一屏、按设计的尺寸):文字被截断 / 超出框、文字叠在一起、对比度不够、点击区太小、字太小、字号太杂、超出屏幕、图片被拉变形 / 没加载出来、一行太长、短字断行、' +
208
+ '固定的顶栏 / 底栏压住内容(滚到底也看不全)或贴得太近、页面脚本报错、样式表 / 脚本 / 字体没载出来、跳到不存在的页面。' +
209
+ '在电脑上用无头浏览器按真实尺寸打开每一屏量,和右侧栏怎么显示无关;返回量了哪几屏、每种尺寸下页面多高、固定栏离内容多远,和问题清单(哪一屏、哪个元素的 CSS 选择器、具体数字)。' +
210
+ '写完一批屏、或改完之后调一次,照清单改源文件;要看小屏 / 大屏就给 sizes(如 ["375x667", "430x932"],或只写宽度)。比另开浏览器截图自查准,也省得多;要看长什么样用 look_design。' +
211
+ `严重的问题(被栏压住、文字叠在一起 / 被截断 / 超出框、超出屏幕、图片没出来、对比度很低)自动附上那一块的局部截图(最多 ${MAX_CROPS} 张,红框圈出元素)。` +
212
+ '要先用 preview_page 打开过页面。',
213
+ parameters: specToJsonSchema({
214
+ screens: { type: 'array', items: { type: 'string' }, description: '量哪几屏:文件名(home.html 或 home)或开发服务器的路由;不填量全部' },
215
+ sizes: { type: 'array', items: { type: 'string' }, description: '按哪几种尺寸量,如 ["375x667", "390x844", "430x932"],或只写宽度 ["320", "375"](高按机型);不填按设计的尺寸。最多 4 种' },
216
+ dark: { type: 'boolean', description: '切到深色量(页面带深色样式时)' },
217
+ images: { type: 'boolean', description: `严重问题附局部截图(默认附,最多 ${MAX_CROPS} 张);false 只要文字` },
218
+ }),
219
+ output: {
220
+ schema: {
221
+ type: 'object',
222
+ additionalProperties: false,
223
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
224
+ required: ['text'],
225
+ },
226
+ render: (_args, value) => withImages(value),
227
+ },
228
+ async execute(args, exec, ctx) {
229
+ const id = toolSession(exec, '体检')
230
+ const a = args || {}
231
+ if (!studio.tracker.snapshot(id).url) return { text: '还没有在预览的页面:先用 preview_page 打开设计文件,再体检。' }
232
+ const { snap, kind, size } = designFor(id)
233
+ const want = sizesFrom(a.sizes, kind)
234
+ const b = studio.browser
235
+ if (b && b.available()) {
236
+ try {
237
+ const pick = pickScreens(snap, a.screens, MAX_SCREENS)
238
+ if (!pick.list.length) return { text: `没找到要量的屏:${pick.missing.join('、')}。现在有:${(snap.screens || []).map((x) => x.key).join('、') || snap.focus}` }
239
+ const sizes = want.sizes.length ? want.sizes : [size]
240
+ const rows = await measureAll(b, { screens: pick.list, sizes, kind, dark: !!a.dark, locate: (items) => locateRules(studio.tracker.stateOf(id), items) })
241
+ if (rows.some((r) => !r.error)) {
242
+ // 同一个会话上次量的结果:这次比一比修好了几个、新出了几个
243
+ const prev = lastChecks.get(id) || null
244
+ // 严重问题的局部截图(截不成就不附;附件库用不了的老版本 DSH 不截,清单照样给)
245
+ let crops = []
246
+ if (a.images !== false && ctx && typeof ctx.get === 'function' && ctx.get('attachments')) {
247
+ try { crops = await cropIssues(b, rows, { screens: pick.list, dark: !!a.dark }) } catch (e) { studio.log(e) }
248
+ }
249
+ const kept = crops.length ? await keepImages(ctx, id, crops) : null
250
+ if (kept && !kept.images) crops = [] // 存进附件库失败:不提图
251
+ let text = formatMeasure(rows, { sizes, where: whereOf(snap), missing: pick.missing, more: pick.more, dark: !!a.dark, prev, crops })
252
+ const next = new Map(prev || [])
253
+ for (const [k, v] of issueSnapshot(rows, !!a.dark)) next.set(k, v)
254
+ lastChecks.delete(id)
255
+ lastChecks.set(id, next)
256
+ while (lastChecks.size > 20) lastChecks.delete(lastChecks.keys().next().value)
257
+ if (want.bad.length) text += `\n认不出的尺寸(没量):${want.bad.join('、')},写成 375x667 或 375`
258
+ if (want.more) text += `\n尺寸太多,只量了前 ${MAX_SIZES} 种`
259
+ text += tweakNotes(id)
260
+ return kept && kept.images ? { text, images: kept.images } : { text }
261
+ }
262
+ studio.log(new Error(`check_design 无头浏览器没量成:${rows[0] && rows[0].error}`))
263
+ } catch (e) {
264
+ studio.log(e)
265
+ }
266
+ }
267
+ // 没有无头浏览器(或者它没量成):让右侧栏在显示着的页面上量
268
+ if (!studio.check) return { text: '这台电脑上没找到能用的 Chrome / Edge,「设计工作台」也没开着,量不了。照 ui-design 技能里的「自查」清单自己过一遍。' }
269
+ const r = await studio.check(id)
270
+ const note = want.sizes.length || a.dark ? '(这台电脑上没找到能用的 Chrome / Edge,没法按指定尺寸 / 深色量;下面是右侧栏按它现在显示的尺寸量的)\n' : ''
271
+ if (!r) return { text: '右侧栏「设计工作台」现在没开着,量不了。照 ui-design 技能里的「自查」清单自己过一遍。' }
272
+ if (!r.error && !r.measured) return { text: '右侧栏开着,但预览页面没回应,这次没量成(可能还在加载)。过一会儿再调一次,或照 ui-design 技能里的「自查」清单自己过一遍。' }
273
+ return { text: note + (r.error || r.text) + tweakNotes(id) }
274
+ },
275
+ }
276
+
277
+ /** 调节滑块(tweaks.json)有没有真接上:check_design 末尾附上 */
278
+ function tweakNotes(id) {
279
+ let list = []
280
+ try {
281
+ const t = studio.tracker.stateOf(id).target
282
+ if (t && t.kind === 'file') list = lintTweaks(t.root)
283
+ } catch (e) { studio.log(e) }
284
+ return list.length ? `\n\n调节滑块(tweaks.json):\n${list.map((x) => `- ${x}`).join('\n')}` : ''
285
+ }
286
+
287
+ /**
288
+ * 浏览器给的规则(样式表地址 + 选择器 + 同一文件里第几条)→ 设计目录里的文件和行号。
289
+ * 只认这个会话正在预览的本地目录里的文件(≤ 2MB);别的地址、插件自己的、跑出目录的给 null
290
+ */
291
+ function locateRules(st, items) {
292
+ const t = st && st.target
293
+ if (!t || t.kind !== 'file' || !st.origin) return items.map(() => null)
294
+ const texts = new Map()
295
+ return items.map((it) => {
296
+ try {
297
+ const u = new URL(String((it && it.href) || ''))
298
+ if (u.origin !== st.origin || u.pathname.startsWith('/__dsh_design__/')) return null
299
+ const hit = resolveInRoot(t.root, u.pathname)
300
+ if (!hit || !hit.real) return null
301
+ if (!texts.has(hit.real)) {
302
+ const stat = fs.statSync(hit.real)
303
+ texts.set(hit.real, stat.isFile() && stat.size <= 2 * 1024 * 1024 ? fs.readFileSync(hit.real, 'utf8') : '')
304
+ }
305
+ const rel = path.relative(t.root, hit.abs).split(path.sep).join('/')
306
+ return { file: rel, line: findRule(texts.get(hit.real), String(it.sel || ''), Math.max(0, Math.floor(Number(it.n) || 0))) }
307
+ } catch {
308
+ return null
309
+ }
310
+ })
311
+ }
312
+
313
+ /** check_design 每个会话上次量出来的问题(复查时比修好了几个、新出了几个);只记最近 20 个会话 */
314
+ const lastChecks = new Map()
315
+
316
+ /** 存成文件的截图(老版本 DSH)只留一天 */
317
+ function pruneLookDir(maxAge = 24 * 3600 * 1000) {
318
+ let dirs = []
319
+ try { dirs = fs.readdirSync(LOOK_DIR) } catch { return }
320
+ const old = Date.now() - maxAge
321
+ for (const d of dirs) {
322
+ const dir = path.join(LOOK_DIR, d)
323
+ try {
324
+ for (const f of fs.readdirSync(dir)) {
325
+ const p = path.join(dir, f)
326
+ if (fs.statSync(p).mtimeMs < old) fs.rmSync(p, { force: true })
327
+ }
328
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
329
+ } catch { /* 删不掉下次再说 */ }
330
+ }
331
+ }
332
+
333
+ /** look_design 截的图存进 DSH 的附件库;附件引用(可序列化)放在工具结果里,render 直接读 */
334
+ const LOOK_DIR = path.join(os.tmpdir(), 'dsh-design-look')
335
+
336
+ /** look_design 工具:在无头浏览器里按真实尺寸打开每一屏截图,图直接回给模型看 */
337
+ export const lookTool = {
338
+ name: 'look_design',
339
+ description:
340
+ '看一眼右侧「设计工作台」里的设计长什么样:在电脑上用无头浏览器按真实尺寸打开每一屏,截图直接回给你(真实渲染的像素)。' +
341
+ '默认每屏截顶上一屏;at=bottom 滚到底(看底栏有没有压住内容)、at=full 从上到下每屏一张;selector 只截一个元素;size 换尺寸(如 375x667 或 375);dark 看深色。' +
342
+ `一次最多 ${MAX_SHOTS} 张。自己检查视觉效果(层次、留白、配色、图片、整体观感)用这个;要问题清单和数字用 check_design。要先用 preview_page 打开过页面。`,
343
+ parameters: specToJsonSchema({
344
+ screens: { type: 'array', items: { type: 'string' }, description: `截哪几屏:文件名(home.html 或 home)或开发服务器的路由;不填截全部(最多 ${MAX_SHOTS} 屏)` },
345
+ size: { type: 'string', description: '尺寸,如 375x667,或只写宽度 375(高按机型);不填按设计的尺寸' },
346
+ at: { type: 'string', enum: ['top', 'bottom', 'full'], description: 'top 顶上一屏(默认)、bottom 滚到底、full 从上到下每屏一张' },
347
+ selector: { type: 'string', description: '只截这个元素(CSS 选择器),如 .tabbar、#hero' },
348
+ dark: { type: 'boolean', description: '切到深色看(页面带深色样式时)' },
349
+ }),
350
+ output: {
351
+ schema: {
352
+ type: 'object',
353
+ additionalProperties: false,
354
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
355
+ required: ['text'],
356
+ },
357
+ render: (_args, value) => withImages(value),
358
+ },
359
+ async execute(args, exec, ctx) {
360
+ const id = toolSession(exec, '截图')
361
+ const a = args || {}
362
+ if (!studio.tracker.snapshot(id).url) return { text: '还没有在预览的页面:先用 preview_page 打开设计文件,再看。' }
363
+ const b = studio.browser
364
+ if (!b || !b.available()) return { text: '这台电脑上没找到 Chrome / Edge(无头浏览器截图要用),看不了。用 check_design 拿测量数字,或者让用户看右侧栏。' }
365
+ const { snap, kind, size } = designFor(id)
366
+ const want = a.size ? sizeOf(a.size, kind) : size
367
+ if (!want) return { text: `认不出尺寸「${String(a.size).slice(0, 20)}」:写成 375x667,或只写宽度 375。` }
368
+ const at = ['top', 'bottom', 'full'].includes(a.at) ? a.at : 'top'
369
+ const pick = pickScreens(snap, a.screens, MAX_SHOTS)
370
+ if (!pick.list.length) return { text: `没找到要截的屏:${pick.missing.join('、')}。现在有:${(snap.screens || []).map((x) => x.key).join('、') || snap.focus}` }
371
+ const shots = await shootAll(b, { screens: pick.list, size: want, at, selector: a.selector, dark: !!a.dark, max: MAX_SHOTS })
372
+ const good = shots.filter((s) => s.jpg).slice(0, MAX_SHOTS)
373
+ const text = formatShots(shots, { where: whereOf(snap), missing: pick.missing, more: pick.more })
374
+ if (!good.length) return { text }
375
+ const kept = await keepImages(ctx, id, good, true)
376
+ if (kept.images) return { text, images: kept.images }
377
+ return { text: `${text}\n(这个 DSH 版本没法把图直接给你,存成了文件:\n${kept.files.join('\n')})` }
378
+ },
379
+ }
380
+
381
+ /** extract_design_system 工具:从用户的网站 / 项目里提一套设计系统,放进库里,以后做新设计都照它来 */
382
+ export const systemTool = {
383
+ name: 'extract_design_system',
384
+ description:
385
+ '从用户自己的网站或项目里提取一套设计系统(品牌的颜色、字体、字号阶梯、圆角、阴影,和按钮 / 输入框 / 卡片的样子),存进用户的设计系统库,默认设为以后做新设计都照它来。' +
386
+ '用户给了网站地址、项目文件夹或 HTML 文件,说「照我们官网的风格」「用我们的品牌」时用。网站地址最准:在无头浏览器里打开量真实渲染的样式、截组件;项目文件夹会扫 CSS / Tailwind 配置 / 主题文件,有 HTML 入口也会打开量。' +
387
+ '返回提出来的配色、字体、圆角、组件,和网站截图、组件截图、样张截图;文件(tokens.css、components.css、components.html 样张、DESIGN.md)在 ~/.dsh/design-systems/<名字>/ 里。' +
388
+ '提完对照截图检查角色猜得对不对(不对就改 tokens.css),把 DESIGN.md 的「品牌气质」补上,再用 preview_page 打开 components.html 给用户看。',
389
+ parameters: specToJsonSchema({
390
+ sources: { type: 'array', items: { type: 'string' }, required: true, description: '来源,1–5 个:网站地址(https://example.com,可以给同一个站的几页)、项目文件夹或 HTML 文件的路径' },
391
+ name: { type: 'string', description: '这套设计系统叫什么(品牌名),可选;不填从网站标题 / package.json 里猜' },
392
+ default: { type: 'boolean', description: '设为默认(以后做新设计都照它来),默认 true' },
393
+ }),
394
+ output: {
395
+ schema: {
396
+ type: 'object',
397
+ additionalProperties: false,
398
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
399
+ required: ['text'],
400
+ },
401
+ render: (_args, value) => withImages(value),
402
+ },
403
+ async execute(args, exec, ctx) {
404
+ const session = exec && exec.agent && exec.agent.session
405
+ const id = (session && session.id) || ''
406
+ const a = args || {}
407
+ const sources = (Array.isArray(a.sources) ? a.sources : [a.sources]).filter((x) => typeof x === 'string' && x.trim())
408
+ if (!sources.length) return { text: '没给来源:sources 写网站地址、项目文件夹或 HTML 文件。' }
409
+ const cwd = studio.tracker && id ? studio.tracker.cwdOf(id) : os.homedir()
410
+ let r
411
+ try {
412
+ r = await extractSystem({ browser: studio.browser, sources, name: typeof a.name === 'string' ? a.name : '', cwd, makeDefault: a.default !== false })
413
+ } catch (e) {
414
+ return { text: `没提成:${String((e && e.message) || e).slice(0, 300)}` }
415
+ }
416
+ if (!r.images.length) return { text: r.text }
417
+ const kept = await keepImages(ctx, id || 'system', r.images, true)
418
+ if (kept.images) return { text: r.text, images: kept.images }
419
+ return { text: `${r.text}\n(截图存在 ${path.join(r.dir, 'source')})` }
420
+ },
421
+ }
422
+
423
+ /** 设计目录里的 tokens.css 是不是从库里哪套设计系统复制来的(文件头写着「设计系统「名字」」) */
424
+ function systemOfDir(root) {
425
+ try {
426
+ const head = fs.readFileSync(path.join(root, 'tokens.css'), 'utf8').slice(0, 400)
427
+ const m = /设计系统「([^」]{1,40})」/.exec(head)
428
+ return m ? m[1] : ''
429
+ } catch {
430
+ return ''
431
+ }
432
+ }
433
+
434
+ /** 路径里的 ~ 换成家目录,相对路径按会话的目录算 */
435
+ const resolvePath = (p, cwd) => path.resolve(cwd, String(p).trim().replace(/^~(?=$|[\\/])/, os.homedir()))
436
+
437
+ /** handoff_design 工具:把设计整理成交接包(说明 + 每屏截图),照它做成真项目的代码 */
438
+ export const handoffTool = {
439
+ name: 'handoff_design',
440
+ description:
441
+ '把右侧「设计工作台」里的设计(本地 HTML 那几屏)整理成交接包,准备做成真项目的代码:写好 <设计目录>/handoff/HANDOFF.md(每屏的文件、建议路由、跳转、设计变量、几屏共用该抽成组件的部分、交互和状态、调节滑块、素材、做法和验收),和每屏从上到下的截图。' +
442
+ '用户说「转成代码」「做成真的项目」「用 React / Vue 做出来」时先调它,读 HANDOFF.md 照着做;每做完一屏用 compare_design 和设计稿比一遍,差异降到 3% 以下再做下一屏。' +
443
+ 'framework:same 跟目标目录现有项目一样(有 package.json 时默认)、react、vue、next、html;dest:代码放哪个目录(不填放在会话目录里新建)。要先用 preview_page 打开过设计。',
444
+ parameters: specToJsonSchema({
445
+ framework: { type: 'string', enum: ['same', ...Object.keys(FRAMEWORKS)], description: 'same 跟现有项目一样;react(React + Vite)、vue(Vue 3 + Vite)、next(Next.js)、html(纯 HTML / CSS)。不填:目标目录有项目就 same,没有就 react' },
446
+ dest: { type: 'string', description: '代码放哪:现有项目的目录,或要新建的项目目录(绝对路径或相对会话目录)。不填:会话目录有项目就放进去,没有就在会话目录下新建' },
447
+ screens: { type: 'array', items: { type: 'string' }, description: '只转哪几屏(文件名);不填全部' },
448
+ }),
449
+ output: {
450
+ schema: {
451
+ type: 'object',
452
+ additionalProperties: false,
453
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
454
+ required: ['text'],
455
+ },
456
+ render: (_args, value) => withImages(value),
457
+ },
458
+ async execute(args, exec, ctx) {
459
+ const id = toolSession(exec, '转成代码')
460
+ const a = args || {}
461
+ const snap = studio.tracker.snapshot(id)
462
+ if (!snap.url) return { text: '还没有在预览的设计:先用 preview_page 打开设计文件。' }
463
+ const t = studio.tracker.stateOf(id).target
464
+ if (!t || t.kind !== 'file') return { text: '现在预览的是开发服务器上的页面,它已经是代码了:直接在那个项目里改;要比对设计稿就先用 preview_page 打开设计的 HTML 文件。' }
465
+ const { kind, size } = designFor(id)
466
+ const pick = pickScreens(snap, a.screens, 12)
467
+ if (!pick.list.length) return { text: `没找到要转的屏:${pick.missing.join('、')}。现在有:${(snap.screens || []).map((x) => x.key).join('、')}` }
468
+ const cwd = studio.tracker.cwdOf(id)
469
+ // 先定下最终放哪(没给就和右侧栏建议的一样),再按真实路径查是不是在设计目录里
470
+ const dest = typeof a.dest === 'string' && a.dest.trim() ? resolvePath(a.dest, cwd) : defaultDest(t.root, cwd, detectProject(cwd))
471
+ if (within(dest, t.root)) return { text: `代码别放进设计目录(${t.root})里:设计稿留着做对比。换一个目录。` }
472
+ const project = detectProject(dest)
473
+ const framework = typeof a.framework === 'string' && (a.framework === 'same' || FRAMEWORKS[a.framework]) ? a.framework : project.pkg && project.framework ? 'same' : 'react'
474
+ if (framework === 'same' && !(project.pkg && project.framework)) return { text: `${dest} 里没有认得出的前端项目(package.json 里没有 React / Vue / Next …):framework 选 react、vue、next 或 html。` }
475
+ let r
476
+ try {
477
+ r = await buildHandoff(studio.browser, { root: t.root, screens: pick.list, kind, size, framework, dest, project, system: systemOfDir(t.root) })
478
+ } catch (e) {
479
+ return { text: `没写成交接包:${String((e && e.message) || e).slice(0, 200)}` }
480
+ }
481
+ const head = `交接包写好了:${r.file}(${r.shots} 张截图在 handoff/shots/)。下面是全文,照「做法和验收」一屏一屏做,每屏做完用 compare_design 比。\n\n`
482
+ // 每屏顶上一张附上,直接看到要做成什么样
483
+ const firsts = r.rows.filter((row) => row.shots.length).slice(0, 4).map((row) => ({ key: row.key, jpg: fs.readFileSync(path.join(r.dir, 'shots', row.shots[0])) }))
484
+ const kept = firsts.length ? await keepImages(ctx, id, firsts) : {}
485
+ return kept.images ? { text: head + r.md, images: kept.images } : { text: head + r.md }
486
+ },
487
+ }
488
+
489
+ /** compare_design 工具:设计稿的一屏和做出来的页面按同样尺寸截图,逐像素比 */
490
+ export const compareTool = {
491
+ name: 'compare_design',
492
+ description:
493
+ '把做出来的页面和右侧「设计工作台」里的设计稿比一比:按同样尺寸在无头浏览器里各截一张,逐像素比,回一张「设计稿 | 做出来的 | 差异」拼图(差得多的地方红框标号)、差了百分之几,和每块差异在两边分别是哪个元素。' +
494
+ '照设计稿写代码时每做完一屏调一次(url 给开发服务器上那一页的地址,如 http://localhost:5173/login),照差异改到 3% 以下。at=bottom 比滚到底的样子。要先用 preview_page 打开过设计。',
495
+ parameters: specToJsonSchema({
496
+ url: { type: 'string', required: true, description: '做出来的页面:本机地址(http://localhost:5173/login,或端口 5173)或本地 HTML 文件' },
497
+ screen: { type: 'string', description: '和设计稿的哪一屏比(文件名,如 login 或 login.html);不填用右侧正在看的那屏' },
498
+ size: { type: 'string', description: '尺寸,如 375x667 或只写宽度;不填按设计的尺寸' },
499
+ at: { type: 'string', enum: ['top', 'bottom'], description: 'top 顶上一屏(默认)、bottom 滚到底' },
500
+ dark: { type: 'boolean', description: '比深色' },
501
+ }),
502
+ output: {
503
+ schema: {
504
+ type: 'object',
505
+ additionalProperties: false,
506
+ properties: { text: { type: 'string' }, images: { type: 'array', items: { type: 'object' } } },
507
+ required: ['text'],
508
+ },
509
+ render: (_args, value) => withImages(value),
510
+ },
511
+ async execute(args, exec, ctx) {
512
+ const id = toolSession(exec, '比对')
513
+ const a = args || {}
514
+ const snap = studio.tracker.snapshot(id)
515
+ if (!snap.url) return { text: '还没有在预览的设计稿:先用 preview_page 打开设计文件,再比。' }
516
+ const b = studio.browser
517
+ if (!b || !b.available()) return { text: '这台电脑上没找到 Chrome / Edge(比对要用无头浏览器截图),比不了。' }
518
+ const { kind, size } = designFor(id)
519
+ const want = a.size ? sizeOf(a.size, kind) : size
520
+ if (!want) return { text: `认不出尺寸「${String(a.size).slice(0, 20)}」:写成 375x667,或只写宽度 375。` }
521
+ // 指定了哪屏就只认那屏,找不到就说(别悄悄拿第一屏比,结果对不上号);没指定才用正在看的 / 第一屏
522
+ const asked = typeof a.screen === 'string' && a.screen.trim()
523
+ const pick = pickScreens(snap, asked ? [a.screen] : [snap.focus], 1)
524
+ const sc = pick.list[0] || (asked ? null : pickScreens(snap, null, 1).list[0])
525
+ if (!sc) return { text: `没找到设计稿的这一屏:${asked ? a.screen : '(没有屏)'}。现在有:${(snap.screens || []).map((x) => x.key).join('、')}` }
526
+ let impl
527
+ try { impl = implUrl(a.url, studio.tracker.cwdOf(id)) } catch (e) { return { text: e.message } }
528
+ if (snap.origin && impl.startsWith(snap.origin + '/')) return { text: '这个地址是设计稿自己(设计工作台的预览),给做出来的那个项目的地址。' }
529
+ let r
530
+ try {
531
+ r = await compareScreens(b, { design: sc, impl, size: want, at: a.at === 'bottom' ? 'bottom' : 'top', dark: !!a.dark })
532
+ } catch (e) {
533
+ return { text: `没比成:${String((e && e.message) || e).slice(0, 200)}(做出来的页面打得开吗?开发服务器起来了吗?)` }
534
+ }
535
+ const kept = await keepImages(ctx, id, [{ key: `compare-${sc.key}`, jpg: r.jpg }], true)
536
+ if (kept.images) return { text: r.text, images: kept.images }
537
+ return { text: `${r.text}\n(这个 DSH 版本没法把图直接给你,存成了文件:${kept.files.join('、')})` }
538
+ },
539
+ }
540
+
541
+ /** 工具结果显示成:文字 + 一张张图 */
542
+ function withImages(value) {
543
+ const blocks = [{ type: 'text', text: value.text }]
544
+ for (const ref of Array.isArray(value.images) ? value.images : []) blocks.push({ type: 'image', attachment: ref })
545
+ return blocks
546
+ }
547
+
548
+ /**
549
+ * 截的图交给 DSH:存进附件库,附件引用(可序列化)放进工具结果,render 直接读 → { images }。
550
+ * 附件库用不了(老版本 DSH):toFile 时存成文件 → { files }(一天前的顺手删掉),否则 {}
551
+ */
552
+ async function keepImages(ctx, id, list, toFile = false) {
553
+ const attachments = ctx && typeof ctx.get === 'function' ? ctx.get('attachments') : null
554
+ if (attachments) {
555
+ try {
556
+ 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` })))
557
+ return { images: [...refs] }
558
+ } catch (e) { studio.log(e) }
559
+ }
560
+ if (!toFile) return {}
561
+ pruneLookDir()
562
+ const dir = path.join(LOOK_DIR, String(id).replace(/[^\w-]+/g, '_'))
563
+ fs.mkdirSync(dir, { recursive: true })
564
+ const files = list.map((s, i) => {
565
+ const f = path.join(dir, `${Date.now().toString(36)}-${i + 1}-${String(s.key).replace(/[^\w.-]+/g, '-')}.jpg`)
566
+ fs.writeFileSync(f, s.jpg)
567
+ return f
568
+ })
569
+ return { files }
570
+ }
571
+
142
572
  const fileStore = {
143
573
  load: () => JSON.parse(fs.readFileSync(STORE_FILE, 'utf8')),
144
574
  save: (data) => {
@@ -221,8 +651,8 @@ export function apply(ctx) {
221
651
  const conn = connectionOf(ctx)
222
652
  return [...own, ...trustedOrigins(conn && conn.trustedHosts)]
223
653
  },
224
- onChange: (key) => {
225
- if (tracker && key.startsWith('file:')) tracker.changed(key.slice(5))
654
+ onChange: (key, file, files) => {
655
+ if (tracker && key.startsWith('file:')) tracker.changed(key.slice(5), files)
226
656
  },
227
657
  })
228
658
  const open = async (target, opts) => {
@@ -247,8 +677,18 @@ export function apply(ctx) {
247
677
  ctx.effect(() => () => hub.closeAll(), 'dsh-design-studio: preview servers')
248
678
  ctx.effect(() => {
249
679
  studio.tracker = tracker
250
- return () => { if (studio.tracker === tracker) studio.tracker = null }
680
+ studio.log = log
681
+ return () => { if (studio.tracker === tracker) { studio.tracker = null; studio.log = () => {} } }
251
682
  }, 'dsh-design-studio: tracker')
683
+ // check_design / look_design 用的无头浏览器:用到时才开,闲置自己关,这一行停用时关掉
684
+ ctx.effect(() => {
685
+ const browser = createBrowser({ log })
686
+ studio.browser = browser
687
+ return () => {
688
+ browser.destroy()
689
+ if (studio.browser === browser) studio.browser = null
690
+ }
691
+ }, 'dsh-design-studio: headless browser')
252
692
  const phone = createPhone({ upstream: (session) => tracker.snapshot(session).origin || '', log })
253
693
  ctx.effect(() => () => phone.stopAll(), 'dsh-design-studio: phone links')
254
694
 
@@ -271,11 +711,128 @@ export function apply(ctx) {
271
711
  return true
272
712
  }
273
713
 
714
+ // ── 模型调 check_design:轮询里带上 checkReq,右侧栏量完 POST /api/check 交回来;右侧栏没开就等到超时 ──
715
+ const checks = new Map()
716
+ let checkSeq = 0
717
+ function requestCheck(session, ms = CHECK_WAIT) {
718
+ const had = checks.get(session)
719
+ if (had) return had.promise
720
+ const c = { id: `c${++checkSeq}-${Date.now().toString(36)}` }
721
+ c.promise = new Promise((resolve) => {
722
+ c.done = (r) => {
723
+ clearTimeout(c.timer)
724
+ if (checks.get(session) === c) checks.delete(session)
725
+ resolve(r)
726
+ }
727
+ c.timer = setTimeout(() => c.done(c.fallback || null), ms)
728
+ })
729
+ checks.set(session, c)
730
+ return c.promise
731
+ }
732
+ ctx.effect(() => {
733
+ studio.check = requestCheck
734
+ return () => {
735
+ if (studio.check === requestCheck) studio.check = null
736
+ for (const c of [...checks.values()]) c.done(null)
737
+ }
738
+ }, 'dsh-design-studio: check')
739
+
274
740
  function state(req, res) {
275
741
  if (!admit(req, res)) return
276
742
  const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
277
743
  const ph = phone.status(session)
278
- json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 } })
744
+ const c = checks.get(session)
745
+ // 调节滑块:tweaks.json 改了(模型写的 / 写回的)右侧栏跟着变
746
+ const t = tracker.stateOf(session).target
747
+ let tweaks = null
748
+ if (t && t.kind === 'file') { try { tweaks = readTweaks(t.root) } catch (e) { log(e) } }
749
+ json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 }, tweaks, ...(c ? { checkReq: c.id } : {}) })
750
+ }
751
+
752
+ /** 调节滑块写回文件:values = { id: 值 } */
753
+ async function tweakRoute(req, res) {
754
+ if (!admit(req, res)) return
755
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
756
+ let body
757
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
758
+ const values = body.values && typeof body.values === 'object' && !Array.isArray(body.values) ? body.values : {}
759
+ try {
760
+ const r = tracker.editDir(String(body.session || ''), (root) => applyTweaks(root, values), (x) => `调节:${x.changed.map((c) => `${c.label} ${c.to}`).join('、')}`.slice(0, 60), String(body.root || ''))
761
+ json(res, 200, r)
762
+ } catch (e) {
763
+ json(res, 500, { error: e.message })
764
+ }
765
+ }
766
+
767
+ /** 设计系统库:GET 列出来;POST { id, default: true / false } 设 / 取消默认 */
768
+ async function systemsRoute(req, res) {
769
+ if (!admit(req, res)) return
770
+ if (req.method === 'POST') {
771
+ let body
772
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
773
+ const id = String(body.id || '')
774
+ // 只认库里列得出来的那几套(id 就是库目录下的文件夹名,别让 ../ 之类指到外面)
775
+ const sys = listSystems().find((x) => x.id === id)
776
+ if (!sys) return json(res, 404, { error: 'no such system' })
777
+ try {
778
+ if (body.default === false) { if (sys.default) setDefault(SYSTEMS_DIR, '') }
779
+ else setDefault(SYSTEMS_DIR, id)
780
+ } catch (e) { return json(res, 400, { error: e.message }) }
781
+ }
782
+ json(res, 200, { dir: SYSTEMS_DIR, systems: listSystems() })
783
+ }
784
+
785
+ /** 设计系统的缩略图(样张截图,没有就用第一张网站截图) */
786
+ function systhumbRoute(req, res) {
787
+ if (!admit(req, res)) return
788
+ const id = new URL(req.url, 'http://x').searchParams.get('id') || ''
789
+ const sys = listSystems().find((x) => x.id === id)
790
+ if (!sys) return json(res, 404, { error: 'no such system' })
791
+ const src = path.join(sys.dir, 'source')
792
+ // 只给 source/ 里真的图片文件:软链接、文件夹都不认(别借缩略图读到库外面的文件)
793
+ const own = (f) => { try { return fs.lstatSync(f).isFile() } catch { return false } }
794
+ let srcOk = false
795
+ try { srcOk = fs.lstatSync(src).isDirectory() } catch { srcOk = false }
796
+ if (!srcOk) return json(res, 404, { error: 'no preview' })
797
+ let file = path.join(src, 'preview.jpg')
798
+ if (!own(file)) {
799
+ let first = ''
800
+ try { first = fs.readdirSync(src).find((f) => /\.jpg$/i.test(f) && own(path.join(src, f))) || '' } catch { first = '' }
801
+ if (!first) return json(res, 404, { error: 'no preview' })
802
+ file = path.join(src, first)
803
+ }
804
+ res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'no-store' })
805
+ fs.createReadStream(file).on('error', () => res.end()).pipe(res)
806
+ }
807
+
808
+ /** 转成代码前:会话目录是不是个前端项目(右侧栏默认选「跟项目一样」、填好放哪) */
809
+ function projectRoute(req, res) {
810
+ if (!admit(req, res)) return
811
+ const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
812
+ const cwd = tracker.cwdOf(session)
813
+ const p = detectProject(cwd)
814
+ const t = tracker.stateOf(session).target
815
+ const root = t && t.kind === 'file' ? t.root : ''
816
+ // root 一起回去:右侧栏按它认这份建议是不是给现在这套设计的;project 按最终放的目录认(默认框架跟着它选)
817
+ const dest = defaultDest(root, cwd, p)
818
+ json(res, 200, { cwd, root, project: dest === cwd ? p : detectProject(dest), dest, frameworks: Object.fromEntries(Object.entries(FRAMEWORKS).map(([k, v]) => [k, v.label])) })
819
+ }
820
+
821
+ async function checkRoute(req, res) {
822
+ if (!admit(req, res)) return
823
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
824
+ let body
825
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
826
+ const c = checks.get(String(body.session || ''))
827
+ if (!c || c.id !== body.id) return json(res, 200, { ok: false })
828
+ const r = { text: String(body.text || '').slice(0, 20000), error: String(body.error || '').slice(0, 500), measured: Number(body.measured) || 0 }
829
+ // 一屏都没量到:先记着,等别的右侧栏(比如电脑上的)交真结果;到点还没有就用它
830
+ if (!r.error && !r.measured) {
831
+ c.fallback = r
832
+ return json(res, 200, { ok: true, held: true })
833
+ }
834
+ c.done(r)
835
+ json(res, 200, { ok: true })
279
836
  }
280
837
 
281
838
  async function openRoute(req, res) {
@@ -353,6 +910,73 @@ export function apply(ctx) {
353
910
  }
354
911
  }
355
912
 
913
+ /** 预览里调样式:写进那个元素在源码里的 style 属性;找不到它就告诉右侧栏(它会改成让模型去改) */
914
+ async function styleRoute(req, res) {
915
+ if (!admit(req, res)) return
916
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
917
+ let body
918
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
919
+ try {
920
+ json(res, 200, tracker.editStyle(String(body.session || ''), { key: body.key, loc: body.loc, css: body.css, name: body.name }))
921
+ } catch (e) {
922
+ json(res, 500, { error: e.message })
923
+ }
924
+ }
925
+
926
+ /**
927
+ * 调样式交给模型前:每项样式胜出的那条规则在源码第几行(picker 给 { href, sel, n },这里在设计目录的文件里找)。
928
+ * 只认这个会话预览的本地目录里的文件;开发服务器的样式多半是编译出来的,不找
929
+ */
930
+ async function locateRoute(req, res) {
931
+ if (!admit(req, res)) return
932
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
933
+ let body
934
+ try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
935
+ const items = Array.isArray(body.items) ? body.items.slice(0, 12) : []
936
+ json(res, 200, { items: locateRules(tracker.stateOf(String(body.session || '')), items) })
937
+ }
938
+
939
+ /**
940
+ * 远程看(手机走外网 / 别的电脑打开 DSH 网页,连不上本机的预览端口):这一屏连同样式、图片打成一份 HTML(见 bundle.js),
941
+ * 右侧栏放进沙盒 iframe。回 JSON(不直接给 HTML:在 DSH 的地址下直接打开时,页面脚本不能拿 DSH 的源跑)。
942
+ * parent:右侧栏所在的源,picker 只和它说话。v:看旧版本。
943
+ */
944
+ function bundleRoute(req, res) {
945
+ if (!admit(req, res)) return
946
+ const q = new URL(req.url, 'http://x').searchParams
947
+ const st = tracker.stateOf(q.get('session') || '')
948
+ const t = st && st.target
949
+ if (!t || t.kind !== 'file') return json(res, 404, { error: 'not-file' })
950
+ const key = q.get('key') || path.basename(t.file)
951
+ if (!/^[^\\]+\.html?$/i.test(key) || key.split('/').some((p) => !p || p === '.' || p === '..')) return json(res, 400, { error: 'bad key' })
952
+ const v = Math.max(0, Math.floor(Number(q.get('v')) || 0))
953
+ let parent = ''
954
+ try { const u = new URL(q.get('parent') || ''); if (u.protocol === 'http:' || u.protocol === 'https:') parent = u.origin } catch { parent = '' }
955
+ const deps = new Set() // 这一屏用到的文件(含没找到的):右侧栏看到这些改了才重新打包
956
+ const read = (rel) => {
957
+ deps.add(rel)
958
+ // 先看大小,太大的不读(打包总量也封顶,见 bundle.js)
959
+ const small = (f) => { const s = fs.statSync(f); return s.isFile() && s.size <= BUNDLE_MAX }
960
+ if (v) {
961
+ const f = history.fileOf(t.root, v, rel)
962
+ if (f) return small(f) ? fs.readFileSync(f) : null
963
+ }
964
+ const hit = resolveInRoot(t.root, '/' + rel.split('/').map(encodeURIComponent).join('/'))
965
+ if (!hit || !hit.real || !small(hit.real)) return null
966
+ return fs.readFileSync(hit.real)
967
+ }
968
+ try {
969
+ const page = read(key)
970
+ if (!page) return json(res, 404, { error: 'no-screen' })
971
+ const href = st.origin ? `${st.origin}/${v ? `__dsh_design__/v/${v}/` : ''}${key.split('/').map(encodeURIComponent).join('/')}` : ''
972
+ const html = bundle(page.toString('utf8'), { key, read, base: href, lead: ERR_CATCHER, tail: inlinePicker(fs.readFileSync(PICKER_FILE, 'utf8'), parent, href) })
973
+ // 用到的文件太多就不列(null = 右侧栏不按文件筛,目录里一有改动就重新打包)
974
+ json(res, 200, { html, deps: deps.size > 200 ? null : [...deps] })
975
+ } catch (e) {
976
+ json(res, 500, { error: e.message })
977
+ }
978
+ }
979
+
356
980
  const exported = new Set() // 这次运行里导出过的图片(只许在访达里显示这些)
357
981
 
358
982
  /** 导出的图片存哪:本地文件预览存设计目录的 exports/,开发服务器存「下载」 */
@@ -362,26 +986,61 @@ export function apply(ctx) {
362
986
  return path.join(os.homedir(), 'Downloads')
363
987
  }
364
988
 
989
+ /** 存进导出目录:文件名只留字母数字中文和 -,同名不覆盖(加 -2、-3 …) */
990
+ function saveExport(res, session, name, ext, buf) {
991
+ const base = String(name || 'design').replace(new RegExp(`\\.${ext}$`, 'i'), '').replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'design'
992
+ const dir = exportDir(session)
993
+ try {
994
+ fs.mkdirSync(dir, { recursive: true })
995
+ let file = path.join(dir, `${base}.${ext}`)
996
+ for (let i = 2; fs.existsSync(file) && i < 1000; i++) file = path.join(dir, `${base}-${i}.${ext}`)
997
+ fs.writeFileSync(file, buf)
998
+ exported.add(file)
999
+ json(res, 200, { path: file })
1000
+ } catch (e) {
1001
+ json(res, 500, { error: e.message })
1002
+ }
1003
+ }
1004
+
365
1005
  async function exportRoute(req, res) {
366
1006
  if (!admit(req, res)) return
367
1007
  if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
368
1008
  let body
369
1009
  try { body = await readBody(req, MAX_EXPORT) } catch (e) { return json(res, 400, { error: e.message }) }
370
- const session = String(body.session || '')
371
1010
  const buf = Buffer.from(String(body.png || ''), 'base64')
372
1011
  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)
1012
+ saveExport(res, String(body.session || ''), body.name, 'png', buf)
1013
+ }
1014
+
1015
+ /** 几屏拼成 PDF / PPT:每屏一页,页面大小按那一屏的 CSS 尺寸 */
1016
+ async function docRoute(req, res) {
1017
+ if (!admit(req, res)) return
1018
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
1019
+ let body
1020
+ try { body = await readBody(req, MAX_DOC) } catch (e) { return json(res, 400, { error: e.message }) }
1021
+ const format = body.format === 'pdf' || body.format === 'pptx' ? body.format : ''
1022
+ if (!format) return json(res, 400, { error: 'format 只能是 pdf / pptx' })
1023
+ const pages = []
1024
+ for (const p of Array.isArray(body.pages) ? body.pages.slice(0, MAX_PAGES) : []) {
1025
+ const jpg = Buffer.from(String((p && p.jpg) || ''), 'base64')
1026
+ const info = jpegInfo(jpg)
1027
+ if (!info) return json(res, 400, { error: '不是 JPEG' })
1028
+ // 页面大小(CSS 像素):没给或不像话就按图片的一半算(截图是 2 倍)
1029
+ const ok = (v) => Number.isFinite(+v) && +v >= 16 && +v <= 30000
1030
+ const w = ok(p.w) && ok(p.h) ? +p.w : info.width / 2
1031
+ const h = ok(p.w) && ok(p.h) ? +p.h : info.height / 2
1032
+ // 跳转链接原样带过去,doc.js 里挑掉不合法的
1033
+ pages.push({ jpg, w, h, title: String(p.title || '').slice(0, 80), links: Array.isArray(p.links) ? p.links.slice(0, 50) : [] })
1034
+ }
1035
+ if (!pages.length) return json(res, 400, { error: '没有页面' })
1036
+ const title = String(body.title || '').slice(0, 120)
1037
+ let buf
375
1038
  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 })
1039
+ buf = format === 'pdf' ? makePdf(pages, { title }) : makePptx(pages, { title, bg: String(body.bg || '') })
382
1040
  } catch (e) {
383
- json(res, 500, { error: e.message })
1041
+ return json(res, 400, { error: e.message })
384
1042
  }
1043
+ saveExport(res, String(body.session || ''), body.name, format, buf)
385
1044
  }
386
1045
 
387
1046
  /** 图片格式看文件头,不信文件名 */
@@ -450,7 +1109,7 @@ export function apply(ctx) {
450
1109
  json(res, 200, st.on ? { ...st, qr: qrMatrix(st.url).map((row) => row.map((c) => (c ? 1 : 0)).join('')) } : st)
451
1110
  }
452
1111
 
453
- const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, ref: refRoute, export: exportRoute, reveal: revealRoute, phone: phoneRoute }
1112
+ 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, tweak: tweakRoute, systems: systemsRoute, systhumb: systhumbRoute, project: projectRoute }
454
1113
  for (const [key, handler] of Object.entries(routes)) {
455
1114
  ctx.effect(() => ctx.webServer.register({
456
1115
  kind: 'exact',