museav-cli 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +19 -0
- package/.github/workflows/publish.yml +79 -0
- package/AGENTS.md +90 -0
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/SECURITY.md +11 -0
- package/dist/client.d.ts +387 -0
- package/dist/client.js +372 -0
- package/dist/commands/assets.d.ts +14 -0
- package/dist/commands/assets.js +30 -0
- package/dist/commands/balance.d.ts +3 -0
- package/dist/commands/balance.js +11 -0
- package/dist/commands/bind-feishu.d.ts +3 -0
- package/dist/commands/bind-feishu.js +63 -0
- package/dist/commands/gen.d.ts +16 -0
- package/dist/commands/gen.js +88 -0
- package/dist/commands/image-to-template.d.ts +24 -0
- package/dist/commands/image-to-template.js +111 -0
- package/dist/commands/jobs.d.ts +11 -0
- package/dist/commands/jobs.js +27 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +67 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +9 -0
- package/dist/commands/products.d.ts +9 -0
- package/dist/commands/products.js +16 -0
- package/dist/commands/reverse.d.ts +3 -0
- package/dist/commands/reverse.js +16 -0
- package/dist/commands/skills.d.ts +5 -0
- package/dist/commands/skills.js +22 -0
- package/dist/commands/templates.d.ts +28 -0
- package/dist/commands/templates.js +79 -0
- package/dist/commands/upload.d.ts +9 -0
- package/dist/commands/upload.js +8 -0
- package/dist/commands/video-templates.d.ts +21 -0
- package/dist/commands/video-templates.js +71 -0
- package/dist/commands/welcome.d.ts +6 -0
- package/dist/commands/welcome.js +93 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.js +14 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +93 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +292 -0
- package/dist/tenant-client.d.ts +31 -0
- package/dist/tenant-client.js +84 -0
- package/package.json +55 -0
- package/src/client.ts +636 -0
- package/src/commands/assets.ts +47 -0
- package/src/commands/balance.ts +12 -0
- package/src/commands/bind-feishu.ts +77 -0
- package/src/commands/gen.ts +111 -0
- package/src/commands/image-to-template.ts +150 -0
- package/src/commands/jobs.ts +37 -0
- package/src/commands/login.ts +80 -0
- package/src/commands/models.ts +12 -0
- package/src/commands/products.ts +38 -0
- package/src/commands/reverse.ts +21 -0
- package/src/commands/skills.ts +29 -0
- package/src/commands/templates.ts +98 -0
- package/src/commands/upload.ts +18 -0
- package/src/commands/video-templates.ts +89 -0
- package/src/commands/welcome.ts +108 -0
- package/src/commands/whoami.ts +19 -0
- package/src/config.ts +114 -0
- package/src/index.ts +318 -0
- package/src/tenant-client.ts +90 -0
- package/src/types/update-notifier.d.ts +31 -0
- package/tsconfig.json +19 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StudioClient —— studio 中台 API 客户端
|
|
3
|
+
*
|
|
4
|
+
* 所有出图/逆向/上传能力都封装在这里。CLI commands 和编程调用共用这一个 class。
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* const studio = new StudioClient({ baseUrl, apiKey })
|
|
8
|
+
* const job = await studio.generateAndWait({ prompt: '一只猫' })
|
|
9
|
+
* console.log(job.cdn_url)
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
/**
|
|
14
|
+
* 客户端自报身份 —— 中台靠它把 gen_jobs.channel 记成 'cli',报错告警也靠它定位调用方。
|
|
15
|
+
*
|
|
16
|
+
* 必要性:同一把租户 apiKey 既可能来自业务方后端,也可能来自有人在终端跑本 CLI;
|
|
17
|
+
* 同一个个人 JWT 既可能来自网页也可能来自这里。只看凭证分不出渠道,必须自报。
|
|
18
|
+
* 版本号从 package.json 读,随发版自动跟随;读不到就退化成不带版本(仍能识别为 cli)。
|
|
19
|
+
*/
|
|
20
|
+
const CLIENT_ID = (() => {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
23
|
+
return `museav-cli/${pkg.version}`;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return 'museav-cli';
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
/**
|
|
30
|
+
* 自报身份的头名。**过渡期两个头一起发,值都是 CLIENT_ID。**
|
|
31
|
+
*
|
|
32
|
+
* X-Museav-Client —— 新头,跟产品名一致,长期只留这一个。
|
|
33
|
+
* X-Studio-Client —— 旧头,头名里还带着已经废弃的 "Studio" 叫法,纯为兼容保留。
|
|
34
|
+
*
|
|
35
|
+
* 为什么两个都发:中台的渠道识别(detectChannel)与报错上下文(_middleware 的
|
|
36
|
+
* requestContext)现在读的是旧头,且对值做前缀匹配。CLI 单方面改名,中台就会把 CLI
|
|
37
|
+
* 的调用记成 browser/api,渠道统计当场失真。过渡期中台两个头都读,等所有客户端都升上来
|
|
38
|
+
* 之后,中台先停读旧头,这里再把 X-Studio-Client 删掉——那时删是纯清理,不影响任何人。
|
|
39
|
+
*/
|
|
40
|
+
const CLIENT_HEADERS = {
|
|
41
|
+
'X-Museav-Client': CLIENT_ID,
|
|
42
|
+
'X-Studio-Client': CLIENT_ID,
|
|
43
|
+
// 中台还有一条 UA 兜底匹配(有人只改 UA 不带自报头时也能认出是 CLI)。
|
|
44
|
+
// Node 默认 UA 是 "node",什么信息都没有,这里显式带上同一个身份串。
|
|
45
|
+
'User-Agent': CLIENT_ID,
|
|
46
|
+
};
|
|
47
|
+
export class StudioClient {
|
|
48
|
+
baseUrl;
|
|
49
|
+
authHeader;
|
|
50
|
+
constructor(opts) {
|
|
51
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
52
|
+
// 个人用户 JWT 走 Bearer;租户 apikey 走 X-API-Key
|
|
53
|
+
if (opts.token) {
|
|
54
|
+
this.authHeader = { Authorization: `Bearer ${opts.token}` };
|
|
55
|
+
}
|
|
56
|
+
else if (opts.apiKey) {
|
|
57
|
+
this.authHeader = { 'X-API-Key': opts.apiKey };
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
throw new Error('需要 token 或 apiKey');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
headers(extra = {}) {
|
|
64
|
+
return { ...CLIENT_HEADERS, ...this.authHeader, ...extra };
|
|
65
|
+
}
|
|
66
|
+
async request(path, init = {}) {
|
|
67
|
+
const url = `${this.baseUrl}/api/${path}`;
|
|
68
|
+
const resp = await fetch(url, {
|
|
69
|
+
...init,
|
|
70
|
+
headers: { ...this.headers(), ...init.headers },
|
|
71
|
+
});
|
|
72
|
+
const text = await resp.text();
|
|
73
|
+
let body;
|
|
74
|
+
try {
|
|
75
|
+
body = JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
body = { raw: text };
|
|
79
|
+
}
|
|
80
|
+
if (!resp.ok) {
|
|
81
|
+
const msg = body.error || body.raw || `HTTP ${resp.status}`;
|
|
82
|
+
throw new Error(`中台 API /api/${path} 失败: ${msg}`);
|
|
83
|
+
}
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
86
|
+
/** 可用技能清单:私有 + 所属租户专属模板 + 公共库,服务端已按调用者权限过滤 */
|
|
87
|
+
async skills() {
|
|
88
|
+
const r = await this.request('skills');
|
|
89
|
+
return Array.isArray(r) ? r : [];
|
|
90
|
+
}
|
|
91
|
+
/** 可用图片/文字模板清单:自己租户建的 + 平台共享的,服务端已按调用者权限过滤。
|
|
92
|
+
* type=image|article 二选一(不传则图片+文字都返回,跟中台默认一致)。 */
|
|
93
|
+
async templates(type) {
|
|
94
|
+
const qs = type ? `?type=${type}` : '';
|
|
95
|
+
const r = await this.request(`templates${qs}`);
|
|
96
|
+
return Array.isArray(r) ? r : [];
|
|
97
|
+
}
|
|
98
|
+
/** 视频模板清单(POST /api/videos 用 template_id)。结构同图片模板的 generation_configs 形态 */
|
|
99
|
+
async videoTemplates() {
|
|
100
|
+
const r = await this.request('video-templates');
|
|
101
|
+
return Array.isArray(r) ? r : [];
|
|
102
|
+
}
|
|
103
|
+
/** 新建图片模板。归属(是否关联租户)由服务端根据鉴权身份决定,见 CreateTemplateInput 注释 */
|
|
104
|
+
async createTemplate(input) {
|
|
105
|
+
const r = await this.request('templates', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify(input),
|
|
109
|
+
});
|
|
110
|
+
return r.row;
|
|
111
|
+
}
|
|
112
|
+
/** 新建视频模板。归属同图片模板:租户 apiKey 自动归租户,平台管理员归平台共享 */
|
|
113
|
+
async createVideoTemplate(input) {
|
|
114
|
+
const r = await this.request('video-templates', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify(input),
|
|
118
|
+
});
|
|
119
|
+
return r.row;
|
|
120
|
+
}
|
|
121
|
+
/** 提交出图任务,立即返回 jobId */
|
|
122
|
+
async generate(opts) {
|
|
123
|
+
// prompt / skill_slug / template_id 三选一:都传时服务端按 prompt > template_id > skill_slug
|
|
124
|
+
// 的优先级取(见服务端 generate.js),这里不替服务端做决定,只保证不凭空造字段
|
|
125
|
+
const body = {};
|
|
126
|
+
if (opts.prompt)
|
|
127
|
+
body.prompt = opts.prompt;
|
|
128
|
+
if (opts.skill_slug)
|
|
129
|
+
body.skill_slug = opts.skill_slug;
|
|
130
|
+
if (opts.template_id)
|
|
131
|
+
body.template_id = opts.template_id;
|
|
132
|
+
// input 是服务端黑盒展开的入参:skill_slug 配一句话描述,template_id 配占位符取值对象,
|
|
133
|
+
// 两者都写进同一个 input 字段(服务端按类型分支处理),CLI 侧分开成两个选项只是好懂
|
|
134
|
+
if (opts.input)
|
|
135
|
+
body.input = opts.input;
|
|
136
|
+
else if (opts.template_fields)
|
|
137
|
+
body.input = opts.template_fields;
|
|
138
|
+
if (opts.ratio)
|
|
139
|
+
body.ratio = opts.ratio;
|
|
140
|
+
if (opts.model)
|
|
141
|
+
body.model = opts.model;
|
|
142
|
+
if (opts.reference_image)
|
|
143
|
+
body.reference_image = opts.reference_image;
|
|
144
|
+
if (opts.quality)
|
|
145
|
+
body.quality = opts.quality;
|
|
146
|
+
const r = await this.request('generate', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
return { jobId: r.jobId, trace_id: r.trace_id };
|
|
152
|
+
}
|
|
153
|
+
/** 查单个任务状态 */
|
|
154
|
+
async getJob(id) {
|
|
155
|
+
const r = await this.request(`jobs?id=${encodeURIComponent(id)}`);
|
|
156
|
+
return r;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 列出当前身份名下的出图工作流(不传 id,走同一个 jobs 端点的集合语义)。
|
|
160
|
+
* 范围由鉴权凭证决定:个人 token 只看得到自己出的图;租户 apiKey 看得到自己业务下的全部记录。
|
|
161
|
+
*
|
|
162
|
+
* 服务端 GET /api/jobs 目前只认 id / all 两个 query 参数,固定按 created_at
|
|
163
|
+
* 倒序返回最近 50 条,不支持 limit/status 这类过滤——传了也会被忽略。
|
|
164
|
+
* 所以 limit/status 在这里做客户端过滤:先拿到这最多 50 条,再本地按 status
|
|
165
|
+
* 筛、按 limit 截断。这意味着 --limit 只能在这 50 条以内选,选不到更早的历史。
|
|
166
|
+
*/
|
|
167
|
+
async listJobs(opts = {}) {
|
|
168
|
+
const r = await this.request('jobs');
|
|
169
|
+
let list = Array.isArray(r) ? r : r.jobs || [];
|
|
170
|
+
if (opts.status)
|
|
171
|
+
list = list.filter((j) => j.status === opts.status);
|
|
172
|
+
if (opts.limit)
|
|
173
|
+
list = list.slice(0, opts.limit);
|
|
174
|
+
return list;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
/** 提交出图 + 自动轮询直到完成/失败。
|
|
178
|
+
* onProgress 可选,每次轮询回调一次(用于 CLI 显示进度)。
|
|
179
|
+
*/
|
|
180
|
+
async generateAndWait(opts, onProgress, intervalMs = 3000, maxAttempts = 100) {
|
|
181
|
+
const { jobId } = await this.generate(opts);
|
|
182
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
183
|
+
await sleep(intervalMs);
|
|
184
|
+
const job = await this.getJob(jobId);
|
|
185
|
+
onProgress?.(job.status);
|
|
186
|
+
if (job.status === 'done')
|
|
187
|
+
return job;
|
|
188
|
+
if (job.status === 'failed') {
|
|
189
|
+
throw new Error(`出图失败: ${job.error || '未知原因'}(jobId: ${jobId})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`出图超时(${(maxAttempts * intervalMs) / 1000}s 未返回,jobId: ${jobId})`);
|
|
193
|
+
}
|
|
194
|
+
/** 提交视频任务(POST /api/videos)——video 走独立链路,不走图片 queue */
|
|
195
|
+
async generateVideo(opts) {
|
|
196
|
+
const body = {};
|
|
197
|
+
if (opts.prompt)
|
|
198
|
+
body.prompt = opts.prompt;
|
|
199
|
+
if (opts.model)
|
|
200
|
+
body.model = opts.model;
|
|
201
|
+
if (opts.ratio)
|
|
202
|
+
body.ratio = opts.ratio;
|
|
203
|
+
if (opts.duration != null)
|
|
204
|
+
body.duration = opts.duration;
|
|
205
|
+
if (opts.image_url)
|
|
206
|
+
body.image_url = opts.image_url;
|
|
207
|
+
if (opts.template_id)
|
|
208
|
+
body.template_id = opts.template_id;
|
|
209
|
+
if (opts.input)
|
|
210
|
+
body.input = opts.input;
|
|
211
|
+
if (opts.callback_url)
|
|
212
|
+
body.callback_url = opts.callback_url;
|
|
213
|
+
const r = await this.request('videos', {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: { 'Content-Type': 'application/json' },
|
|
216
|
+
body: JSON.stringify(body),
|
|
217
|
+
});
|
|
218
|
+
return { jobId: r.job_id || r.id, upstreamTaskId: r.id };
|
|
219
|
+
}
|
|
220
|
+
/** 轮询视频任务直到完成/失败。返回 { cdn_url, status } */
|
|
221
|
+
async waitVideo(jobId, onProgress, intervalMs = 5000, maxAttempts = 120) {
|
|
222
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
223
|
+
await sleep(intervalMs);
|
|
224
|
+
const r = await this.request(`videos?id=${encodeURIComponent(jobId)}`);
|
|
225
|
+
onProgress?.(r.status || 'processing');
|
|
226
|
+
if (r.status === 'completed')
|
|
227
|
+
return { cdn_url: r.cdn_url || null, status: 'completed' };
|
|
228
|
+
if (r.status === 'failed')
|
|
229
|
+
return { cdn_url: null, status: 'failed', error: r.error || '未知原因' };
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`视频生成超时(${(maxAttempts * intervalMs) / 1000}s 未完成,jobId: ${jobId})`);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 图片逆向(**纯读图**):传文件路径或图片 URL,拿回 SCULPT 六要素与出图 prompt。
|
|
235
|
+
*
|
|
236
|
+
* ⚠️ 这里只发图片,一个别的字段都不发。2026-08-16 中台把「读图」和「把图做成模板」
|
|
237
|
+
* 拆成两个接口后,/api/reverse 见到 variablize / variables / variable_labels /
|
|
238
|
+
* create_template / template / async 任何一个都会直接 400(不是静默忽略)。
|
|
239
|
+
* 要做模板走 imageToTemplate()。
|
|
240
|
+
*/
|
|
241
|
+
async reverse(input) {
|
|
242
|
+
if (input.file) {
|
|
243
|
+
return this.request('reverse', { method: 'POST', body: fileForm(input.file) });
|
|
244
|
+
}
|
|
245
|
+
return this.request('reverse', {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: { 'Content-Type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({ image_url: input.imageUrl }),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 上传素材(POST /api/upload-ref),返回公网直链。
|
|
253
|
+
*
|
|
254
|
+
* 图片 / 音频 / 视频都收:中台按**字节魔数**判真实类型(不信客户端声明的 MIME),
|
|
255
|
+
* 分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。认不出类型直接 400。
|
|
256
|
+
* 同一归属每小时 120 个的防滥用刹车在服务端,超了返回 429。
|
|
257
|
+
*/
|
|
258
|
+
async uploadRef(filePath) {
|
|
259
|
+
const r = await this.request('upload-ref', { method: 'POST', body: fileForm(filePath) });
|
|
260
|
+
return { url: r.url, media_type: r.media_type, mime: r.mime };
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 图片转模板(POST /api/image-to-template):一张图 → 一个可复用的图片模板。
|
|
264
|
+
*
|
|
265
|
+
* 同步还是异步**由入参决定,不要猜**(中台契约 §7.6):
|
|
266
|
+
* createTemplate=true 或 async=true → 返回 { jobId }(这里是 ImageToTemplateJob)
|
|
267
|
+
* 两者都不给 → 直接返回结果本体(ImageToTemplateResult)
|
|
268
|
+
* 调用方用返回值里有没有 jobId 区分,见 isAsyncJob()。
|
|
269
|
+
*
|
|
270
|
+
* 这个接口没有 variablize 开关:调它本身就是「我要模板」这个意图。
|
|
271
|
+
*/
|
|
272
|
+
async imageToTemplate(input) {
|
|
273
|
+
const { file, imageUrl, variables, variableLabels, createTemplate, template, async: forceAsync } = input;
|
|
274
|
+
if (file) {
|
|
275
|
+
// multipart 分支:中台 formOptions() 对这几个键做 JSON.parse(variables 还支持逗号分隔),
|
|
276
|
+
// 所以对象/数组要自己序列化成字符串,不能直接塞进 FormData。
|
|
277
|
+
const fd = fileForm(file);
|
|
278
|
+
if (variables?.length)
|
|
279
|
+
fd.append('variables', JSON.stringify(variables));
|
|
280
|
+
if (variableLabels)
|
|
281
|
+
fd.append('variable_labels', JSON.stringify(variableLabels));
|
|
282
|
+
if (createTemplate)
|
|
283
|
+
fd.append('create_template', 'true');
|
|
284
|
+
if (template)
|
|
285
|
+
fd.append('template', JSON.stringify(template));
|
|
286
|
+
if (forceAsync)
|
|
287
|
+
fd.append('async', 'true');
|
|
288
|
+
return this.request('image-to-template', { method: 'POST', body: fd });
|
|
289
|
+
}
|
|
290
|
+
const body = { image_url: imageUrl };
|
|
291
|
+
if (variables?.length)
|
|
292
|
+
body.variables = variables;
|
|
293
|
+
if (variableLabels)
|
|
294
|
+
body.variable_labels = variableLabels;
|
|
295
|
+
if (createTemplate)
|
|
296
|
+
body.create_template = true;
|
|
297
|
+
if (template)
|
|
298
|
+
body.template = template;
|
|
299
|
+
if (forceAsync)
|
|
300
|
+
body.async = true;
|
|
301
|
+
return this.request('image-to-template', {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/** 取 image-to-template 异步任务的**结果本体**(进度看 getJob 的 steps,结果在这里) */
|
|
308
|
+
async getImageToTemplateResult(jobId) {
|
|
309
|
+
return this.request(`image-to-template?job_id=${encodeURIComponent(jobId)}`);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 轮询 image-to-template 异步任务直到终态,返回结果本体。
|
|
313
|
+
*
|
|
314
|
+
* 进度和结果是两条通道(中台刻意分开的):阶段在 GET /api/jobs?id= 的 steps 里,
|
|
315
|
+
* 结果在 GET /api/image-to-template?job_id= 里。所以这里每轮先拉 job 看阶段
|
|
316
|
+
* (回调给 CLI 打「正在解析图片…」),到终态再去取结果。
|
|
317
|
+
*/
|
|
318
|
+
async waitImageToTemplate(jobId, onStep, intervalMs = 3000, maxAttempts = 100) {
|
|
319
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
320
|
+
await sleep(intervalMs);
|
|
321
|
+
const job = await this.getJob(jobId);
|
|
322
|
+
if (job.steps?.length)
|
|
323
|
+
onStep?.(job.steps);
|
|
324
|
+
if (job.status !== 'done' && job.status !== 'failed')
|
|
325
|
+
continue;
|
|
326
|
+
const out = await this.getImageToTemplateResult(jobId);
|
|
327
|
+
if (job.status === 'failed' || out.status === 'failed') {
|
|
328
|
+
throw new Error(`图生模板失败: ${out.error || job.error || '未知原因'}(jobId: ${jobId})`);
|
|
329
|
+
}
|
|
330
|
+
if (!out.result) {
|
|
331
|
+
// 任务是 done 但结果取不到——中台把原因写在 error 里(例如结果列缺失还没跑迁移)。
|
|
332
|
+
// 不再继续轮询:状态已经是终态,等下去也不会变。
|
|
333
|
+
throw new Error(`任务已完成但取不到结果:${out.error || '中台未返回 result'}(jobId: ${jobId})`);
|
|
334
|
+
}
|
|
335
|
+
return out.result;
|
|
336
|
+
}
|
|
337
|
+
throw new Error(`图生模板超时(${(maxAttempts * intervalMs) / 1000}s 未完成,jobId: ${jobId})`);
|
|
338
|
+
}
|
|
339
|
+
/** 查当前登录账户信息(含租户归属品牌)。仅个人 token 鉴权可用,apiKey 调用会 401。 */
|
|
340
|
+
async me() {
|
|
341
|
+
return this.request('me');
|
|
342
|
+
}
|
|
343
|
+
/** 查可用模型列表 */
|
|
344
|
+
async models() {
|
|
345
|
+
return this.request('available-models');
|
|
346
|
+
}
|
|
347
|
+
/** 查上游余额 */
|
|
348
|
+
async balance() {
|
|
349
|
+
return this.request('balance');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function sleep(ms) {
|
|
353
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 把本地文件包成 multipart 的 file 字段。
|
|
357
|
+
*
|
|
358
|
+
* 带上原文件名:中台判类型靠字节魔数不靠这个,但文件名会进日志/对象存储的排查线索,
|
|
359
|
+
* 匿名的 "blob" 出问题时谁也认不出是哪张图。故意不设 MIME——声明的 MIME 中台本来就不信。
|
|
360
|
+
*/
|
|
361
|
+
function fileForm(filePath) {
|
|
362
|
+
const fd = new FormData();
|
|
363
|
+
fd.append('file', new Blob([readFileSync(filePath)]), basename(filePath));
|
|
364
|
+
return fd;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 区分 imageToTemplate() 拿到的是异步回执还是结果本体。
|
|
368
|
+
* 判据是有没有 jobId —— 跟中台契约一致,不靠 status 字符串猜。
|
|
369
|
+
*/
|
|
370
|
+
export function isAsyncJob(r) {
|
|
371
|
+
return typeof r.jobId === 'string';
|
|
372
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav assets —— 查所属租户自己的素材/资产库。
|
|
3
|
+
*
|
|
4
|
+
* 数据不在 Studio 中台,在租户自己的后台(见 ../tenant-client.ts 顶部注释)。
|
|
5
|
+
*
|
|
6
|
+
* 【响应形状故意没有统一】两个已接入租户的"素材"根本不是一回事:
|
|
7
|
+
* - 好易美(hym):明星素材库 + 贴图库两张互不关联的表 → 返回
|
|
8
|
+
* { celebrity_materials: [...], stickers: [...] }
|
|
9
|
+
* - mzmeso:品牌素材库 brand_assets 一张表 → 返回扁平数组 [...]
|
|
10
|
+
* 调查过程中发现二者字段/数量级差异很大,硬凑成一种格式只会两边都不像,所以这里
|
|
11
|
+
* 按响应形状分别展示,而不是假装它们是同一种资源。
|
|
12
|
+
*/
|
|
13
|
+
import type { TenantClient } from '../tenant-client.js';
|
|
14
|
+
export declare function assets(client: TenantClient): Promise<void>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export async function assets(client) {
|
|
2
|
+
const data = await client.get('tenant-assets');
|
|
3
|
+
if (Array.isArray(data)) {
|
|
4
|
+
if (!data.length) {
|
|
5
|
+
process.stderr.write('该租户暂无素材数据\n');
|
|
6
|
+
console.log('[]');
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
process.stderr.write(`素材(${data.length} 个):\n`);
|
|
10
|
+
for (const a of data) {
|
|
11
|
+
process.stderr.write(` ${String(a.id).padEnd(38)} ${String(a.category || '').padEnd(14)} ${String(a.name || '')}\n`);
|
|
12
|
+
}
|
|
13
|
+
console.log(JSON.stringify(data));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (data && typeof data === 'object') {
|
|
17
|
+
const obj = data;
|
|
18
|
+
for (const [group, rows] of Object.entries(obj)) {
|
|
19
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
20
|
+
process.stderr.write(`${group}(${list.length} 个):\n`);
|
|
21
|
+
for (const r of list) {
|
|
22
|
+
process.stderr.write(` ${String(r.id).padEnd(38)} ${String(r.name || r.artist || '')}\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
console.log(JSON.stringify(obj));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
process.stderr.write('返回数据格式未知,原样输出到 stdout\n');
|
|
29
|
+
console.log(JSON.stringify(data));
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function balance(client) {
|
|
2
|
+
const r = await client.balance();
|
|
3
|
+
// 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)
|
|
4
|
+
process.stderr.write(`余额: ¥${r.balance_usd?.toFixed(2) ?? '?'}`);
|
|
5
|
+
if (r.markup_pct)
|
|
6
|
+
process.stderr.write(` 加价率: ${(r.markup_pct * 100).toFixed(0)}%`);
|
|
7
|
+
if (r.checked_at)
|
|
8
|
+
process.stderr.write(` 校验时间: ${r.checked_at.slice(0, 19).replace('T', ' ')}`);
|
|
9
|
+
process.stderr.write('\n');
|
|
10
|
+
console.log(JSON.stringify(r));
|
|
11
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav bind-feishu —— CLI 绑定飞书(设备码模式,2026-08-15)
|
|
3
|
+
*
|
|
4
|
+
* 流程:CLI 调 /feishu-bind/cli-start(需个人 login token)→ 终端显示验证码 + 授权链接
|
|
5
|
+
* → 用户在浏览器打开链接(已登录后台)授权飞书 → CLI 轮询 /feishu-bind/cli-poll
|
|
6
|
+
* → 绑定完成,提示已关联的平台账户数。
|
|
7
|
+
*
|
|
8
|
+
* 一个飞书 open_id 可绑定多个平台账户(owner 的 gmail/163 双 superadmin):
|
|
9
|
+
* 绑定后 agent 在飞书里能认出你(resolveSpeaker superadmin 优先)。
|
|
10
|
+
* 只支持个人 login 账户;租户 apiKey 身份无法绑定个人飞书。
|
|
11
|
+
*/
|
|
12
|
+
import { loadConfig, DEFAULT_BASE_URL } from '../config.js';
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
export async function bindFeishu(opts) {
|
|
15
|
+
const baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
16
|
+
const cfg = loadConfig();
|
|
17
|
+
if (!cfg.token) {
|
|
18
|
+
throw new Error('绑定飞书需要个人账户登录:先执行 museav login(租户 apiKey 无法绑定个人飞书)');
|
|
19
|
+
}
|
|
20
|
+
process.stderr.write('正在发起飞书绑定...\n');
|
|
21
|
+
const startResp = await fetch(`${baseUrl}/api/feishu-bind/cli-start`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
24
|
+
});
|
|
25
|
+
if (!startResp.ok) {
|
|
26
|
+
const d = await startResp.json().catch(() => ({}));
|
|
27
|
+
throw new Error(`发起绑定失败: HTTP ${startResp.status} ${d?.error || ''}`);
|
|
28
|
+
}
|
|
29
|
+
const start = await startResp.json();
|
|
30
|
+
process.stderr.write('\n');
|
|
31
|
+
process.stderr.write('┌──────────────────────────────────────────────────┐\n');
|
|
32
|
+
process.stderr.write('│ │\n');
|
|
33
|
+
process.stderr.write(`│ 验证码: ${start.user_code.padEnd(34)} │\n`);
|
|
34
|
+
process.stderr.write('│ │\n');
|
|
35
|
+
process.stderr.write('│ 请在浏览器打开以下地址,授权绑定你的飞书: │\n');
|
|
36
|
+
process.stderr.write('│ │\n');
|
|
37
|
+
process.stderr.write('│ ' + start.authorize_url + '\n');
|
|
38
|
+
process.stderr.write('│ │\n');
|
|
39
|
+
process.stderr.write('└──────────────────────────────────────────────────┘\n');
|
|
40
|
+
process.stderr.write('\n等待授权完成...(可随时 Ctrl+C 取消)\n');
|
|
41
|
+
const interval = (start.interval || 3) * 1000;
|
|
42
|
+
const maxAttempts = Math.floor(((start.expires_in || 600) * 1000) / interval);
|
|
43
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
44
|
+
await sleep(interval);
|
|
45
|
+
const pollResp = await fetch(`${baseUrl}/api/feishu-bind/cli-poll`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
49
|
+
});
|
|
50
|
+
if (!pollResp.ok)
|
|
51
|
+
continue;
|
|
52
|
+
const poll = await pollResp.json();
|
|
53
|
+
if (poll.status === 'approved') {
|
|
54
|
+
const n = poll.linked_count || 1;
|
|
55
|
+
process.stderr.write(`\n✅ 飞书绑定成功!已关联 ${n} 个平台账户。agent 在飞书里能认出你了。\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (poll.status === 'expired') {
|
|
59
|
+
throw new Error('绑定请求已过期,请重新执行 museav bind-feishu');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw new Error('绑定超时,请重新执行 museav bind-feishu');
|
|
63
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** museav gen —— 出图 / 出视频(核心命令) */
|
|
2
|
+
import type { StudioClient } from '../client.js';
|
|
3
|
+
export declare function gen(client: StudioClient, opts: {
|
|
4
|
+
prompt?: string;
|
|
5
|
+
skill?: string;
|
|
6
|
+
input?: string;
|
|
7
|
+
template?: string;
|
|
8
|
+
fields?: string;
|
|
9
|
+
ratio?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
quality?: string;
|
|
12
|
+
ref?: string;
|
|
13
|
+
video?: boolean;
|
|
14
|
+
duration?: number;
|
|
15
|
+
image?: string;
|
|
16
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export async function gen(client, opts) {
|
|
2
|
+
// prompt / skill / template 三选一。commander 不好表达互斥,在这里校验,报错要说清怎么改
|
|
3
|
+
const picked = [opts.prompt, opts.skill, opts.template].filter(Boolean).length;
|
|
4
|
+
if (picked === 0) {
|
|
5
|
+
throw new Error('需要 --prompt "完整提示词" 或 --skill <技能名>(museav skills 查)或 --template <模板id>(museav templates 查)');
|
|
6
|
+
}
|
|
7
|
+
if (picked > 1) {
|
|
8
|
+
throw new Error('--prompt / --skill / --template 只能给一个:分别对应自己写提示词、用中台技能展开、用图片模板展开');
|
|
9
|
+
}
|
|
10
|
+
if (opts.input && !opts.skill) {
|
|
11
|
+
throw new Error('--input 是配合 --skill 的业务描述;只写提示词请用 --prompt');
|
|
12
|
+
}
|
|
13
|
+
if (opts.fields && !opts.template) {
|
|
14
|
+
throw new Error('--fields 是配合 --template 的占位符取值;用技能请用 --input');
|
|
15
|
+
}
|
|
16
|
+
if (opts.video && opts.skill) {
|
|
17
|
+
throw new Error('--video 暂不支持配合 --skill(视频模板走 --template 或直接 --prompt)');
|
|
18
|
+
}
|
|
19
|
+
let templateFields;
|
|
20
|
+
if (opts.fields) {
|
|
21
|
+
try {
|
|
22
|
+
templateFields = JSON.parse(opts.fields);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`--fields 必须是合法 JSON 对象,如 '{"artist":"王嘉尔","city":"南京"}',收到: ${opts.fields}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// 可选:先上传垫图(图片出图 --ref / 视频图生视频 --image 都走这里)
|
|
29
|
+
let referenceImage;
|
|
30
|
+
const refPath = opts.ref || opts.image;
|
|
31
|
+
if (refPath) {
|
|
32
|
+
process.stderr.write(`上传垫图 ${refPath} ...\n`);
|
|
33
|
+
const up = await client.uploadRef(refPath);
|
|
34
|
+
referenceImage = up.url;
|
|
35
|
+
process.stderr.write(`垫图就绪: ${referenceImage}\n`);
|
|
36
|
+
}
|
|
37
|
+
// ── 视频模式:走 /api/videos 独立链路 ──
|
|
38
|
+
if (opts.video) {
|
|
39
|
+
if (opts.quality)
|
|
40
|
+
throw new Error('--quality 仅图片出图支持');
|
|
41
|
+
if (opts.skill)
|
|
42
|
+
throw new Error('--video 暂不支持配合 --skill(视频模板走 --template,清单用 museav video-templates 查)');
|
|
43
|
+
process.stderr.write(`提交视频: ${opts.template ? `模板 ${opts.template}` : opts.prompt?.slice(0, 40) || ''}${opts.image ? ' · 图生视频' : ''}\n`);
|
|
44
|
+
const { jobId } = await client.generateVideo({
|
|
45
|
+
prompt: opts.prompt,
|
|
46
|
+
model: opts.model,
|
|
47
|
+
ratio: opts.ratio,
|
|
48
|
+
duration: opts.duration,
|
|
49
|
+
image_url: referenceImage,
|
|
50
|
+
template_id: opts.template,
|
|
51
|
+
input: templateFields,
|
|
52
|
+
});
|
|
53
|
+
process.stderr.write(`视频任务已提交: ${jobId}\n生成中(视频通常 1-5 分钟)...\n`);
|
|
54
|
+
const result = await client.waitVideo(jobId, (status) => {
|
|
55
|
+
if (status === 'processing')
|
|
56
|
+
process.stderr.write('生成中...\r');
|
|
57
|
+
});
|
|
58
|
+
if (result.status !== 'completed' || !result.cdn_url) {
|
|
59
|
+
throw new Error(`视频生成失败: ${result.error || '未知原因'}`);
|
|
60
|
+
}
|
|
61
|
+
process.stderr.write(`✅ 视频完成\n`);
|
|
62
|
+
console.log(result.cdn_url);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// ── 图片模式(原逻辑)──
|
|
66
|
+
process.stderr.write(opts.skill
|
|
67
|
+
? `提交出图: 技能 ${opts.skill}${opts.input ? ` · ${opts.input.slice(0, 30)}` : '(未给描述,按技能规范自由发挥)'}\n`
|
|
68
|
+
: opts.template
|
|
69
|
+
? `提交出图: 模板 ${opts.template}${templateFields ? ` · ${JSON.stringify(templateFields).slice(0, 40)}` : ''}\n`
|
|
70
|
+
: `提交出图: ${opts.prompt.slice(0, 40)}...\n`);
|
|
71
|
+
const job = await client.generateAndWait({
|
|
72
|
+
prompt: opts.prompt,
|
|
73
|
+
skill_slug: opts.skill,
|
|
74
|
+
input: opts.input,
|
|
75
|
+
template_id: opts.template,
|
|
76
|
+
template_fields: templateFields,
|
|
77
|
+
ratio: opts.ratio,
|
|
78
|
+
model: opts.model,
|
|
79
|
+
quality: opts.quality,
|
|
80
|
+
reference_image: referenceImage,
|
|
81
|
+
}, (status) => {
|
|
82
|
+
if (status === 'processing')
|
|
83
|
+
process.stderr.write('生成中...\r');
|
|
84
|
+
});
|
|
85
|
+
// 成功:图片 URL 输出到 stdout(便于管道),元信息到 stderr
|
|
86
|
+
process.stderr.write(`✅ 完成 (${job.elapsed_ms ? (job.elapsed_ms / 1000).toFixed(1) + 's' : '?'})\n`);
|
|
87
|
+
console.log(job.cdn_url);
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* museav image-to-template —— 一张图 → 一个可复用的图片模板。
|
|
3
|
+
*
|
|
4
|
+
* 跟 reverse 的分工(中台 2026-08-16 拆成两个接口,别再混):
|
|
5
|
+
* reverse 只读图:SCULPT 六要素 + 出图 prompt,一次模型调用,同步返回。
|
|
6
|
+
* image-to-template 读图 + 文字层逆向 + 变量化 + 建模板,原图还会被焊成模板的参考图。
|
|
7
|
+
* 想要哪个就调哪个,没有开关可拨——调用这个命令本身就是「我要模板」的意图。
|
|
8
|
+
*
|
|
9
|
+
* 同步/异步不由 --async 一个人说了算(中台契约 §7.6):
|
|
10
|
+
* 建模板(默认)或显式 --async → 异步,先拿 jobId 再轮询
|
|
11
|
+
* --no-create 且没给 --async → 同步,直接返回草稿
|
|
12
|
+
*/
|
|
13
|
+
import type { StudioClient } from '../client.js';
|
|
14
|
+
export interface ImageToTemplateOpts {
|
|
15
|
+
/** commander 的 --no-create 会把 create 置 false,默认 true */
|
|
16
|
+
create?: boolean;
|
|
17
|
+
name?: string;
|
|
18
|
+
slug?: string;
|
|
19
|
+
category?: string;
|
|
20
|
+
variables?: string;
|
|
21
|
+
labels?: string;
|
|
22
|
+
async?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function imageToTemplate(client: StudioClient, input: string, opts?: ImageToTemplateOpts): Promise<void>;
|