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/LICENSE +21 -0
- package/README.en.md +89 -0
- package/README.md +142 -2
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/lib/client.js +2843 -0
- package/lib/core.js +684 -0
- package/lib/history.js +244 -0
- package/lib/index.js +470 -0
- package/lib/phone.js +355 -0
- package/lib/picker.js +1403 -0
- package/lib/qr.js +263 -0
- package/lib/serve.js +513 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/skills/ui-design/SKILL.md +90 -0
- package/skills/ui-design/references/app.md +30 -0
- package/skills/ui-design/references/email.md +17 -0
- package/skills/ui-design/references/poster.md +21 -0
- package/skills/ui-design/references/slides.md +19 -0
- package/skills/ui-design/references/web.md +21 -0
- package/skills/ui-design/tokens.css +70 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-design-studio(服务端)
|
|
3
|
+
*
|
|
4
|
+
* preview_page 工具 模型做界面时调一次,右侧栏「设计工作台」打开这个页面(可以带类型:网页 / App / 海报 …)
|
|
5
|
+
* 自动发现 模型写了 HTML、或命令输出里出现 localhost:端口(开发服务器起来了),自动预览
|
|
6
|
+
* ui-design 技能 设计流程、设计原则、中文排版、各类型要点、避免 AI 味、自查清单(skills/ui-design/,DSH 有技能服务时才挂上)
|
|
7
|
+
* 接口(DSH 自己的地址,要登录态):
|
|
8
|
+
* GET /dsh-design-studio/api/state?session= 右侧栏轮询:预览什么、有哪几屏、要不要弹出来、模型在不在干活
|
|
9
|
+
* POST /dsh-design-studio/api/open 地址栏输入新地址 { session, target }
|
|
10
|
+
* POST /dsh-design-studio/api/view 改类型 / 尺寸 / 视图,或换到另一屏 { session, kind?, size?, view?, focus? }
|
|
11
|
+
* POST /dsh-design-studio/api/close 清掉预览 { session }
|
|
12
|
+
* GET /dsh-design-studio/api/history?session= 设计版本(每轮对话前后各存一版),新的在前
|
|
13
|
+
* POST /dsh-design-studio/api/restore 回到某一版 { session, n }(现在的先存一版)
|
|
14
|
+
* POST /dsh-design-studio/api/export 存导出的图片 { session, name, png: base64 } → 设计目录的 exports/
|
|
15
|
+
* POST /dsh-design-studio/api/ref 存参考图 { session, name, data: base64 } → 设计目录的 refs/(没有设计目录就存插件缓存)
|
|
16
|
+
* POST /dsh-design-studio/api/reveal 在访达 / 资源管理器里显示刚导出的图片 { path }
|
|
17
|
+
* POST /dsh-design-studio/api/edit 预览里直接改字 { session, key, before, after, nth } → 写回那一屏的 HTML(改前改后各存一版)
|
|
18
|
+
* POST /dsh-design-studio/api/phone 手机上看:{ session, on: true, path } 开局域网入口(给链接和二维码)、
|
|
19
|
+
* { on: false } 关掉、{ follow: path } 电脑上换了一屏手机跟着跳
|
|
20
|
+
*
|
|
21
|
+
* 页面本身由 serve.js 开的独立端口提供(见那边的说明)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import os from 'node:os'
|
|
28
|
+
import { execFile } from 'node:child_process'
|
|
29
|
+
import { createHub, webOrigins, trustedOrigins } from './serve.js'
|
|
30
|
+
import { createTracker, parseTarget, KINDS } from './core.js'
|
|
31
|
+
import { createHistory } from './history.js'
|
|
32
|
+
import { createPhone } from './phone.js'
|
|
33
|
+
import { qrMatrix } from './qr.js'
|
|
34
|
+
|
|
35
|
+
export const name = 'dsh-design-studio'
|
|
36
|
+
export const inject = ['tools', 'webServer']
|
|
37
|
+
|
|
38
|
+
const API = '/dsh-design-studio/api/'
|
|
39
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
40
|
+
const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
|
|
41
|
+
const MAX_BODY = 64 * 1024
|
|
42
|
+
const MAX_EXPORT = 48 * 1024 * 1024 // 导出图片的请求体(base64)
|
|
43
|
+
const HISTORY_DIR = path.join(process.env.HOME || '.', '.dsh', 'cache', 'dsh-design-studio', 'history')
|
|
44
|
+
// 每个会话预览的是什么,存这里(DSH 重启后接着显示)
|
|
45
|
+
// 参考图:没有本地设计目录(开发服务器 / 还没开始)时存这里
|
|
46
|
+
const REFS_DIR = path.join(process.env.HOME || '.', '.dsh', 'cache', 'dsh-design-studio', 'refs')
|
|
47
|
+
const STORE_FILE = path.join(process.env.HOME || '.', '.dsh', 'cache', 'dsh-design-studio.json')
|
|
48
|
+
const SKILL_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'ui-design')
|
|
49
|
+
const SKILL_RANK = 600 // 和 DSH 自带的技能同一档;用户 / 项目里同名的技能优先
|
|
50
|
+
|
|
51
|
+
/** 设计技能:模型做界面设计时加载(DSH 的 skill 工具),也能让用户手动调 */
|
|
52
|
+
export const designSkill = {
|
|
53
|
+
name: 'ui-design',
|
|
54
|
+
description:
|
|
55
|
+
'做网页、App 界面、平板界面、海报 / 社媒图、幻灯片、邮件、组件这类界面设计之前先加载:' +
|
|
56
|
+
'配合右侧「设计工作台」画布的做法(一屏一个文件、设计变量、类型标记)、现代设计原则、中文排版、各类型画幅要点、避免 AI 味、设计自查清单。',
|
|
57
|
+
whenToUse: '用户要做或改任何看得见的界面 / 视觉设计时',
|
|
58
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
59
|
+
provider: 'dsh-design-studio',
|
|
60
|
+
source: 'bundled',
|
|
61
|
+
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
62
|
+
rank: SKILL_RANK,
|
|
63
|
+
locator: path.join(SKILL_DIR, 'SKILL.md'),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const skillProvider = {
|
|
67
|
+
name: 'dsh-design-studio',
|
|
68
|
+
list: () => Promise.resolve([designSkill]),
|
|
69
|
+
async get() {
|
|
70
|
+
const { name, description, whenToUse, invocation, provider, source, resourceBase } = designSkill
|
|
71
|
+
return { name, description, whenToUse, invocation, provider, source, resourceBase, content: await fs.promises.readFile(designSkill.locator, 'utf8') }
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fileStore = {
|
|
76
|
+
load: () => JSON.parse(fs.readFileSync(STORE_FILE, 'utf8')),
|
|
77
|
+
save: (data) => {
|
|
78
|
+
fs.mkdirSync(path.dirname(STORE_FILE), { recursive: true })
|
|
79
|
+
fs.writeFileSync(STORE_FILE + '.tmp', JSON.stringify(data), { mode: 0o600 })
|
|
80
|
+
fs.renameSync(STORE_FILE + '.tmp', STORE_FILE)
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ownHost(ctx) {
|
|
85
|
+
try { return new URL(ctx.webServer.getOrigin()).host } catch { return '' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fenceOk(req, own) {
|
|
89
|
+
const host = String(req.headers.host || '')
|
|
90
|
+
if (!LOOPBACK_HOST.test(host) && !(own && host === own)) return false
|
|
91
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
92
|
+
const origin = req.headers.origin
|
|
93
|
+
if (origin === undefined) return true
|
|
94
|
+
try { return new URL(origin).host === host } catch { return false }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function connectionOf(ctx) {
|
|
98
|
+
try {
|
|
99
|
+
const c = ctx.get('connection')
|
|
100
|
+
return c && typeof c.requestRejection === 'function' ? c : null
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function json(res, code, body) {
|
|
107
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
108
|
+
res.end(JSON.stringify(body))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readBody(req, limit = MAX_BODY) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let size = 0
|
|
114
|
+
const chunks = []
|
|
115
|
+
req.on('data', (c) => {
|
|
116
|
+
size += c.length
|
|
117
|
+
if (size > limit) {
|
|
118
|
+
reject(new Error('too large'))
|
|
119
|
+
req.destroy()
|
|
120
|
+
} else chunks.push(c)
|
|
121
|
+
})
|
|
122
|
+
req.on('end', () => {
|
|
123
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) } catch { reject(new Error('bad json')) }
|
|
124
|
+
})
|
|
125
|
+
req.on('error', reject)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function specToJsonSchema(spec) {
|
|
130
|
+
const properties = {}
|
|
131
|
+
const required = []
|
|
132
|
+
for (const [key, node] of Object.entries(spec)) {
|
|
133
|
+
const { required: isRequired, ...rest } = node
|
|
134
|
+
properties[key] = rest
|
|
135
|
+
if (isRequired) required.push(key)
|
|
136
|
+
}
|
|
137
|
+
return { type: 'object', properties, ...(required.length ? { required } : {}) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function apply(ctx) {
|
|
141
|
+
const log = (e) => {
|
|
142
|
+
try { ctx.logger.warn(`dsh-design-studio: ${e && e.message ? e.message : e}`) } catch { /* 没 logger 就算了 */ }
|
|
143
|
+
}
|
|
144
|
+
const previewPorts = new Set()
|
|
145
|
+
let tracker = null
|
|
146
|
+
const history = createHistory({ base: HISTORY_DIR, log })
|
|
147
|
+
const hub = createHub({
|
|
148
|
+
log,
|
|
149
|
+
history,
|
|
150
|
+
// 桌面版的 dsh-app://app 是默认就认的;这里补上 DSH 网页端的地址,和 DSH 配置里声明的对外地址(反向代理 / 域名)
|
|
151
|
+
parents: () => {
|
|
152
|
+
let own = []
|
|
153
|
+
try { own = webOrigins(ctx.webServer.getOrigin()) } catch { own = [] }
|
|
154
|
+
const conn = connectionOf(ctx)
|
|
155
|
+
return [...own, ...trustedOrigins(conn && conn.trustedHosts)]
|
|
156
|
+
},
|
|
157
|
+
onChange: (key) => {
|
|
158
|
+
if (tracker && key.startsWith('file:')) tracker.changed(key.slice(5))
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
const open = async (target, opts) => {
|
|
162
|
+
const r = await hub.open(target.kind === 'file' ? { kind: 'file', root: target.root } : target, opts)
|
|
163
|
+
previewPorts.add(Number(new URL(r.origin).port))
|
|
164
|
+
return r
|
|
165
|
+
}
|
|
166
|
+
tracker = createTracker({
|
|
167
|
+
open,
|
|
168
|
+
log,
|
|
169
|
+
history,
|
|
170
|
+
store: fileStore,
|
|
171
|
+
alive: (origin) => hub.alive(origin),
|
|
172
|
+
skipPorts: () => {
|
|
173
|
+
const s = new Set(previewPorts)
|
|
174
|
+
const own = ownHost(ctx).split(':')[1]
|
|
175
|
+
if (own) s.add(Number(own))
|
|
176
|
+
return s
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
ctx.effect(() => () => hub.closeAll(), 'dsh-design-studio: preview servers')
|
|
180
|
+
const phone = createPhone({ upstream: (session) => tracker.snapshot(session).origin || '', log })
|
|
181
|
+
ctx.effect(() => () => phone.stopAll(), 'dsh-design-studio: phone links')
|
|
182
|
+
|
|
183
|
+
// 设计技能:等 DSH 的技能服务就绪再挂(老版本没有就算了),插件卸载时一起摘掉
|
|
184
|
+
if (typeof ctx.inject === 'function') {
|
|
185
|
+
try {
|
|
186
|
+
ctx.inject(['skills'], (sub) => { sub.skills.registerProvider(() => skillProvider) })
|
|
187
|
+
} catch (e) {
|
|
188
|
+
log(e)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 接口 ──────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function admit(req, res) {
|
|
195
|
+
const conn = connectionOf(ctx)
|
|
196
|
+
if (conn) {
|
|
197
|
+
const rejected = conn.requestRejection(req)
|
|
198
|
+
if (rejected) {
|
|
199
|
+
json(res, rejected, { error: rejected === 401 ? 'unauthorized' : 'forbidden' })
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
if (!LOOPBACK.has(req.socket.remoteAddress) || !fenceOk(req, ownHost(ctx))) {
|
|
205
|
+
json(res, 403, { error: 'forbidden' })
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function state(req, res) {
|
|
212
|
+
if (!admit(req, res)) return
|
|
213
|
+
const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
|
|
214
|
+
const ph = phone.status(session)
|
|
215
|
+
json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 } })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function openRoute(req, res) {
|
|
219
|
+
if (!admit(req, res)) return
|
|
220
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
221
|
+
let body
|
|
222
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
223
|
+
const session = String(body.session || '')
|
|
224
|
+
if (!session) return json(res, 400, { error: '缺 session' })
|
|
225
|
+
try {
|
|
226
|
+
const target = parseTarget(body.target, tracker.cwdOf(session))
|
|
227
|
+
await tracker.setTarget(session, target, { source: 'user', popup: false })
|
|
228
|
+
json(res, 200, tracker.snapshot(session))
|
|
229
|
+
} catch (e) {
|
|
230
|
+
json(res, 400, { error: e.message })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function viewRoute(req, res) {
|
|
235
|
+
if (!admit(req, res)) return
|
|
236
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
237
|
+
let body
|
|
238
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
239
|
+
const session = String(body.session || '')
|
|
240
|
+
if (!session) return json(res, 400, { error: '缺 session' })
|
|
241
|
+
const patch = {}
|
|
242
|
+
for (const k of ['kind', 'size', 'view', 'focus']) if (typeof body[k] === 'string') patch[k] = body[k]
|
|
243
|
+
try {
|
|
244
|
+
await tracker.setView(session, patch)
|
|
245
|
+
json(res, 200, tracker.snapshot(session))
|
|
246
|
+
} catch (e) {
|
|
247
|
+
json(res, 400, { error: e.message })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function closeRoute(req, res) {
|
|
252
|
+
if (!admit(req, res)) return
|
|
253
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
254
|
+
let body
|
|
255
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
256
|
+
tracker.clear(String(body.session || ''))
|
|
257
|
+
phone.stop(String(body.session || ''))
|
|
258
|
+
json(res, 200, tracker.snapshot(String(body.session || '')))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function historyRoute(req, res) {
|
|
262
|
+
if (!admit(req, res)) return
|
|
263
|
+
const session = new URL(req.url, 'http://x').searchParams.get('session') || ''
|
|
264
|
+
json(res, 200, { versions: tracker.versions(session) })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function restoreRoute(req, res) {
|
|
268
|
+
if (!admit(req, res)) return
|
|
269
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
270
|
+
let body
|
|
271
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
272
|
+
try {
|
|
273
|
+
const r = tracker.restoreVersion(String(body.session || ''), Number(body.n))
|
|
274
|
+
json(res, 200, { ...r, versions: tracker.versions(String(body.session || '')) })
|
|
275
|
+
} catch (e) {
|
|
276
|
+
json(res, 400, { error: e.message })
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 预览里直接改字:写回这一屏的 HTML;找不到那段字就告诉右侧栏(它会改成让模型去改) */
|
|
281
|
+
async function editRoute(req, res) {
|
|
282
|
+
if (!admit(req, res)) return
|
|
283
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
284
|
+
let body
|
|
285
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
286
|
+
try {
|
|
287
|
+
json(res, 200, tracker.editText(String(body.session || ''), { key: body.key, before: body.before, after: body.after, nth: body.nth }))
|
|
288
|
+
} catch (e) {
|
|
289
|
+
json(res, 500, { error: e.message })
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const exported = new Set() // 这次运行里导出过的图片(只许在访达里显示这些)
|
|
294
|
+
|
|
295
|
+
/** 导出的图片存哪:本地文件预览存设计目录的 exports/,开发服务器存「下载」 */
|
|
296
|
+
function exportDir(session) {
|
|
297
|
+
const st = tracker.stateOf(session)
|
|
298
|
+
if (st.target && st.target.kind === 'file') return path.join(st.target.root, 'exports')
|
|
299
|
+
return path.join(os.homedir(), 'Downloads')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function exportRoute(req, res) {
|
|
303
|
+
if (!admit(req, res)) return
|
|
304
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
305
|
+
let body
|
|
306
|
+
try { body = await readBody(req, MAX_EXPORT) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
307
|
+
const session = String(body.session || '')
|
|
308
|
+
const buf = Buffer.from(String(body.png || ''), 'base64')
|
|
309
|
+
if (buf.length < 8 || buf.readUInt32BE(0) !== 0x89504e47) return json(res, 400, { error: '不是 PNG' })
|
|
310
|
+
const base = String(body.name || 'design').replace(/\.png$/i, '').replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'design'
|
|
311
|
+
const dir = exportDir(session)
|
|
312
|
+
try {
|
|
313
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
314
|
+
let file = path.join(dir, base + '.png')
|
|
315
|
+
for (let i = 2; fs.existsSync(file) && i < 1000; i++) file = path.join(dir, `${base}-${i}.png`)
|
|
316
|
+
fs.writeFileSync(file, buf)
|
|
317
|
+
exported.add(file)
|
|
318
|
+
json(res, 200, { path: file })
|
|
319
|
+
} catch (e) {
|
|
320
|
+
json(res, 500, { error: e.message })
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 图片格式看文件头,不信文件名 */
|
|
325
|
+
function imageExt(buf) {
|
|
326
|
+
if (buf.length < 12) return ''
|
|
327
|
+
if (buf.readUInt32BE(0) === 0x89504e47) return 'png'
|
|
328
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpg'
|
|
329
|
+
if (buf.toString('latin1', 0, 4) === 'GIF8') return 'gif'
|
|
330
|
+
if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'webp'
|
|
331
|
+
return ''
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 存参考图:本地文件预览存设计目录的 refs/,不然存插件缓存;同名不覆盖 */
|
|
335
|
+
async function refRoute(req, res) {
|
|
336
|
+
if (!admit(req, res)) return
|
|
337
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
338
|
+
let body
|
|
339
|
+
try { body = await readBody(req, MAX_EXPORT) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
340
|
+
const buf = Buffer.from(String(body.data || ''), 'base64')
|
|
341
|
+
const ext = imageExt(buf)
|
|
342
|
+
if (!ext) return json(res, 400, { error: '不是图片' })
|
|
343
|
+
const st = tracker.stateOf(String(body.session || ''))
|
|
344
|
+
const dir = st.target && st.target.kind === 'file' ? path.join(st.target.root, 'refs') : REFS_DIR
|
|
345
|
+
const base = String(body.name || 'ref').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/[^\w\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'ref'
|
|
346
|
+
try {
|
|
347
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
348
|
+
let file = path.join(dir, `${base}.${ext}`)
|
|
349
|
+
for (let i = 2; fs.existsSync(file) && i < 1000; i++) file = path.join(dir, `${base}-${i}.${ext}`)
|
|
350
|
+
fs.writeFileSync(file, buf)
|
|
351
|
+
json(res, 200, { path: file })
|
|
352
|
+
} catch (e) {
|
|
353
|
+
json(res, 500, { error: e.message })
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function revealRoute(req, res) {
|
|
358
|
+
if (!admit(req, res)) return
|
|
359
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
360
|
+
let body
|
|
361
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
362
|
+
const file = String(body.path || '')
|
|
363
|
+
if (!exported.has(file) || !fs.existsSync(file)) return json(res, 404, { error: 'not exported here' })
|
|
364
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', ['-R', file]]
|
|
365
|
+
: process.platform === 'win32' ? ['explorer', [`/select,${file}`]]
|
|
366
|
+
: ['xdg-open', [path.dirname(file)]]
|
|
367
|
+
execFile(cmd, args, (e) => { if (e && process.platform !== 'win32') log(e) }) // explorer 正常也返回 1
|
|
368
|
+
json(res, 200, { ok: true })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 手机上看:开 / 关局域网入口,或让开着的手机跟着跳到另一屏 */
|
|
372
|
+
async function phoneRoute(req, res) {
|
|
373
|
+
if (!admit(req, res)) return
|
|
374
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
375
|
+
let body
|
|
376
|
+
try { body = await readBody(req) } catch (e) { return json(res, 400, { error: e.message }) }
|
|
377
|
+
const session = String(body.session || '')
|
|
378
|
+
if (!session) return json(res, 400, { error: '缺 session' })
|
|
379
|
+
if (typeof body.follow === 'string') return json(res, 200, { ok: phone.follow(session, body.follow) })
|
|
380
|
+
if (body.on === false) {
|
|
381
|
+
phone.stop(session)
|
|
382
|
+
return json(res, 200, phone.status(session))
|
|
383
|
+
}
|
|
384
|
+
if (!tracker.snapshot(session).origin) return json(res, 400, { error: '还没有在预览的页面' })
|
|
385
|
+
const st = await phone.start(session, typeof body.path === 'string' && body.path.startsWith('/') ? body.path : '/')
|
|
386
|
+
// 二维码一行一串 0 / 1,右侧栏画成 SVG
|
|
387
|
+
json(res, 200, st.on ? { ...st, qr: qrMatrix(st.url).map((row) => row.map((c) => (c ? 1 : 0)).join('')) } : st)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, ref: refRoute, export: exportRoute, reveal: revealRoute, phone: phoneRoute }
|
|
391
|
+
for (const [key, handler] of Object.entries(routes)) {
|
|
392
|
+
ctx.effect(() => ctx.webServer.register({
|
|
393
|
+
kind: 'exact',
|
|
394
|
+
path: API + key,
|
|
395
|
+
handler: (req, res) => {
|
|
396
|
+
Promise.resolve(handler(req, res)).catch((e) => {
|
|
397
|
+
log(e)
|
|
398
|
+
if (!res.headersSent) json(res, 500, { error: 'internal' })
|
|
399
|
+
})
|
|
400
|
+
},
|
|
401
|
+
}), `dsh-design-studio: ${key} route`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── 工具 ──────────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
ctx.tools.register({
|
|
407
|
+
name: 'preview_page',
|
|
408
|
+
description:
|
|
409
|
+
'在右侧栏「设计工作台」里实时打开一个页面,用户能边看边点选要改的地方。' +
|
|
410
|
+
'做网页、App 界面、海报、幻灯片、邮件这类界面设计时用:写好第一个 HTML 文件、或启动开发服务器后调一次就行,之后改文件会自动刷新,不用再调。' +
|
|
411
|
+
'本地 HTML 写文件路径;开发服务器写地址或端口(如 http://localhost:5173)。只能预览本机页面。' +
|
|
412
|
+
'kind 说明是哪类设计,右侧按它的画幅显示(App 套手机外框、幻灯片 16:9 …)。' +
|
|
413
|
+
'动手设计前先加载 ui-design 技能(设计流程、规范和自查清单)。' +
|
|
414
|
+
'要做好几屏(App 的几个界面、网站的几个页面)时,每屏一个 HTML 文件放在同一个目录,右侧画布会按生成顺序并排显示,一屏一屏写就行。' +
|
|
415
|
+
'用户在预览里点选的内容会以「〔选中元素〕」或「〔选中区域〕」开头出现在消息里,带 CSS 路径、HTML、关键样式,' +
|
|
416
|
+
'拿得到时还有组件和源码位置:照它找到源码里对应的地方再改。',
|
|
417
|
+
parameters: specToJsonSchema({
|
|
418
|
+
target: { type: 'string', required: true, description: '本地 HTML 文件的绝对路径,或开发服务器地址 / 端口,例如 /Users/me/site/index.html、http://localhost:5173、5173' },
|
|
419
|
+
kind: {
|
|
420
|
+
type: 'string',
|
|
421
|
+
enum: KINDS,
|
|
422
|
+
description: '设计类型:web 网页(默认)、app 手机 App 界面(390×844)、tablet 平板、poster 海报 / 社媒图(1080×1350)、slides 幻灯片(16:9)、email 邮件',
|
|
423
|
+
},
|
|
424
|
+
size: { type: 'string', description: '画幅尺寸,可选,如 1080x1920;不填按类型的默认尺寸' },
|
|
425
|
+
screens: {
|
|
426
|
+
type: 'array',
|
|
427
|
+
items: { type: 'string' },
|
|
428
|
+
description: '开发服务器上要并排显示的几个路由,可选,如 ["/", "/login", "/settings"]。本地 HTML 不用填:同目录的 HTML 文件自动并排',
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
output: {
|
|
432
|
+
schema: {
|
|
433
|
+
type: 'object',
|
|
434
|
+
additionalProperties: false,
|
|
435
|
+
properties: { text: { type: 'string' }, url: { type: 'string' } },
|
|
436
|
+
required: ['text'],
|
|
437
|
+
},
|
|
438
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
439
|
+
},
|
|
440
|
+
async execute(args, exec) {
|
|
441
|
+
const session = exec && exec.agent && exec.agent.session
|
|
442
|
+
const id = session && session.id
|
|
443
|
+
if (!id) throw new Error('拿不到当前会话,没法打开预览')
|
|
444
|
+
const target = parseTarget(args.target, tracker.cwdOf(id))
|
|
445
|
+
const opts = { source: 'tool' }
|
|
446
|
+
if (args.kind !== undefined) opts.kind = args.kind
|
|
447
|
+
if (args.size !== undefined) opts.size = args.size
|
|
448
|
+
if (args.screens !== undefined) opts.routes = args.screens
|
|
449
|
+
const st = await tracker.setTarget(id, target, opts)
|
|
450
|
+
const what = target.kind === 'file' ? target.file : `localhost:${target.port}${target.path}`
|
|
451
|
+
const many = target.kind === 'file' ? '同目录再写别的 HTML 文件,会作为新的一屏并排出现。' : ''
|
|
452
|
+
return {
|
|
453
|
+
text: `已在右侧「设计工作台」打开 ${what}。${target.kind === 'file' ? '这个目录里的文件一改就自动刷新。' + many : '开发服务器的热更新照常生效。'}`,
|
|
454
|
+
url: st.url,
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// ── 自动发现 ──────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
if (typeof ctx.on === 'function') {
|
|
462
|
+
ctx.on('session/event', (session, event) => {
|
|
463
|
+
try {
|
|
464
|
+
if (session && event && session.id) tracker.observe(session.id, event)
|
|
465
|
+
} catch (e) {
|
|
466
|
+
log(e)
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
}
|