@dsh-plugins/dsh-museav-assets 0.1.1
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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.en.md +114 -0
- package/README.md +127 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1157 -0
- package/lib/index.js +492 -0
- package/lib/museav.js +320 -0
- package/package.json +68 -0
- package/types/client.d.ts +21 -0
- package/types/index.d.ts +73 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-museav-assets —— Host(Node)半。
|
|
3
|
+
*
|
|
4
|
+
* 职责只有一件事:把 museav CLI 的输出变成 client 半能直接渲染的 JSON。
|
|
5
|
+
* 自己不碰 ~/.museav.json,不转发 token —— 认证是 CLI 的事(见 lib/museav.js)。
|
|
6
|
+
*
|
|
7
|
+
* 路由(全部 exact,一次注册,路径唯一 —— 重复注册会整棵 Cordis 崩):
|
|
8
|
+
* GET /api/dsh-museav-assets/health CLI 在不在、什么版本
|
|
9
|
+
* GET /api/dsh-museav-assets/projects 工作区列表 + 出图/素材计数
|
|
10
|
+
* POST /api/dsh-museav-assets/projects { name } 新建工作区
|
|
11
|
+
* GET /api/dsh-museav-assets/assets?project=<id|名> 工作区素材库
|
|
12
|
+
* POST /api/dsh-museav-assets/assets 传素材(base64 → 临时文件 → CLI)
|
|
13
|
+
* DELETE /api/dsh-museav-assets/assets?id=<素材id> 删素材(CLI 是硬删)
|
|
14
|
+
* GET /api/dsh-museav-assets/templates?source=&category= 我的 / 租户 / 平台模板
|
|
15
|
+
* GET /api/dsh-museav-assets/jobs?limit=<n> 最近出图记录
|
|
16
|
+
*
|
|
17
|
+
* 失败语义:上游/CLI 出错一律 200 + `{ok:false,error}`,让 tab 内联显示原因;
|
|
18
|
+
* 只有「参数不合法」才回 400。tab 不因为一次网络抖动变成白屏。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { basename, extname, join } from 'node:path'
|
|
24
|
+
import {
|
|
25
|
+
runMuseav,
|
|
26
|
+
mergeProjects,
|
|
27
|
+
mergeAssets,
|
|
28
|
+
mergeTemplates,
|
|
29
|
+
parseJobArray,
|
|
30
|
+
summarizeJobs,
|
|
31
|
+
PROJECT_LIMIT,
|
|
32
|
+
} from './museav.js'
|
|
33
|
+
|
|
34
|
+
export const name = '@dsh-plugins/dsh-museav-assets'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 只需要 HTTP 载体。
|
|
38
|
+
*
|
|
39
|
+
* **不要**在这里声明 'config',也不要读 `ctx.config`(2026-09-29 实测连踩两次):
|
|
40
|
+
* · 不声明就读 → `cannot get property "config" without inject`
|
|
41
|
+
* · 声明了也等不到 → `pending (waiting for service: config)`,插件永远 pending
|
|
42
|
+
* 这套 Cordis 里 config 是 entry 上的 `Config` 描述符,由 class Service 形态的插件
|
|
43
|
+
* 自己持有(见 dsh-agent-default-model);函数式插件没有这条路。
|
|
44
|
+
* 所以要换 CLI 路径就走环境变量 `MUSEAW_BIN` —— 与 profile 里 MCP 桥的约定一致。
|
|
45
|
+
*
|
|
46
|
+
* 这类错**不会**把服务带崩,只会让插件静默变哑:日志一行、页面照常、路由 404。
|
|
47
|
+
* 所以「改完 host 半去 launchd.log 搜自己插件名」不是形式主义。
|
|
48
|
+
*/
|
|
49
|
+
export const inject = ['webServer']
|
|
50
|
+
|
|
51
|
+
/** museav 可执行文件名或绝对路径。运行时求值,测试就是靠它换成 stub。 */
|
|
52
|
+
function cliBin () {
|
|
53
|
+
return process.env.MUSEAW_BIN || 'museav'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 单条 CLI 命令的超时(毫秒)。出图类慢,列表类快,这里给列表用的宽松值。 */
|
|
57
|
+
const CLI_TIMEOUT_MS = 30000
|
|
58
|
+
|
|
59
|
+
/** 出图记录单次最多取几条(服务端固定只返回最近 50 条)。 */
|
|
60
|
+
const MAX_JOBS = 50
|
|
61
|
+
|
|
62
|
+
/** 宽高比白名单 —— 直接透传给 CLI,所以先在这儿挡一道,别把任意字符串塞进命令行。 */
|
|
63
|
+
const RATIOS = ['3:4', '9:16', '1:1', '4:3', '16:9']
|
|
64
|
+
|
|
65
|
+
/** 出图是同步跑完的(CLI 自己轮询到终态),实测 20~60s,超时给到 5 分钟。 */
|
|
66
|
+
const GEN_TIMEOUT_MS = 300000
|
|
67
|
+
|
|
68
|
+
/** 垫图下载上限:单张 8MB、总计 20MB,超了就不带这张 ref(不是致命错)。 */
|
|
69
|
+
const REF_MAX_BYTES = 8 * 1024 * 1024
|
|
70
|
+
const REF_TOTAL_MAX_BYTES = 20 * 1024 * 1024
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 传素材的载荷上限(base64 字符数)。
|
|
74
|
+
* base64 比原文大 ~33%,CLI 侧图片上限 8MB → 这里给 11MB 的字符预算。
|
|
75
|
+
* 更大的文件(视频/音频)不走这条路,让用户回 CLI 跑 `museav upload`。
|
|
76
|
+
*/
|
|
77
|
+
const MAX_UPLOAD_CHARS = 11 * 1024 * 1024
|
|
78
|
+
|
|
79
|
+
/** 路由前缀 —— 结尾不带斜杠(webServer 的 prefix 匹配规则)。 */
|
|
80
|
+
const PREFIX = '/api/dsh-museav-assets'
|
|
81
|
+
|
|
82
|
+
/** 同源校验:浏览器页面自身发起的请求才放行,避免被第三方页面读走素材清单。 */
|
|
83
|
+
function isSameOrigin (request) {
|
|
84
|
+
const site = request.headers['sec-fetch-site']
|
|
85
|
+
if (typeof site === 'string') return site === 'same-origin' || site === 'none'
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendJson (response, status, payload) {
|
|
90
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
91
|
+
response.end(JSON.stringify(payload))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 读 JSON 请求体。
|
|
96
|
+
*
|
|
97
|
+
* limit 是**字符**数上限:普通接口 4KB(入参只有一个项目名),传素材接口要放到
|
|
98
|
+
* MAX_UPLOAD_CHARS。超限不是 destroy 掉请求了事 —— 那会让浏览器看到网络错误而不是
|
|
99
|
+
* 一句人话,这里直接 resolve 一个 { tooLarge: true },由路由回 400 + 原因。
|
|
100
|
+
*/
|
|
101
|
+
function readJsonBody (request, limit = 4096) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
let raw = ''
|
|
104
|
+
let overflow = false
|
|
105
|
+
request.on('data', (chunk) => {
|
|
106
|
+
if (overflow) return
|
|
107
|
+
raw += chunk
|
|
108
|
+
if (raw.length > limit) { overflow = true; raw = '' }
|
|
109
|
+
})
|
|
110
|
+
request.on('end', () => {
|
|
111
|
+
if (overflow) return resolve({ tooLarge: true })
|
|
112
|
+
if (!raw.trim()) return resolve({})
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(raw)
|
|
115
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {})
|
|
116
|
+
} catch {
|
|
117
|
+
resolve({})
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
request.on('error', () => resolve({}))
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 把一张 CDN 直链下载成临时文件,交给 `museav gen --ref`。
|
|
126
|
+
*
|
|
127
|
+
* 为什么必须下载:CLI 的 --ref 收文件路径,素材库和历史作品给的全是直链。
|
|
128
|
+
* 尺寸从 URL 尾缀猜,猜不出就不给后缀 —— CLI 按字节内容判类型,后缀只是线索。
|
|
129
|
+
*
|
|
130
|
+
* @param {{spent: number}} budget 多张垫图共用的字节预算,超了就别再下了
|
|
131
|
+
* @returns {Promise<string|null>} 落盘路径;下不来返回 null(调用方跳过这张 ref)
|
|
132
|
+
*/
|
|
133
|
+
async function downloadRef (href, dir, index, budget) {
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(href, { signal: AbortSignal.timeout(20000) })
|
|
136
|
+
if (!res.ok) return null
|
|
137
|
+
const len = Number(res.headers.get('content-length') ?? 0)
|
|
138
|
+
if (len > REF_MAX_BYTES || budget.spent + len > REF_TOTAL_MAX_BYTES) return null
|
|
139
|
+
const bytes = Buffer.from(await res.arrayBuffer())
|
|
140
|
+
if (!bytes.length || bytes.length > REF_MAX_BYTES) return null
|
|
141
|
+
budget.spent += bytes.length
|
|
142
|
+
if (budget.spent > REF_TOTAL_MAX_BYTES) return null
|
|
143
|
+
const name = basename(new URL(href).pathname) || `ref-${index}`
|
|
144
|
+
const safe = name.replace(/[^\w.-]/g, '_').slice(-40)
|
|
145
|
+
const target = join(dir, `ref-${index}-${safe}`)
|
|
146
|
+
await writeFile(target, bytes)
|
|
147
|
+
return target
|
|
148
|
+
} catch {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 把浏览器传来的 base64 落成临时文件,交给 `museav projects assets add`,然后清掉。
|
|
155
|
+
*
|
|
156
|
+
* 为什么不直接把 base64 喂 CLI:CLI 的 add 收的是**文件路径**。
|
|
157
|
+
* 为什么不复用 DSH 的文件上传凭据(dsh-client-file-upload):那套 receipt 属于会话
|
|
158
|
+
* 工作区的文件区,素材要进的是 museav 的对象存储,走一遍是两次归属,写临时文件更直白。
|
|
159
|
+
*
|
|
160
|
+
* 文件名只取 basename + 扩展名:客户端传来的名字是用户可控的,拼路径等于把
|
|
161
|
+
* ../../.ssh 之类的东西交给文件系统。扩展名保留 —— CLI 按字节内容判类型,但后缀
|
|
162
|
+
* 仍是它的兜底线索。
|
|
163
|
+
*
|
|
164
|
+
* @returns {Promise<{ok: boolean, error?: string, url?: string, filename?: string}>}
|
|
165
|
+
*/
|
|
166
|
+
async function stageAndUpload ({ project, filename, data, name, tag }, cliOpts) {
|
|
167
|
+
const ext = extname(filename).slice(0, 12).replace(/[^.\w]/g, '')
|
|
168
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-museav-upload-'))
|
|
169
|
+
const target = join(dir, `upload${ext || '.bin'}`)
|
|
170
|
+
try {
|
|
171
|
+
const bytes = Buffer.from(data, 'base64')
|
|
172
|
+
if (!bytes.length) return { ok: false, error: '文件内容解不开(base64 损坏)' }
|
|
173
|
+
await writeFile(target, bytes)
|
|
174
|
+
|
|
175
|
+
const args = ['projects', 'assets', 'add', target, '--project', project]
|
|
176
|
+
if (name) args.push('--name', String(name).slice(0, 60))
|
|
177
|
+
if (tag) args.push('--tag', String(tag).slice(0, 30))
|
|
178
|
+
const res = await runMuseav(args, { ...cliOpts, timeoutMs: 120000 })
|
|
179
|
+
if (!res.ok) return { ok: false, error: res.error }
|
|
180
|
+
// CLI 的 stdout 是新素材的 cdn_url
|
|
181
|
+
const url = res.stdout.trim()
|
|
182
|
+
return { ok: true, url: url || null, filename: basename(filename) }
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { ok: false, error: String(error?.message ?? error) }
|
|
185
|
+
} finally {
|
|
186
|
+
// 临时文件必须清 —— 里面有用户的原图
|
|
187
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** GET 路由共用的入口检查:方法、同源。 */
|
|
192
|
+
function guard (request, response, allow) {
|
|
193
|
+
if (!allow.includes(request.method)) {
|
|
194
|
+
response.writeHead(405, { allow: allow.join(', ') })
|
|
195
|
+
response.end()
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
if (!isSameOrigin(request)) {
|
|
199
|
+
sendJson(response, 403, { ok: false, error: '拒绝跨源读取' })
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 从 URL 上取查询参数。 */
|
|
206
|
+
function queryOf (request) {
|
|
207
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
208
|
+
return url.searchParams
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function apply (ctx) {
|
|
212
|
+
// 每次请求现取:测试和临时换 CLI 路径都不用重载插件。
|
|
213
|
+
const cliOpts = () => ({ bin: cliBin(), timeoutMs: CLI_TIMEOUT_MS })
|
|
214
|
+
|
|
215
|
+
ctx.effect(() => ctx.webServer.register({
|
|
216
|
+
kind: 'exact',
|
|
217
|
+
path: `${PREFIX}/health`,
|
|
218
|
+
handler: async (request, response) => {
|
|
219
|
+
if (!guard(request, response, ['GET', 'HEAD'])) return
|
|
220
|
+
const res = await runMuseav(['--version'], cliOpts())
|
|
221
|
+
sendJson(response, 200, {
|
|
222
|
+
ok: res.ok,
|
|
223
|
+
cli: {
|
|
224
|
+
bin: cliBin(),
|
|
225
|
+
version: res.ok ? res.stdout.trim() : null,
|
|
226
|
+
error: res.error,
|
|
227
|
+
},
|
|
228
|
+
projectLimit: PROJECT_LIMIT,
|
|
229
|
+
})
|
|
230
|
+
},
|
|
231
|
+
}), 'dsh-museav-assets: health route')
|
|
232
|
+
|
|
233
|
+
ctx.effect(() => ctx.webServer.register({
|
|
234
|
+
kind: 'exact',
|
|
235
|
+
path: `${PREFIX}/projects`,
|
|
236
|
+
handler: async (request, response) => {
|
|
237
|
+
if (!guard(request, response, ['GET', 'HEAD', 'POST'])) return
|
|
238
|
+
|
|
239
|
+
// 新建工作区:先判参数,参数不合法才回 400。
|
|
240
|
+
if (request.method === 'POST') {
|
|
241
|
+
const body = await readJsonBody(request)
|
|
242
|
+
const name = String(body.name ?? '').trim()
|
|
243
|
+
if (!name) {
|
|
244
|
+
sendJson(response, 400, { ok: false, error: '缺少项目名' })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (name.length > 20) {
|
|
248
|
+
sendJson(response, 400, { ok: false, error: '项目名最多 20 字' })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const res = await runMuseav(['projects', 'create', '--name', name], cliOpts())
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
sendJson(response, 200, { ok: false, error: res.error })
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
sendJson(response, 200, { ok: true, id: res.stdout.trim() || null })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const res = await runMuseav(['projects'], cliOpts())
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
sendJson(response, 200, { ok: false, error: res.error, projects: [] })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const projects = mergeProjects(res.stdout, res.stderr)
|
|
266
|
+
sendJson(response, 200, {
|
|
267
|
+
ok: true,
|
|
268
|
+
projects,
|
|
269
|
+
used: projects.length,
|
|
270
|
+
limit: PROJECT_LIMIT,
|
|
271
|
+
// 到顶了就别再让用户点「新建」——服务端会拒,白跑一趟。
|
|
272
|
+
full: projects.length >= PROJECT_LIMIT,
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
}), 'dsh-museav-assets: projects route')
|
|
276
|
+
|
|
277
|
+
ctx.effect(() => ctx.webServer.register({
|
|
278
|
+
kind: 'exact',
|
|
279
|
+
path: `${PREFIX}/assets`,
|
|
280
|
+
handler: async (request, response) => {
|
|
281
|
+
if (!guard(request, response, ['GET', 'HEAD', 'POST', 'DELETE'])) return
|
|
282
|
+
|
|
283
|
+
// ── 传素材:浏览器给 base64,host 落临时文件再交给 CLI ──
|
|
284
|
+
if (request.method === 'POST') {
|
|
285
|
+
const body = await readJsonBody(request, MAX_UPLOAD_CHARS)
|
|
286
|
+
if (body.tooLarge) {
|
|
287
|
+
sendJson(response, 400, {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: `文件太大:载荷超过 ${(MAX_UPLOAD_CHARS / 1024 / 1024).toFixed(0)}MB。大视频请回终端跑 museav upload。`,
|
|
290
|
+
})
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
const project = String(body.project ?? '').trim()
|
|
294
|
+
const filename = String(body.filename ?? '').trim()
|
|
295
|
+
const data = String(body.data ?? '')
|
|
296
|
+
if (!project) { sendJson(response, 400, { ok: false, error: '缺少 project' }); return }
|
|
297
|
+
if (!filename) { sendJson(response, 400, { ok: false, error: '缺少文件名' }); return }
|
|
298
|
+
if (!data) { sendJson(response, 400, { ok: false, error: '没有文件内容' }); return }
|
|
299
|
+
// base64 比原文大 33%;CLI 侧图片上限 8MB,这里按 11MB 的载荷卡。
|
|
300
|
+
if (data.length > MAX_UPLOAD_CHARS) {
|
|
301
|
+
sendJson(response, 400, {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: `文件太大(载荷 ${(data.length / 1024 / 1024).toFixed(1)}MB,上限 ${(MAX_UPLOAD_CHARS / 1024 / 1024).toFixed(0)}MB)。大视频请用 museav upload 走 CLI。`,
|
|
304
|
+
})
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const uploaded = await stageAndUpload({ project, filename, data, name: body.name, tag: body.tag }, cliOpts())
|
|
308
|
+
sendJson(response, 200, uploaded)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── 删素材:CLI 是硬删(R2 对象 + 记录一起没)──
|
|
313
|
+
if (request.method === 'DELETE') {
|
|
314
|
+
const id = queryOf(request).get('id')?.trim()
|
|
315
|
+
if (!id) { sendJson(response, 400, { ok: false, error: '缺少素材 id' }); return }
|
|
316
|
+
const res = await runMuseav(['projects', 'assets', 'rm', id], cliOpts())
|
|
317
|
+
if (!res.ok) { sendJson(response, 200, { ok: false, error: res.error }); return }
|
|
318
|
+
sendJson(response, 200, { ok: true, id: res.stdout.trim() || id })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const project = queryOf(request).get('project')?.trim()
|
|
323
|
+
if (!project) {
|
|
324
|
+
sendJson(response, 400, { ok: false, error: '缺少 project(工作区 id 或名称)' })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
const res = await runMuseav(['projects', 'assets', '--project', project], cliOpts())
|
|
328
|
+
if (!res.ok) {
|
|
329
|
+
sendJson(response, 200, { ok: false, error: res.error, assets: [] })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
sendJson(response, 200, { ok: true, project, assets: mergeAssets(res.stdout, res.stderr) })
|
|
333
|
+
},
|
|
334
|
+
}), 'dsh-museav-assets: assets route')
|
|
335
|
+
|
|
336
|
+
ctx.effect(() => ctx.webServer.register({
|
|
337
|
+
kind: 'exact',
|
|
338
|
+
path: `${PREFIX}/gen`,
|
|
339
|
+
handler: async (request, response) => {
|
|
340
|
+
if (!guard(request, response, ['POST'])) return
|
|
341
|
+
const body = await readJsonBody(request)
|
|
342
|
+
const prompt = String(body.prompt ?? '').trim()
|
|
343
|
+
const templateId = String(body.templateId ?? '').trim()
|
|
344
|
+
const ratio = String(body.ratio ?? '').trim()
|
|
345
|
+
const project = String(body.project ?? '').trim()
|
|
346
|
+
const fields = body.fields && typeof body.fields === 'object' ? body.fields : null
|
|
347
|
+
const refs = Array.isArray(body.refs) ? body.refs.slice(0, 5) : []
|
|
348
|
+
|
|
349
|
+
// CLI 的三选一:prompt / skill / template。这里只做 prompt 与 template 两种 ——
|
|
350
|
+
// skill 走服务端展开,tab 里给一个技能下拉不如让聊天里用 mcp__museav__gen_background。
|
|
351
|
+
if (!prompt && !templateId) {
|
|
352
|
+
sendJson(response, 400, { ok: false, error: '给一句提示词,或者选一个模板' })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (ratio && !RATIOS.includes(ratio)) {
|
|
356
|
+
sendJson(response, 400, { ok: false, error: `比例只能是 ${RATIOS.join(' / ')}` })
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-museav-gen-'))
|
|
361
|
+
try {
|
|
362
|
+
const args = ['gen']
|
|
363
|
+
if (templateId) {
|
|
364
|
+
args.push('--template', templateId)
|
|
365
|
+
if (fields && Object.keys(fields).length) args.push('--fields', JSON.stringify(fields))
|
|
366
|
+
} else {
|
|
367
|
+
args.push('--prompt', prompt)
|
|
368
|
+
}
|
|
369
|
+
if (ratio) args.push('--ratio', ratio)
|
|
370
|
+
if (project) args.push('--project', project)
|
|
371
|
+
if (body.transparent === true) args.push('--transparent')
|
|
372
|
+
|
|
373
|
+
// --ref 收的是**文件路径**,而素材库给的是 CDN 直链 —— 这里下载一份到临时目录。
|
|
374
|
+
// 顺序即语义:提示词里的「图片1/图片2」对应 refs 的下标,所以必须按原序追加。
|
|
375
|
+
const budget = { spent: 0 }
|
|
376
|
+
for (const [i, url] of refs.entries()) {
|
|
377
|
+
const href = String(url ?? '').trim()
|
|
378
|
+
if (!href || !/^https?:\/\//i.test(href)) continue
|
|
379
|
+
const local = await downloadRef(href, dir, i, budget)
|
|
380
|
+
if (local) args.push('--ref', local)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// gen 同步跑完(CLI 自己轮询到终态),实测 20~60s,所以超时给到 5 分钟
|
|
384
|
+
const res = await runMuseav(args, { ...cliOpts(), timeoutMs: GEN_TIMEOUT_MS })
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
sendJson(response, 200, { ok: false, error: res.error })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
// CLI stdout 是成品直链,一行一条(批量时多行)
|
|
390
|
+
const urls = res.stdout.split('\n').map((u) => u.trim()).filter(Boolean)
|
|
391
|
+
sendJson(response, 200, { ok: true, urls, url: urls[0] ?? null })
|
|
392
|
+
} finally {
|
|
393
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
}), 'dsh-museav-assets: gen route')
|
|
397
|
+
|
|
398
|
+
ctx.effect(() => ctx.webServer.register({
|
|
399
|
+
kind: 'exact',
|
|
400
|
+
path: `${PREFIX}/templates`,
|
|
401
|
+
handler: async (request, response) => {
|
|
402
|
+
if (!guard(request, response, ['GET', 'HEAD', 'POST', 'DELETE'])) return
|
|
403
|
+
|
|
404
|
+
// ── 新建模板:只有 名字 + 提示词模板是必填,其余走 CLI 默认 ──
|
|
405
|
+
if (request.method === 'POST') {
|
|
406
|
+
const body = await readJsonBody(request)
|
|
407
|
+
const action = String(body.action ?? '').trim()
|
|
408
|
+
const id = String(body.id ?? '').trim()
|
|
409
|
+
|
|
410
|
+
if (action === 'share' || action === 'unshare') {
|
|
411
|
+
if (!id) { sendJson(response, 400, { ok: false, error: '缺少模板 id' }); return }
|
|
412
|
+
// publish / unshare:unshare 是收紧方向,服务端不校验门槛
|
|
413
|
+
const res = await runMuseav(['templates', action, id], cliOpts())
|
|
414
|
+
if (!res.ok) { sendJson(response, 200, { ok: false, error: res.error }); return }
|
|
415
|
+
sendJson(response, 200, { ok: true, id, shared: action === 'share' })
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (action === 'delete') {
|
|
420
|
+
if (!id) { sendJson(response, 400, { ok: false, error: '缺少模板 id' }); return }
|
|
421
|
+
const res = await runMuseav(['templates', 'delete', id], cliOpts())
|
|
422
|
+
if (!res.ok) { sendJson(response, 200, { ok: false, error: res.error }); return }
|
|
423
|
+
sendJson(response, 200, { ok: true, id })
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const name = String(body.name ?? '').trim()
|
|
428
|
+
const prompt = String(body.prompt ?? '').trim()
|
|
429
|
+
if (!name) { sendJson(response, 400, { ok: false, error: '给模板起个名字' }); return }
|
|
430
|
+
if (!prompt) { sendJson(response, 400, { ok: false, error: '提示词模板不能为空(占位符用 {key} 形式)' }); return }
|
|
431
|
+
const args = ['templates', 'create', '--name', name, '--prompt', prompt]
|
|
432
|
+
if (body.category) args.push('--category', String(body.category).trim())
|
|
433
|
+
if (body.ratio) args.push('--ratio', String(body.ratio).trim())
|
|
434
|
+
if (body.description) args.push('--description', String(body.description).trim().slice(0, 200))
|
|
435
|
+
const res = await runMuseav(args, cliOpts())
|
|
436
|
+
if (!res.ok) { sendJson(response, 200, { ok: false, error: res.error }); return }
|
|
437
|
+
sendJson(response, 200, { ok: true, id: res.stdout.trim().split('\n').pop()?.trim() || null })
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (request.method === 'DELETE') {
|
|
442
|
+
const id = queryOf(request).get('id')?.trim()
|
|
443
|
+
if (!id) { sendJson(response, 400, { ok: false, error: '缺少模板 id' }); return }
|
|
444
|
+
const res = await runMuseav(['templates', 'delete', id], cliOpts())
|
|
445
|
+
if (!res.ok) { sendJson(response, 200, { ok: false, error: res.error }); return }
|
|
446
|
+
sendJson(response, 200, { ok: true, id })
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const params = queryOf(request)
|
|
451
|
+
// 三档归属:mine=我建的 / tenant=本租户 / platform=平台共享。
|
|
452
|
+
// 不传就是全量(102 条),但 tab 默认只拉「我建的 + 本租户」—— 平台共享那 90
|
|
453
|
+
// 多条是别人的东西,混在资产台里只会把真正属于我的埋掉。
|
|
454
|
+
// 认不出来的 source 归一到 all:回显一个没真用上的值比不认更坑。
|
|
455
|
+
const raw = params.get('source')?.trim()
|
|
456
|
+
const source = raw === 'mine' || raw === 'tenant' || raw === 'platform' ? raw : 'all'
|
|
457
|
+
const args = ['templates']
|
|
458
|
+
if (source !== 'all') args.push(`--${source}`)
|
|
459
|
+
const category = params.get('category')?.trim()
|
|
460
|
+
if (category) args.push('--category', category)
|
|
461
|
+
const type = params.get('type')?.trim()
|
|
462
|
+
if (type === 'image' || type === 'article') args.push('--type', type)
|
|
463
|
+
|
|
464
|
+
const res = await runMuseav(args, cliOpts())
|
|
465
|
+
if (!res.ok) {
|
|
466
|
+
sendJson(response, 200, { ok: false, error: res.error, templates: [] })
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
sendJson(response, 200, { ok: true, source, templates: mergeTemplates(res.stdout, res.stderr) })
|
|
470
|
+
},
|
|
471
|
+
}), 'dsh-museav-assets: templates route')
|
|
472
|
+
|
|
473
|
+
ctx.effect(() => ctx.webServer.register({
|
|
474
|
+
kind: 'exact',
|
|
475
|
+
path: `${PREFIX}/jobs`,
|
|
476
|
+
handler: async (request, response) => {
|
|
477
|
+
if (!guard(request, response, ['GET', 'HEAD'])) return
|
|
478
|
+
const params = queryOf(request)
|
|
479
|
+
const raw = Number(params.get('limit') ?? 20)
|
|
480
|
+
const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.floor(raw), MAX_JOBS) : 20
|
|
481
|
+
const project = params.get('project')?.trim()
|
|
482
|
+
const args = ['jobs', '--limit', String(limit)]
|
|
483
|
+
if (project) args.push('--project', project)
|
|
484
|
+
const res = await runMuseav(args, cliOpts())
|
|
485
|
+
if (!res.ok) {
|
|
486
|
+
sendJson(response, 200, { ok: false, error: res.error, jobs: [] })
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
sendJson(response, 200, { ok: true, jobs: summarizeJobs(parseJobArray(res.stdout)) })
|
|
490
|
+
},
|
|
491
|
+
}), 'dsh-museav-assets: jobs route')
|
|
492
|
+
}
|