@pippit-dev/cli 1.0.28 → 1.0.30
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/README.md +25 -1
- package/checksums.txt +6 -6
- package/dist/checksums.txt +6 -6
- package/internal/models/describe.go +1 -0
- package/internal/models/describe_test.go +2 -2
- package/internal/models/models.go +3 -4
- package/internal/models/models_test.go +14 -0
- package/package.json +3 -2
- package/skills/short-drama/SKILL.md +30 -391
- package/skills/short-drama/commands/auth.md +31 -0
- package/skills/short-drama/commands/download-result.md +26 -0
- package/skills/short-drama/commands/get-thread.md +21 -0
- package/skills/short-drama/commands/list-thread-file.md +25 -0
- package/skills/short-drama/commands/submit-run.md +25 -0
- package/skills/short-drama/commands/upload-file.md +17 -0
- package/skills/short-drama/examples/create-and-deliver.md +24 -0
- package/skills/short-drama/examples/reference-and-continue.md +28 -0
- package/skills/short-drama/scripts/install.md +31 -0
- package/skills/short-drama/workflows/creation.md +52 -0
- package/skills/short-drama/workflows/poll-and-deliver.md +45 -0
- package/skills/xyq-marketing-skill/SKILL.md +51 -0
- package/skills/xyq-marketing-skill/examples/product-video.md +45 -0
- package/skills/xyq-marketing-skill/references/api.md +63 -0
- package/skills/xyq-marketing-skill/scripts/marketing.js +268 -0
- package/skills/xyq-nest-skill/SKILL.md +2 -0
- package/skills/xyq-nest-skill/commands/model.md +3 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Documented Xiaoyunque marketing API; Node.js >= 16, no dependencies.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { Readable } = require('stream');
|
|
8
|
+
const { pipeline } = require('stream/promises');
|
|
9
|
+
|
|
10
|
+
const BASE = 'https://xyq.jianying.com';
|
|
11
|
+
const PATHS = {
|
|
12
|
+
generate: '/api/biz/v1/agent/submit_marketing_run',
|
|
13
|
+
upload: '/api/biz/v1/skill/upload_file',
|
|
14
|
+
query: '/api/biz/v1/agent/query_generate_video_result',
|
|
15
|
+
balance: '/api/biz/v1/skill/get_credit_balance',
|
|
16
|
+
};
|
|
17
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
19
|
+
const RUN_STATES = Object.freeze({ 0: 'Unspecified', 1: 'Submitted', 2: 'Working', 3: 'Completed', 4: 'Failed', 5: 'Canceled', 6: 'InputRequired', 7: 'Generating', 8: 'Interrupt', 9: 'HITL_Interrupt' });
|
|
20
|
+
const WAIT_STATES = ['1', '2', '7', '8'];
|
|
21
|
+
const INPUT_STATES = ['6', '9'];
|
|
22
|
+
function requireValue(condition, message) { if (!condition) throw new Error(message); }
|
|
23
|
+
function onlyKeys(value, keys) {
|
|
24
|
+
requireValue(object(value), '请求字段必须是 JSON 对象');
|
|
25
|
+
requireValue(Object.keys(value).every(key => keys.includes(key)), '存在未支持的字段,请核对接口文档');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validate(body) {
|
|
29
|
+
onlyKeys(body, ['message', 'asset_ids', 'thread_id', 'general_agent_settings']);
|
|
30
|
+
requireValue(nonempty(body.message), 'message 必须为用户的非空创作指令');
|
|
31
|
+
if ('thread_id' in body) requireValue(nonempty(body.thread_id), 'thread_id 必须为非空字符串');
|
|
32
|
+
if ('asset_ids' in body) requireValue(Array.isArray(body.asset_ids) && body.asset_ids.every(nonempty), 'asset_ids 必须为素材 ID 字符串数组');
|
|
33
|
+
const settings = body.general_agent_settings;
|
|
34
|
+
onlyKeys(settings, ['ratio', 'duration_start', 'duration_end', 'show_subtitle', 'video_model', 'video_resolution']);
|
|
35
|
+
requireValue(nonempty(settings.video_model), 'general_agent_settings.video_model 必填;请先取得用户模型选择或按已授权的选择范围配置模型');
|
|
36
|
+
if ('ratio' in settings) requireValue([2, 3, 4, 5, 6].includes(settings.ratio), 'ratio 必须为 2/3/4/5/6 的整数枚举');
|
|
37
|
+
for (const key of ['duration_start', 'duration_end']) {
|
|
38
|
+
if (key in settings) requireValue(Number.isInteger(settings[key]) && settings[key] > 0 && settings[key] <= 2147483647, `${key} 必须为正整数秒数(int32)`);
|
|
39
|
+
}
|
|
40
|
+
if ('duration_start' in settings && 'duration_end' in settings) requireValue(settings.duration_start <= settings.duration_end, '时长下限不能大于上限');
|
|
41
|
+
if ('show_subtitle' in settings) requireValue(typeof settings.show_subtitle === 'boolean', 'show_subtitle 必须为布尔值');
|
|
42
|
+
for (const key of ['video_model', 'video_resolution']) {
|
|
43
|
+
if (key in settings) requireValue(nonempty(settings[key]), `${key} 必须为非空字符串`);
|
|
44
|
+
}
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkResponse(action, result, body) {
|
|
49
|
+
requireValue(object(result) && (result.ret === '0' || result.ret === 0),
|
|
50
|
+
`API 失败:ret=${result && result.ret} errmsg=${result && result.errmsg || ''} log_id=${result && result.log_id || ''}`);
|
|
51
|
+
const data = result.data;
|
|
52
|
+
requireValue(object(data), 'API 响应缺少 data');
|
|
53
|
+
if (action === 'generate') requireValue(object(data.run) && nonempty(data.run.thread_id) && nonempty(data.run.run_id), '提交响应缺少任务 ID;结果不明确,禁止自动重提');
|
|
54
|
+
if (action === 'upload') requireValue(nonempty(data.pippit_asset_id), '上传响应缺少 pippit_asset_id');
|
|
55
|
+
if (action === 'balance') requireValue(typeof data.total_remain_amount === 'string' && /^\d+$/.test(data.total_remain_amount), '余额响应缺少字符串 total_remain_amount');
|
|
56
|
+
if (action === 'query') {
|
|
57
|
+
requireValue(data.thread_id === body.thread_id && data.run_id === body.run_id, '返回任务 ID 与请求不一致');
|
|
58
|
+
requireValue((typeof data.run_state === 'number' || typeof data.run_state === 'string') && /^\d+$/.test(String(data.run_state)), 'run_state 缺失或格式异常');
|
|
59
|
+
for (const key of ['video_urls', 'image_urls']) {
|
|
60
|
+
if (key in data) requireValue(Array.isArray(data[key]) && data[key].every(nonempty), `${key} 格式异常`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createClient({ key = process.env.XYQ_ACCESS_KEY, request = https.request, timeout = 60000 } = {}) {
|
|
67
|
+
function open(url, method, headers, body) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const target = new URL(url);
|
|
70
|
+
requireValue(target.protocol === 'https:' && !target.username && !target.password, '仅支持无内嵌凭据的 HTTPS URL');
|
|
71
|
+
const req = request(target, { method, headers }, res => {
|
|
72
|
+
// Keep the total deadline active through response consumption.
|
|
73
|
+
res.once('end', () => clearTimeout(timer));
|
|
74
|
+
res.once('close', () => clearTimeout(timer));
|
|
75
|
+
resolve(res);
|
|
76
|
+
});
|
|
77
|
+
const timer = setTimeout(() => req.destroy(new Error('请求超时;提交结果可能不明确,请勿自动重提')), timeout);
|
|
78
|
+
req.once('error', error => { clearTimeout(timer); reject(error); });
|
|
79
|
+
if (body && typeof body.pipe === 'function') {
|
|
80
|
+
body.once('error', error => req.destroy(error));
|
|
81
|
+
req.once('close', () => body.destroy());
|
|
82
|
+
body.pipe(req);
|
|
83
|
+
} else req.end(body);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function api(action, body, headers = { 'Content-Type': 'application/json' }) {
|
|
88
|
+
requireValue(nonempty(key) && !/[\r\n]/.test(key), '请在本机环境设置 XYQ_ACCESS_KEY,不要在聊天中发送密钥;CLI 登录不会设置此变量');
|
|
89
|
+
const wire = headers['Content-Type'] === 'application/json' ? JSON.stringify(body) : body;
|
|
90
|
+
const res = await open(BASE + PATHS[action], 'POST', { ...headers, Accept: 'application/json', Authorization: `Bearer ${key}` }, wire);
|
|
91
|
+
// Never follow API redirects or automatically retry a POST.
|
|
92
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
93
|
+
res.resume();
|
|
94
|
+
throw new Error(`HTTP ${res.statusCode};请求未重试,生成结果可能不明确`);
|
|
95
|
+
}
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let size = 0;
|
|
98
|
+
for await (const chunk of res) {
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
requireValue(size <= 8 * 1024 * 1024, 'API 响应超出 8 MiB 限制');
|
|
101
|
+
chunks.push(chunk);
|
|
102
|
+
}
|
|
103
|
+
let result;
|
|
104
|
+
try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
105
|
+
catch (_) { throw new Error('API 未返回有效 JSON;提交结果可能不明确,请勿自动重提'); }
|
|
106
|
+
return checkResponse(action, result, body);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function upload(file) {
|
|
110
|
+
const stat = await fs.promises.stat(file);
|
|
111
|
+
requireValue(stat.isFile() && stat.size > 0 && stat.size < 500000000, '上传需要非空文件且小于 500 MB');
|
|
112
|
+
const boundary = 'xyq-' + crypto.randomBytes(16).toString('hex');
|
|
113
|
+
const name = path.basename(file).replace(/["\r\n\\]/g, '_');
|
|
114
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
115
|
+
const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${mime}\r\n\r\n`);
|
|
116
|
+
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
117
|
+
const body = Readable.from((async function* () {
|
|
118
|
+
yield head;
|
|
119
|
+
for await (const chunk of fs.createReadStream(file)) yield chunk;
|
|
120
|
+
yield tail;
|
|
121
|
+
})());
|
|
122
|
+
return api('upload', body, { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': head.length + stat.size + tail.length });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function download(url, destination) {
|
|
126
|
+
// Media requests never receive the API key, including across redirects.
|
|
127
|
+
let res;
|
|
128
|
+
for (let redirects = 0; ; redirects++) {
|
|
129
|
+
res = await open(url, 'GET', {}, undefined);
|
|
130
|
+
if (![301, 302, 303, 307, 308].includes(res.statusCode)) break;
|
|
131
|
+
res.resume();
|
|
132
|
+
requireValue(redirects < 5 && res.headers.location, '媒体重定向次数过多或缺少 Location');
|
|
133
|
+
url = new URL(res.headers.location, url).href;
|
|
134
|
+
}
|
|
135
|
+
if (res.statusCode !== 200) { res.resume(); throw new Error(`媒体下载 HTTP ${res.statusCode}`); }
|
|
136
|
+
if (/text\/html|application\/json/i.test(res.headers['content-type'] || '')) {
|
|
137
|
+
res.resume(); throw new Error('下载返回网页或 JSON,未取得媒体');
|
|
138
|
+
}
|
|
139
|
+
// Exclusive creation preserves existing user files and permits safe recovery.
|
|
140
|
+
let output;
|
|
141
|
+
try { output = await fs.promises.open(destination, 'wx'); }
|
|
142
|
+
catch (error) { res.destroy(); throw error; }
|
|
143
|
+
try {
|
|
144
|
+
await pipeline(res, fs.createWriteStream(destination, { fd: output.fd, autoClose: false }));
|
|
145
|
+
await output.close();
|
|
146
|
+
requireValue((await fs.promises.stat(destination)).size > 0, '下载文件为空');
|
|
147
|
+
} catch (error) {
|
|
148
|
+
await output.close().catch(() => {});
|
|
149
|
+
await fs.promises.unlink(destination).catch(() => {});
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
return path.resolve(destination);
|
|
153
|
+
}
|
|
154
|
+
return { api, upload, download };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const HELP = `小云雀营销 Skill(Node.js >= 16)
|
|
158
|
+
node marketing.js generate --request request.json [--dry-run | --execute]
|
|
159
|
+
node marketing.js upload --file product.png
|
|
160
|
+
node marketing.js query --thread-id ID --run-id ID [--wait] [--max-wait 900] [--output-dir DIR]
|
|
161
|
+
node marketing.js balance
|
|
162
|
+
所有 API 调用从环境读取 XYQ_ACCESS_KEY;generate 默认仅预览。
|
|
163
|
+
--timeout 秒数:单请求总时限,默认 60;--max-wait:轮询总时限,默认 900。
|
|
164
|
+
query 输出 API 原始响应;有 --output-dir 时成功结果附带 downloaded_files。
|
|
165
|
+
退出码:0 成功/单次查询进行中;1 输入或接口错误;2 生成失败/取消/无视频;3 等待超时;4 等待用户交互;5 未知或未指定状态。
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
function parseArgs(argv) {
|
|
169
|
+
const [action, ...rest] = argv;
|
|
170
|
+
requireValue(Object.hasOwnProperty.call(PATHS, action), '操作必须是 generate/upload/query/balance');
|
|
171
|
+
const options = {};
|
|
172
|
+
const flags = ['execute', 'dry-run', 'wait'];
|
|
173
|
+
const allowed = { generate: ['request', 'execute', 'dry-run'], upload: ['file'], query: ['thread-id', 'run-id', 'wait', 'max-wait', 'output-dir'], balance: [] }[action].concat('timeout');
|
|
174
|
+
for (let i = 0; i < rest.length; i++) {
|
|
175
|
+
const name = rest[i].replace(/^--/, '');
|
|
176
|
+
requireValue(rest[i].startsWith('--') && allowed.includes(name) && !(name in options), `无效或重复参数:${rest[i]}`);
|
|
177
|
+
if (flags.includes(name)) options[name] = true;
|
|
178
|
+
else {
|
|
179
|
+
requireValue(nonempty(rest[i + 1]) && !rest[i + 1].startsWith('--'), `${name} 缺少值`);
|
|
180
|
+
options[name] = rest[++i];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
requireValue(!(options.execute && options['dry-run']), '--execute 和 --dry-run 不能同时使用');
|
|
184
|
+
for (const [name, fallback, max] of [['timeout', 60, 1800], ['max-wait', 900, 86400]]) {
|
|
185
|
+
const value = options[name] === undefined ? fallback : Number(options[name]);
|
|
186
|
+
requireValue(Number.isInteger(value) && value > 0 && value <= max, `${name} 必须为 1 至 ${max} 的整数秒数`);
|
|
187
|
+
options[name] = value;
|
|
188
|
+
}
|
|
189
|
+
return { action, options };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function main(argv, { env = process.env, out = console.log, clientFactory = createClient, now = Date.now, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
193
|
+
if (!argv.length || argv.includes('--help') || argv.includes('-h')) { out(HELP); return 0; }
|
|
194
|
+
const { action, options } = parseArgs(argv);
|
|
195
|
+
let body = {};
|
|
196
|
+
if (action === 'generate') {
|
|
197
|
+
requireValue(options.request, 'generate 需要 --request JSON 文件');
|
|
198
|
+
body = validate(JSON.parse(await fs.promises.readFile(options.request, 'utf8')));
|
|
199
|
+
if (!options.execute) { out(JSON.stringify({ dry_run: true, url: BASE + PATHS.generate, body })); return 0; }
|
|
200
|
+
}
|
|
201
|
+
if (action === 'query') {
|
|
202
|
+
requireValue(nonempty(options['thread-id']) && nonempty(options['run-id']), 'query 需要 thread-id 和 run-id');
|
|
203
|
+
body = { thread_id: options['thread-id'], run_id: options['run-id'] };
|
|
204
|
+
}
|
|
205
|
+
if (action === 'upload') requireValue(nonempty(options.file), 'upload 需要 --file');
|
|
206
|
+
const client = clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: options.timeout * 1000 });
|
|
207
|
+
let result;
|
|
208
|
+
const deadline = now() + options['max-wait'] * 1000;
|
|
209
|
+
do {
|
|
210
|
+
// A poll request cannot overrun the remaining wait budget.
|
|
211
|
+
const pollClient = action === 'query' && options.wait
|
|
212
|
+
? clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client;
|
|
213
|
+
result = action === 'upload' ? await client.upload(options.file) : await pollClient.api(action, body);
|
|
214
|
+
if (action !== 'query' || !options.wait || !WAIT_STATES.includes(String(result.data.run_state))) break;
|
|
215
|
+
if (now() >= deadline) {
|
|
216
|
+
out(JSON.stringify({ ...result, wait_timed_out: true })); return 3;
|
|
217
|
+
}
|
|
218
|
+
await pause(Math.min(10000, deadline - now()));
|
|
219
|
+
if (now() >= deadline) { out(JSON.stringify({ ...result, wait_timed_out: true })); return 3; }
|
|
220
|
+
} while (true);
|
|
221
|
+
if (action === 'query') {
|
|
222
|
+
const state = String(result.data.run_state);
|
|
223
|
+
if (INPUT_STATES.includes(state)) {
|
|
224
|
+
out(JSON.stringify({ ...result, run_state_name: RUN_STATES[state], action_required: true,
|
|
225
|
+
next_step: '查看同一会话的确认或问卷;沿用已有授权,缺少必要选择时再询问用户。确认后取得同一 thread 的最新 run_id 再查询,旧 Run 可保持此状态。不要重复生成。' }));
|
|
226
|
+
return 4;
|
|
227
|
+
}
|
|
228
|
+
if (!(state in RUN_STATES) || state === '0') {
|
|
229
|
+
out(JSON.stringify({ ...result, run_state_name: RUN_STATES[state] || 'Unknown', unknown_state: true,
|
|
230
|
+
next_step: '停止自动轮询,保留任务 ID 和原始响应,核实服务端状态含义;不要重新提交生成。' }));
|
|
231
|
+
return 5;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Print IDs and URLs before downloads so a partial download failure remains resumable.
|
|
235
|
+
out(JSON.stringify(result));
|
|
236
|
+
if (action === 'query') {
|
|
237
|
+
const state = String(result.data.run_state);
|
|
238
|
+
if (['4', '5'].includes(state)) return 2;
|
|
239
|
+
if (state === '3') {
|
|
240
|
+
if (!(result.data.video_urls || []).length) return 2;
|
|
241
|
+
if (options['output-dir']) {
|
|
242
|
+
await fs.promises.mkdir(options['output-dir'], { recursive: true });
|
|
243
|
+
const downloaded = [];
|
|
244
|
+
for (const [field, ext] of [['video_urls', '.mp4'], ['image_urls', '.jpg']]) {
|
|
245
|
+
for (const [index, url] of (result.data[field] || []).entries()) {
|
|
246
|
+
const suffix = path.extname(new URL(url).pathname);
|
|
247
|
+
const mediaExt = /^\.(mp4|mov|webm|mkv|jpg|jpeg|png|webp|gif)$/i.test(suffix) ? suffix : ext;
|
|
248
|
+
const file = path.join(options['output-dir'], `${field}-${index + 1}-${crypto.randomBytes(4).toString('hex')}${mediaExt}`);
|
|
249
|
+
downloaded.push({ url, path: await client.download(url, file) });
|
|
250
|
+
out(JSON.stringify({ downloaded_file: downloaded[downloaded.length - 1] }));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
out(JSON.stringify({ ...result, downloaded_files: downloaded }));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (require.main === module) {
|
|
261
|
+
main(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(error => {
|
|
262
|
+
const key = process.env.XYQ_ACCESS_KEY;
|
|
263
|
+
const message = key ? error.message.split(key).join('[REDACTED]') : error.message;
|
|
264
|
+
console.error(JSON.stringify({ error: message, note: '请求未自动重试;已有任务请按原 thread_id/run_id 继续查询。' }));
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
module.exports = { BASE, PATHS, validate, checkResponse, createClient, parseArgs, main };
|
|
@@ -10,6 +10,8 @@ metadata:
|
|
|
10
10
|
|
|
11
11
|
通过 CLI 完成生成、处理、结果下载与媒体交付。支持下表中的操作;不提供多轮会话续写或自动拆分剧本、分镜并编排成片的能力。复杂需求先确认能由所列命令完成的具体操作,不承诺未覆盖的流程。
|
|
12
12
|
|
|
13
|
+
商品图文营销一键成片(剧情广告、品牌大片、达人带货)由独立的 `xyq-marketing-skill` 提供,不用普通生视频代替营销编排;未安装该 Skill 时先说明当前能力边界。
|
|
14
|
+
|
|
13
15
|
## 开始执行
|
|
14
16
|
|
|
15
17
|
1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`,其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。
|
|
@@ -18,7 +18,9 @@ pippit-tool-cli model list --refresh
|
|
|
18
18
|
pippit-tool-cli model describe MiniMax-H3 --refresh
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`list` 输出 `models`,每项包含 `key`、`name`、`kind
|
|
21
|
+
`list` 输出 `models`,每项包含 `key`、`name`、`kind`。关键词与 key 完全一致时优先返回该项,否则按 key / name 不区分大小写检索。`describe` 只接受准确 key,输出整理后的 `model` 参数详情;不知道 key 时先查列表,不用展示名猜枚举。
|
|
22
|
+
|
|
23
|
+
列表与详情不输出模型级 `is_default`;服务端默认标记不代表用户授权自动选模型。用户未明确模型且未授权代选时先确认,不按列表顺序代选;比例、分辨率、时长等参数默认值继续展示。
|
|
22
24
|
|
|
23
25
|
两种输出均包含 `scene`、`cached`、`fetched_at`、`expires_at`。合法空列表输出 `models: []`,表示当前没有可见模型。
|
|
24
26
|
|