dsh-design-studio 0.0.0-stage → 0.3.3

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/core.js ADDED
@@ -0,0 +1,698 @@
1
+ /**
2
+ * 不碰 DSH 的核心逻辑(方便单测):
3
+ * parseTarget 用户 / 模型给的地址 → 预览目标
4
+ * parseKind / parseSize 设计类型(网页 / App / 海报 …)和画幅尺寸
5
+ * listScreens 一个目录里的几屏(HTML 文件,按生成顺序)
6
+ * createTracker 每个会话的预览状态,以及从会话事件里自动发现「该预览什么」
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const HTML_EXT = /\.html?$/i
15
+ const LOCAL_URL = /^(?:https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})(\/[^\s]*)?$/i
16
+ // 命令输出里的本机地址(开发服务器启动时会打印)
17
+ const URL_IN_TEXT = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})(\/[^\s"'`<>)\]]*)?/gi
18
+ const LIVE_MS = 120000 // 事件时间离现在超过这么久就当是重放历史
19
+ const DESIGN_SKILL = 'ui-design'
20
+ const DESIGN_META = /<meta[^>]+name=["']?dsh-design\b/i
21
+ // 只查看、不启动服务的命令(curl 一下别的项目的端口,不该把那个端口当成在做的设计)
22
+ const PROBE_CMD = /\b(curl|wget|httpie|xh|lsof|netstat|ss|nc|telnet|kill|pkill|open|ps)\b/i
23
+ // 启动开发服务器的命令,或者服务器启动时打印的那一行
24
+ const DEV_CMD = /\b(dev|serve|server|start|preview|watch|vite|next|nuxt|astro|webpack|parcel|storybook|hugo|jekyll|eleventy)\b|http\.server|http-server|live-server|browser-sync/i
25
+ const BANNER = /\b(local|listening|running|serving|started|ready|available|preview)\b/i
26
+ const SHELL_TOOL = /^(bash|shell|sh|zsh|powershell|pwsh|exec|run_command|terminal)$/i
27
+ // 放到后台跑的命令(DSH:bash 带 run_in_background,或前台超时转后台)回一个 job id,输出要用 job_output 去读
28
+ const JOB_READ_TOOL = /^(job_output|bash_output|read_job|job_read)$/i
29
+ const BG_JOB = /\bbackground job\s+`?([\w.:-]{3,})/i
30
+ const MAX_SCREENS = 24
31
+
32
+ // 设计类型:决定右侧用什么画幅显示(网页框、手机、平板、海报、幻灯片、邮件)
33
+ export const KINDS = ['web', 'app', 'tablet', 'poster', 'slides', 'email']
34
+ const KIND_ALIAS = {
35
+ website: 'web', site: 'web', page: 'web', landing: 'web', desktop: 'web', dashboard: 'web',
36
+ phone: 'app', mobile: 'app', ios: 'app', android: 'app', miniapp: 'app',
37
+ pad: 'tablet', ipad: 'tablet',
38
+ social: 'poster', image: 'poster', banner: 'poster', card: 'poster', flyer: 'poster', cover: 'poster',
39
+ slide: 'slides', deck: 'slides', ppt: 'slides', presentation: 'slides',
40
+ mail: 'email', newsletter: 'email', edm: 'email',
41
+ }
42
+
43
+ /** 类型名(含常见别名)→ KINDS 里的一个;认不出返回 '' */
44
+ export function parseKind(input) {
45
+ const k = String(input || '').trim().toLowerCase()
46
+ if (!k) return ''
47
+ if (KINDS.includes(k)) return k
48
+ return KIND_ALIAS[k] || ''
49
+ }
50
+
51
+ /** 「1080x1920」「1080×1920」→ '1080x1920';不像尺寸返回 '' */
52
+ export function parseSize(input) {
53
+ const m = /^\s*(\d{2,5})\s*[x×*,]\s*(\d{2,5})\s*$/i.exec(String(input || ''))
54
+ if (!m) return ''
55
+ const w = Number(m[1])
56
+ const h = Number(m[2])
57
+ if (w < 100 || h < 100 || w > 8000 || h > 8000) return ''
58
+ return `${w}x${h}`
59
+ }
60
+
61
+ /** 目录里的几屏:顶层的 HTML 文件,按创建时间(= 模型生成的顺序)排,最多 24 个 */
62
+ export function listScreens(root) {
63
+ let dirents
64
+ try { dirents = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] }
65
+ const out = []
66
+ for (const d of dirents) {
67
+ if (!d.isFile() || !HTML_EXT.test(d.name) || d.name.startsWith('.')) continue
68
+ let born = 0
69
+ try {
70
+ const st = fs.statSync(path.join(root, d.name))
71
+ born = st.birthtimeMs || st.ctimeMs
72
+ } catch { continue }
73
+ out.push({ name: d.name, born })
74
+ }
75
+ out.sort((a, b) => a.born - b.born || a.name.localeCompare(b.name))
76
+ return out.slice(0, MAX_SCREENS).map((x) => x.name)
77
+ }
78
+
79
+ /** 开发服务器上要并排看的几个路由 */
80
+ function normRoutes(list) {
81
+ if (!Array.isArray(list)) return null
82
+ const out = []
83
+ for (const r of list) {
84
+ let s = String(r || '').trim()
85
+ if (!s) continue
86
+ const m = LOCAL_URL.exec(s)
87
+ if (m) s = m[3] || '/'
88
+ if (!s.startsWith('/')) s = '/' + s
89
+ if (!out.includes(s)) out.push(s)
90
+ if (out.length >= MAX_SCREENS) break
91
+ }
92
+ return out.length ? out : null
93
+ }
94
+
95
+ /** Windows 上 DSH 用 Git Bash 跑命令,模型给的路径常是 /c/Users/… 这种写法:换成 C:\\Users\\…;别的系统原样 */
96
+ export function fromBashPath(p, platform = process.platform) {
97
+ if (platform !== 'win32') return p
98
+ const m = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(p)
99
+ return m ? `${m[1].toUpperCase()}:\\${(m[2] || '').replace(/\//g, '\\')}` : p
100
+ }
101
+
102
+ /**
103
+ * @returns {{ kind: 'file', file, root } | { kind: 'proxy', port, path }}
104
+ * @throws Error 带中文说明
105
+ */
106
+ export function parseTarget(input, cwd = os.homedir()) {
107
+ const s = String(input || '').trim()
108
+ if (!s) throw new Error('地址是空的')
109
+ if (/^\d{2,5}$/.test(s)) return proxyTarget(Number(s), '/')
110
+ const m = LOCAL_URL.exec(s)
111
+ if (m) return proxyTarget(Number(m[2]), m[3] || '/')
112
+ if (/^https?:\/\//i.test(s)) throw new Error('只能预览本机的页面:本地 HTML 文件,或 localhost 上的开发服务器')
113
+ let p = null
114
+ // 标准的 file:// 地址交给 Node 转(Windows 上是 file:///C:/…,直接去掉前缀会变成 /C:/…)
115
+ if (/^file:\/\//i.test(s)) try { p = fileURLToPath(s) } catch { /* file://~/… 之类的写法,按下面的老办法 */ }
116
+ if (p === null) {
117
+ p = s.replace(/^file:\/\//i, '')
118
+ if (p.includes('%')) {
119
+ try { p = decodeURIComponent(p) } catch { /* 原样用 */ }
120
+ }
121
+ }
122
+ p = fromBashPath(p)
123
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = path.join(os.homedir(), p.slice(1))
124
+ p = path.resolve(cwd, p)
125
+ let st
126
+ try { st = fs.statSync(p) } catch { throw new Error(`找不到:${p}`) }
127
+ if (st.isDirectory()) {
128
+ const index = path.join(p, 'index.html')
129
+ if (!fs.existsSync(index)) throw new Error(`这个目录里没有 index.html:${p}`)
130
+ p = index
131
+ } else if (!HTML_EXT.test(p)) {
132
+ throw new Error(`只能预览 .html 文件:${p}`)
133
+ }
134
+ return { kind: 'file', file: p, root: path.dirname(p) }
135
+ }
136
+
137
+ function proxyTarget(port, pathname) {
138
+ if (!(port >= 1 && port <= 65535)) throw new Error(`端口不对:${port}`)
139
+ return { kind: 'proxy', port, path: pathname.startsWith('/') ? pathname : '/' + pathname }
140
+ }
141
+
142
+ /** 预览服务器上的地址 */
143
+ export function urlFor(target, origin) {
144
+ if (target.kind === 'file') {
145
+ const rel = path.relative(target.root, target.file).split(path.sep).map(encodeURIComponent).join('/')
146
+ return `${origin}/${rel}`
147
+ }
148
+ return origin + target.path
149
+ }
150
+
151
+ /** 工具栏上显示的短名字 */
152
+ export function labelOf(target) {
153
+ return target.kind === 'file' ? path.basename(target.file) : `localhost:${target.port}${target.path === '/' ? '' : target.path}`
154
+ }
155
+
156
+ export function sameTarget(a, b) {
157
+ if (!a || !b || a.kind !== b.kind) return false
158
+ return a.kind === 'file' ? a.file === b.file : a.port === b.port && a.path === b.path
159
+ }
160
+
161
+ /** 工具参数(JSON 字符串或对象)里的写文件路径;不是写文件返回 '' */
162
+ function writtenPath(name, args) {
163
+ if (!/^(write|edit|multi_?edit|create_file|str_replace\w*)$/i.test(String(name || ''))) return ''
164
+ let a = args
165
+ if (typeof a === 'string') {
166
+ try { a = JSON.parse(a) } catch { return '' }
167
+ }
168
+ if (!a || typeof a !== 'object') return ''
169
+ const p = a.file_path || a.filePath || a.path
170
+ return typeof p === 'string' ? p : ''
171
+ }
172
+
173
+ function textsOf(content) {
174
+ if (typeof content === 'string') return [content]
175
+ if (!Array.isArray(content)) return []
176
+ return content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text)
177
+ }
178
+
179
+ /** HTML 文件里写了 <meta name="dsh-design">(设计技能让模型写的类型标记)就是设计稿 */
180
+ export function isDesignHtml(file) {
181
+ try {
182
+ const fd = fs.openSync(file, 'r')
183
+ try {
184
+ const buf = Buffer.alloc(65536)
185
+ const n = fs.readSync(fd, buf, 0, buf.length, 0)
186
+ return DESIGN_META.test(buf.subarray(0, n).toString('utf8'))
187
+ } finally { fs.closeSync(fd) }
188
+ } catch { return false }
189
+ }
190
+
191
+ /** 用户自己调了设计技能(输入框里打 /ui-design) */
192
+ function userLoadedSkill(data) {
193
+ if (!data || !data.source || data.source.kind !== 'user') return false
194
+ const c = data.content
195
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n') : ''
196
+ return new RegExp(`<skill_content[^>]*name=["']${DESIGN_SKILL}["']|^\\s*/${DESIGN_SKILL}\\b`, 'm').test(text)
197
+ }
198
+
199
+ function argsOf(a) {
200
+ if (a && typeof a === 'object') return a
201
+ try { return JSON.parse(a) || {} } catch { return {} }
202
+ }
203
+
204
+ /**
205
+ * 这条命令输出里有没有「开发服务器起来了」的地址:只认 bash 跑的启动命令(或输出里像启动信息的那一行),
206
+ * curl / wget 之类只是去看别的端口的不算。
207
+ */
208
+ export function devServerIn(command, text, skipPorts = new Set()) {
209
+ const cmd = String(command || '')
210
+ if (PROBE_CMD.test(cmd.replace(/\b(dev|serve|server|start|preview)\b/gi, ''))) return null
211
+ const lines = String(text || '').split('\n').filter((l) => /https?:\/\//i.test(l) && (DEV_CMD.test(cmd) || BANNER.test(l)))
212
+ return lines.length ? serverUrlIn(lines.join('\n'), skipPorts) : null
213
+ }
214
+
215
+ /** 命令输出里最后一个本机地址(开发服务器常常先打 Local: 再打 Network:,取 localhost 那个) */
216
+ // ── 预览里直接改字:在 HTML 源码的文字部分里找到那一处换掉 ──────────────────────
217
+
218
+ /** 标签、注释、脚本 / 样式这类不算文字的块;剩下的就是页面上看得到的字 */
219
+ const NOT_TEXT = /<!--[\s\S]*?-->|<(script|style|textarea|noscript|template|title)\b[^>]*>[\s\S]*?<\/\1\s*>|<[^>]*>/gi
220
+ const ENT = {
221
+ '&': '(?:&|&amp;|&#0*38;|&#x0*26;)',
222
+ '<': '(?:<|&lt;|&#0*60;|&#x0*3c;)',
223
+ '>': '(?:>|&gt;|&#0*62;|&#x0*3e;)',
224
+ '"': '(?:"|&quot;|&#0*34;|&#x0*22;)',
225
+ "'": "(?:'|&apos;|&#0*39;|&#x0*27;)",
226
+ }
227
+ const WS_SRC = '(?:\\s|&nbsp;|&#0*160;|&#x0*a0;)+'
228
+ const escHtml = (x) => x.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
229
+
230
+ /** 页面上看到的一段字(空白已合并)→ 匹配它在源码里各种写法的正则 */
231
+ function textPattern(text) {
232
+ let src = ''
233
+ for (const ch of text) {
234
+ if (/\s/.test(ch)) { if (!src.endsWith(WS_SRC)) src += WS_SRC }
235
+ else src += ENT[ch] || ch.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
236
+ }
237
+ return new RegExp(src, 'gi')
238
+ }
239
+
240
+ /**
241
+ * 把 HTML 里第 nth 处(从 0 数,只数页面上看得到的文字,按出现顺序)before 换成 after。
242
+ * before / after 是页面上看到的样子(空白合并过);after 会转义成 HTML。
243
+ * @returns {{ ok: true, html } | { ok: false, reason: 'empty' | 'not-found' }}
244
+ */
245
+ export function replaceText(html, before, after, nth = 0) {
246
+ const norm = (x) => String(x || '').replace(/[\s\u00a0]+/g, ' ').trim()
247
+ const from = norm(before)
248
+ const to = norm(after)
249
+ if (!from || !to) return { ok: false, reason: 'empty' }
250
+ const re = textPattern(from)
251
+ let seen = 0
252
+ let last = 0
253
+ NOT_TEXT.lastIndex = 0
254
+ for (let m = NOT_TEXT.exec(html); ; m = NOT_TEXT.exec(html)) {
255
+ const end = m ? m.index : html.length
256
+ const seg = html.slice(last, end)
257
+ re.lastIndex = 0
258
+ for (let t = re.exec(seg); t; t = re.exec(seg)) {
259
+ if (seen++ === nth) {
260
+ const at = last + t.index
261
+ return { ok: true, html: html.slice(0, at) + escHtml(to) + html.slice(at + t[0].length) }
262
+ }
263
+ }
264
+ if (!m) break
265
+ last = m.index + m[0].length
266
+ }
267
+ return { ok: false, reason: 'not-found' }
268
+ }
269
+
270
+ export function serverUrlIn(text, skipPorts = new Set()) {
271
+ let found = null
272
+ URL_IN_TEXT.lastIndex = 0
273
+ let m
274
+ while ((m = URL_IN_TEXT.exec(String(text || '')))) {
275
+ const port = Number(m[1])
276
+ if (skipPorts.has(port)) continue
277
+ if (!found) found = { port, path: m[2] || '/' }
278
+ }
279
+ return found
280
+ }
281
+
282
+ /**
283
+ * 每个会话一份预览状态。
284
+ *
285
+ * state: { target, origin, url, label, source, rev, openRev, turn, writing, changedAt, error, kind, size, view, routes, screens }
286
+ * source 'tool' 模型调工具 | 'user' 用户在地址栏输入 | 'auto' 自动发现
287
+ * kind 设计类型('' = 看页面自己的 <meta name="dsh-design">,再没有就当网页)
288
+ * size 画幅尺寸 '1080x1920'('' = 按类型默认)
289
+ * view 'canvas' 几屏并排 | 'single' 只看一屏 | '' 自动(有好几屏就并排)
290
+ * screens 并排的几屏:本地文件是同目录的 HTML 文件名,开发服务器是路由
291
+ * openRev 每次要右侧栏自动弹出来就 +1(客户端看到变大就打开标签页)
292
+ * rev 页面内容变了就 +1(文件改动)
293
+ * turn 这个会话正在跑一轮(预览上显示「正在生成」)
294
+ *
295
+ * @param opts.open (target) => Promise<{ origin }> 开预览服务器
296
+ * @param opts.skipPorts () => Set<number> 不当开发服务器的端口(DSH 自己、预览服务器)
297
+ * @param opts.store { load(): object, save(object) } 记住每个会话预览的是什么(DSH 重启后接着显示)
298
+ */
299
+ // 插件塞进输入框的块:头一行认出说的是什么,后面几行是给模型看的细节(不算用户的话)
300
+ const BLOCK_HEAD = [
301
+ [/^〔选中元素〕(.*?)(「.*」)?$/, (m) => m[2] || m[1]],
302
+ [/^〔选中区域〕/, () => '选中区域'],
303
+ [/^〔设计变量〕([^::\s]+)/, (m) => m[1]],
304
+ [/^(〔参考图〕|\[Reference image\])/, () => '参考图'],
305
+ ]
306
+ const BLOCK_DETAIL = /^((文件|页面|位置|组件|HTML|样式|包含|主要颜色):|(Main colors|File|Page):|- |照这张参考图|(Restyle|Design) .*this reference)/
307
+ const TEXT_ASK = /^(?:把页面上的「(.+?)」改成「(.*?)」|Change the text "(.+?)" to "(.*?)")/
308
+
309
+ /** 用户这一句说了什么(给版本起名):只认用户自己打的字;点选 / 变量 / 参考图的块换成一个短名字,取前 40 个字 */
310
+ export function askOf(data) {
311
+ if (!data || !data.source || data.source.kind !== 'user') return ''
312
+ const c = data.content
313
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n') : ''
314
+ const subjects = []
315
+ let words = ''
316
+ // 块有时紧贴在用户那句话后面(同一行),先断开
317
+ const lines = String(text).replace(/(?=〔(选中元素|选中区域|设计变量|参考图)〕|\[Reference image\])/g, '\n').split('\n')
318
+ for (const l of lines.map((x) => x.trim())) {
319
+ if (!l || l.startsWith('<') || BLOCK_DETAIL.test(l)) continue
320
+ const head = BLOCK_HEAD.find(([re]) => re.test(l))
321
+ if (head) {
322
+ const sub = head[1](l.match(head[0])).trim()
323
+ if (sub && !subjects.includes(sub)) subjects.push(sub)
324
+ continue
325
+ }
326
+ const ta = l.match(TEXT_ASK)
327
+ if (ta) { words = words || `改字「${ta[1] || ta[3]}」→「${ta[2] ?? ta[4] ?? ''}」`; continue }
328
+ words = words || l
329
+ }
330
+ const sub = subjects.slice(0, 2).join('、') + (subjects.length > 2 ? ' 等' : '')
331
+ const line = sub && words ? `${sub} · ${words}` : sub ? (sub === '参考图' ? '照参考图改' : `改${sub}`) : words
332
+ return line.length > 40 ? line.slice(0, 40) + '…' : line
333
+ }
334
+
335
+ /**
336
+ * @param opts.alive (origin) → 这个预览服务器还开着吗(开的太多时旧的会被关掉;发现没了就重开)
337
+ */
338
+ export function createTracker({ open, skipPorts = () => new Set(), now = Date.now, log = () => {}, store = null, history = null, alive = () => true }) {
339
+ const states = new Map() // sessionId → state
340
+ const cwds = new Map() // sessionId → 最近一次工具的工作目录
341
+ let saved = {}
342
+ try { saved = (store && store.load()) || {} } catch (e) { log(e) }
343
+ const restoring = new Set()
344
+
345
+ function persist(id, st) {
346
+ if (!store) return
347
+ if (st.target) saved[id] = { target: st.target, source: st.source, design: st.design, kind: st.kind, size: st.size, view: st.view, routes: st.routes, order: st.target.kind === 'file' ? st.screens : undefined, at: now() }
348
+ else delete saved[id]
349
+ // 只留最近 200 个会话
350
+ const ids = Object.keys(saved)
351
+ if (ids.length > 200) {
352
+ ids.sort((a, b) => saved[a].at - saved[b].at).slice(0, ids.length - 200).forEach((k) => delete saved[k])
353
+ }
354
+ try { store.save(saved) } catch (e) { log(e) }
355
+ }
356
+
357
+ /** 内存里没有、盘上记着的:重新开预览服务器(文件还在才开),不弹右侧栏 */
358
+ function restore(id) {
359
+ const rec = saved[id]
360
+ if (!rec || !rec.target || restoring.has(id)) return
361
+ const t = rec.target
362
+ if (t.kind === 'file' && !fs.existsSync(t.file)) return
363
+ // 以前不分是不是在做设计、自动打开的(比如别的项目的端口):不是设计稿就不再接着显示
364
+ if (rec.source === 'auto' && !rec.design && !(t.kind === 'file' && isDesignHtml(t.file))) return
365
+ stateOf(id).design = stateOf(id).design || !!rec.design
366
+ restoring.add(id)
367
+ setTarget(id, t, { source: rec.source || 'auto', popup: false, kind: rec.kind || '', size: rec.size || '', view: rec.view || '', routes: rec.routes || null, order: rec.order })
368
+ .catch((e) => log(e))
369
+ .finally(() => restoring.delete(id))
370
+ }
371
+
372
+ /** 预览服务器被关了(开的太多,没人连着的旧服务器先关):照原样重开一个,先试原来的端口(地址不变,右侧栏不用重载) */
373
+ function revive(id, st) {
374
+ if (restoring.has(id)) return
375
+ const t = st.target
376
+ let port = 0
377
+ try { port = Number(new URL(st.origin).port) || 0 } catch { port = 0 }
378
+ restoring.add(id)
379
+ Promise.resolve()
380
+ .then(() => open(t, { port }))
381
+ .then(({ origin }) => {
382
+ if (st.target !== t) return // 这期间换了别的页面
383
+ st.origin = origin
384
+ st.url = urlFor(t, origin)
385
+ })
386
+ .catch((e) => log(e))
387
+ .finally(() => restoring.delete(id))
388
+ }
389
+
390
+ function stateOf(id) {
391
+ let st = states.get(id)
392
+ if (!st) {
393
+ st = { target: null, origin: '', url: '', label: '', source: '', rev: 0, openRev: 0, turn: false, writing: '', changedAt: 0, error: '',
394
+ kind: '', size: '', view: '', routes: null, screens: [], ask: '', vrev: 0, design: false, hint: '', calls: new Map(), jobs: new Map() }
395
+ states.set(id, st)
396
+ }
397
+ return st
398
+ }
399
+
400
+ function cwdOf(id) {
401
+ return cwds.get(id) || os.homedir()
402
+ }
403
+
404
+ /** 新的一屏排到最后;「再出两版」的 login-b.html / login-c.html 紧跟在 login.html 后面,方便挑 */
405
+ function placeScreen(list, k) {
406
+ const VAR = /^(.+)-[b-z]\.html?$/
407
+ const stem = (x) => x.toLowerCase().replace(/\.html?$/, '')
408
+ const m = VAR.exec(k.toLowerCase())
409
+ if (m && list.some((x) => stem(x) === m[1])) {
410
+ let at = -1
411
+ list.forEach((x, i) => { if (stem(x) === m[1] || (VAR.exec(x.toLowerCase()) || [])[1] === m[1]) at = i })
412
+ list.splice(at + 1, 0, k)
413
+ return
414
+ }
415
+ list.push(k)
416
+ }
417
+
418
+
419
+ function refreshScreens(st) {
420
+ const t = st.target
421
+ if (!t) { st.screens = []; return }
422
+ if (t.kind === 'file') {
423
+ const found = listScreens(t.root)
424
+ const me = path.basename(t.file)
425
+ if (!found.includes(me)) found.push(me)
426
+ // 已经排好的几屏位置不动:编辑器改文件常是写临时文件再换名,创建时间会变,按它重排画布会乱跳
427
+ const list = st.screens.filter((k) => found.includes(k))
428
+ for (const k of found) if (!list.includes(k)) placeScreen(list, k)
429
+ st.screens = list.slice(0, MAX_SCREENS)
430
+ } else {
431
+ const list = st.routes ? [...st.routes] : []
432
+ if (!list.includes(t.path)) list.unshift(t.path)
433
+ st.screens = list
434
+ }
435
+ }
436
+
437
+ /** 和现在预览的是不是同一个目录 / 同一个开发服务器(是的话类型、视图这些设置留着) */
438
+ function sameRoot(a, b) {
439
+ if (!a || !b || a.kind !== b.kind) return false
440
+ return a.kind === 'file' ? a.root === b.root : a.port === b.port
441
+ }
442
+
443
+ /** 设预览目标并开服务器;popup 为真时让右侧栏弹出来。kind / size / view / routes 不传就沿用(换了目录就清掉) */
444
+ async function setTarget(id, target, { source, popup = true, kind, size, view, routes, order } = {}) {
445
+ const st = stateOf(id)
446
+ const { origin } = await open(target)
447
+ const changed = !sameTarget(st.target, target)
448
+ if (!sameRoot(st.target, target)) {
449
+ st.kind = ''
450
+ st.size = ''
451
+ st.view = ''
452
+ st.routes = null
453
+ st.screens = []
454
+ }
455
+ if (Array.isArray(order)) st.screens = order.filter((k) => typeof k === 'string')
456
+ if (kind !== undefined) st.kind = parseKind(kind)
457
+ if (size !== undefined) st.size = parseSize(size)
458
+ if (view !== undefined) st.view = view === 'canvas' || view === 'single' ? view : ''
459
+ if (routes !== undefined) st.routes = normRoutes(routes)
460
+ st.target = target
461
+ st.origin = origin
462
+ st.url = urlFor(target, origin)
463
+ st.label = labelOf(target)
464
+ st.source = source
465
+ if (source === 'tool' || source === 'user') st.design = true // 模型调了预览工具 / 用户自己打开:这个会话就是在做设计
466
+ st.hint = ''
467
+ st.error = ''
468
+ if (changed) st.rev++
469
+ if (popup) st.openRev++
470
+ refreshScreens(st)
471
+ persist(id, st)
472
+ return st
473
+ }
474
+
475
+ /** 右侧栏改设置:类型、尺寸、视图,或者换到另一屏(focus = 那一屏的文件名 / 路由) */
476
+ async function setView(id, { kind, size, view, focus } = {}) {
477
+ const st = stateOf(id)
478
+ if (!st.target) throw new Error('还没有在预览的页面')
479
+ if (kind !== undefined) st.kind = parseKind(kind)
480
+ if (size !== undefined) st.size = parseSize(size)
481
+ if (view !== undefined) st.view = view === 'canvas' || view === 'single' ? view : ''
482
+ if (focus) {
483
+ const t = st.target
484
+ const key = String(focus)
485
+ if (t.kind === 'file') {
486
+ if (!listScreens(t.root).includes(key) && key !== path.basename(t.file)) throw new Error(`没有这一屏:${key}`)
487
+ await setTarget(id, { kind: 'file', file: path.join(t.root, key), root: t.root }, { source: st.source, popup: false })
488
+ } else {
489
+ if (!key.startsWith('/')) throw new Error(`路由要以 / 开头:${key}`)
490
+ await setTarget(id, { kind: 'proxy', port: t.port, path: key }, { source: st.source, popup: false })
491
+ }
492
+ }
493
+ persist(id, st)
494
+ return st
495
+ }
496
+
497
+ function clear(id) {
498
+ const st = stateOf(id)
499
+ st.target = null
500
+ st.url = ''
501
+ st.label = ''
502
+ st.source = ''
503
+ st.rev++
504
+ persist(id, st)
505
+ }
506
+
507
+ /** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1 */
508
+ function changed(root) {
509
+ for (const [id, st] of states) {
510
+ if (st.target && st.target.kind === 'file' && st.target.root === root) {
511
+ st.rev++
512
+ st.changedAt = now()
513
+ const before = st.screens.join('\n')
514
+ refreshScreens(st)
515
+ if (st.screens.join('\n') !== before) persist(id, st)
516
+ }
517
+ }
518
+ }
519
+
520
+ function auto(id, target) {
521
+ setTarget(id, target, { source: 'auto' }).catch((e) => log(e))
522
+ }
523
+
524
+ /** 会话事件 → 状态;自动发现只在没有目标、或目标也是自动发现的时候动手 */
525
+ function observe(sessionId, event) {
526
+ if (!sessionId || !event) return
527
+ const d = event.data || {}
528
+ const live = typeof event.time !== 'number' || now() - event.time < LIVE_MS
529
+ const st = stateOf(sessionId)
530
+ switch (event.type) {
531
+ case 'user/message': {
532
+ const ask = live ? askOf(d) : ''
533
+ if (ask) st.ask = ask
534
+ if (userLoadedSkill(d)) st.design = true
535
+ break
536
+ }
537
+ case 'turn/start':
538
+ if (live) {
539
+ st.turn = true
540
+ keepVersion(st, '')
541
+ }
542
+ break
543
+ case 'turn/end':
544
+ st.turn = false
545
+ st.writing = ''
546
+ if (live) keepVersion(st, st.ask)
547
+ st.ask = '' // 用过就清:下一轮不是用户发起的(没有新的一句话)就不借这一句当名字
548
+ break
549
+ case 'tool/call': {
550
+ // 加载了设计技能 / 调了预览工具:这个会话在做设计(重放历史时也认,DSH 重启后不丢)
551
+ if (d.name === 'skill' && argsOf(d.arguments).name === DESIGN_SKILL) st.design = true
552
+ if (d.name === 'preview_page') st.design = true
553
+ if (!live) break
554
+ if (d.callId) {
555
+ st.calls.set(d.callId, { name: d.name, args: argsOf(d.arguments) })
556
+ if (st.calls.size > 64) st.calls.delete(st.calls.keys().next().value)
557
+ }
558
+ const p = writtenPath(d.name, d.arguments)
559
+ if (!p) break
560
+ const abs = path.resolve(cwdOf(sessionId), p.replace(/^~(?=\/|$)/, os.homedir()))
561
+ if (st.target && st.target.kind === 'file' && (abs === st.target.file || abs.startsWith(st.target.root + path.sep))) {
562
+ st.writing = path.basename(abs)
563
+ }
564
+ if (HTML_EXT.test(abs) && (!st.target || (st.source === 'auto' && st.target.kind === 'file'))) {
565
+ st.pendingHtml = abs // 写完(tool/result)再预览,免得打开一个还不存在的文件
566
+ }
567
+ break
568
+ }
569
+ case 'tool/result': {
570
+ if (d.meta && typeof d.meta.cwd === 'string' && d.meta.cwd) cwds.set(sessionId, d.meta.cwd)
571
+ st.writing = ''
572
+ if (!live) break
573
+ const pending = st.pendingHtml
574
+ st.pendingHtml = ''
575
+ const callId = (d.message && (d.message.toolCallId || (d.message.source && d.message.source.callId))) || d.callId || ''
576
+ const call = callId ? st.calls.get(callId) : null
577
+ if (callId) st.calls.delete(callId)
578
+ if (pending && fs.existsSync(pending) && (!st.target || st.source === 'auto') && !(st.target && st.target.file === pending)) {
579
+ // 只有在做设计(加载了设计技能、或页面写了设计类型标记)才自动打开;别的任务写的网页只记下来,不弹
580
+ if (st.design || isDesignHtml(pending)) {
581
+ st.design = true
582
+ auto(sessionId, { kind: 'file', file: pending, root: path.dirname(pending) })
583
+ } else st.hint = pending
584
+ }
585
+ if (st.target && st.source !== 'auto') break
586
+ if (!st.design || !call) break
587
+ const err = d.message && d.message.isError
588
+ if (err) break
589
+ const texts = textsOf(d.message && d.message.content)
590
+ let command = ''
591
+ if (SHELL_TOOL.test(String(call.name))) {
592
+ command = String(call.args.command || '')
593
+ for (const text of texts) {
594
+ const m = BG_JOB.exec(text)
595
+ if (!m) continue
596
+ st.jobs.set(m[1], command)
597
+ if (st.jobs.size > 32) st.jobs.delete(st.jobs.keys().next().value)
598
+ }
599
+ } else if (JOB_READ_TOOL.test(String(call.name))) {
600
+ // 不认识的 job(比如插件重启前起的):命令当空的,只认像启动信息的那一行
601
+ command = st.jobs.get(String(call.args.job_id || call.args.jobId || call.args.id || '')) || ''
602
+ } else break
603
+ for (const text of texts) {
604
+ const found = devServerIn(command, text, skipPorts())
605
+ if (found) {
606
+ const t = { kind: 'proxy', port: found.port, path: '/' }
607
+ if (!sameTarget(st.target, t)) auto(sessionId, t)
608
+ break
609
+ }
610
+ }
611
+ break
612
+ }
613
+ }
614
+ }
615
+
616
+ /** 本地文件预览:存一版(和上一版一样就不存);存了就让右侧栏刷新版本列表 */
617
+ function keepVersion(st, label) {
618
+ if (!history || !st.target || st.target.kind !== 'file') return null
619
+ const v = history.snapshot(st.target.root, label)
620
+ if (v) st.vrev++
621
+ return v
622
+ }
623
+
624
+ /**
625
+ * 预览里直接改字:在这一屏(key = 文件名)的 HTML 里把第 nth 处 before 换成 after,改前改后各存一版。
626
+ * 开发服务器的页面源码在项目里、不一定是这份 HTML,不在这里改(右侧栏改成让模型去改)。
627
+ * 模型正在干活时不改:它可能正整份重写这个文件,改了也会被盖掉(右侧栏改成让模型去改)。
628
+ */
629
+ function editText(id, { key, before, after, nth = 0 } = {}) {
630
+ const st = states.get(id)
631
+ const t = st && st.target
632
+ if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
633
+ if (st.turn) return { ok: false, reason: 'busy' }
634
+ const name = String(key || path.basename(t.file))
635
+ const file = path.join(t.root, name)
636
+ if (path.dirname(file) !== t.root || !HTML_EXT.test(name) || !fs.existsSync(file)) return { ok: false, reason: 'no-screen' }
637
+ // 软链接指到目录外的不读也不写
638
+ let real = ''
639
+ try { real = fs.realpathSync(file) } catch { real = '' }
640
+ if (!real.startsWith(fs.realpathSync(t.root) + path.sep)) return { ok: false, reason: 'no-screen' }
641
+ const r = replaceText(fs.readFileSync(file, 'utf8'), before, after, Math.max(0, Math.floor(Number(nth) || 0)))
642
+ if (!r.ok) return r
643
+ keepVersion(st, '')
644
+ fs.writeFileSync(file, r.html)
645
+ const short = String(after).replace(/\s+/g, ' ').trim()
646
+ keepVersion(st, `改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
647
+ return { ok: true, file: name }
648
+ }
649
+
650
+ /** 版本列表(新的在前);开发服务器没有版本(项目自己有 git) */
651
+ function versions(id) {
652
+ const st = states.get(id)
653
+ if (!history || !st || !st.target || st.target.kind !== 'file') return []
654
+ return history.list(st.target.root)
655
+ }
656
+
657
+ /** 回到某一版:现在的先存一版,再写回那一版的文件(目录监听会让预览自己刷新) */
658
+ function restoreVersion(id, n) {
659
+ const st = states.get(id)
660
+ if (!history || !st || !st.target || st.target.kind !== 'file') throw new Error('这个预览没有版本记录')
661
+ const r = history.restore(st.target.root, n)
662
+ st.vrev++
663
+ return r
664
+ }
665
+
666
+ function snapshot(id) {
667
+ const st = states.get(id)
668
+ if (!(st && st.target)) restore(id)
669
+ else if (st.origin && !alive(st.origin)) revive(id, st)
670
+ if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, writing: '', hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
671
+ const { target, url, label, source, rev, openRev, turn, writing, changedAt, kind, size, view, origin } = st
672
+ const file = target.kind === 'file'
673
+ return {
674
+ target: file ? { kind: 'file', file: target.file } : { kind: 'proxy', port: target.port, path: target.path },
675
+ url,
676
+ label,
677
+ source,
678
+ rev,
679
+ openRev,
680
+ turn,
681
+ writing,
682
+ changedAt,
683
+ kind,
684
+ size,
685
+ view,
686
+ origin,
687
+ vrev: st.vrev,
688
+ focus: file ? path.basename(target.file) : target.path,
689
+ screens: st.screens.map((key) => ({
690
+ key,
691
+ url: file ? `${origin}/${encodeURIComponent(key)}` : origin + key,
692
+ label: file ? key.replace(HTML_EXT, '') : key,
693
+ })),
694
+ }
695
+ }
696
+
697
+ return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
698
+ }