dsh-design-studio 0.0.0-stage → 0.3.2

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