kie-ai-cli 1.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/dist/image.js ADDED
@@ -0,0 +1,233 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { createJob, getJobInfo, pollJob } from './job.js';
6
+ import { buildImageTaskInput, formatImageModelLabel, resolveImageModel, } from './image-models.js';
7
+ import { IMAGE_DOWNLOAD_TIMEOUT_MS, isAbortError } from './kie-http.js';
8
+ function formatDownloadSize(bytes) {
9
+ if (bytes < 1024)
10
+ return `${bytes} B`;
11
+ if (bytes < 1024 * 1024)
12
+ return `${(bytes / 1024).toFixed(1)} KB`;
13
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
14
+ }
15
+ /** 流式下载,带超时与进度回调 */
16
+ async function downloadUrlToBuffer(url, onProgress) {
17
+ const ac = new AbortController();
18
+ const timer = setTimeout(() => ac.abort(), IMAGE_DOWNLOAD_TIMEOUT_MS);
19
+ try {
20
+ const response = await fetch(url, {
21
+ signal: ac.signal,
22
+ headers: {
23
+ 'User-Agent': 'Mozilla/5.0 (compatible; kie-cli/1.0)',
24
+ Accept: 'image/*,*/*;q=0.8',
25
+ },
26
+ redirect: 'follow',
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`HTTP ${response.status} ${response.statusText || ''}`.trim());
30
+ }
31
+ const total = Number(response.headers.get('content-length')) || 0;
32
+ const body = response.body;
33
+ if (!body) {
34
+ onProgress?.('正在读取响应…');
35
+ return Buffer.from(await response.arrayBuffer());
36
+ }
37
+ const reader = body.getReader();
38
+ const chunks = [];
39
+ let received = 0;
40
+ let lastTick = 0;
41
+ while (true) {
42
+ const { done, value } = await reader.read();
43
+ if (done)
44
+ break;
45
+ const chunk = Buffer.from(value);
46
+ chunks.push(chunk);
47
+ received += chunk.length;
48
+ const now = Date.now();
49
+ if (onProgress && now - lastTick >= 400) {
50
+ lastTick = now;
51
+ const pct = total > 0 ? ` ${Math.min(100, Math.round((received / total) * 100))}%` : '';
52
+ const totalPart = total > 0 ? ` / ${formatDownloadSize(total)}` : '';
53
+ onProgress(`正在下载 ${formatDownloadSize(received)}${totalPart}${pct}`);
54
+ }
55
+ }
56
+ if (received === 0) {
57
+ throw new Error('响应体为空');
58
+ }
59
+ return Buffer.concat(chunks);
60
+ }
61
+ catch (err) {
62
+ if (isAbortError(err)) {
63
+ throw new Error(`下载超时(${IMAGE_DOWNLOAD_TIMEOUT_MS / 1000}s)。可改用 --no-download 仅保留链接,或浏览器直接打开 URL`);
64
+ }
65
+ throw err;
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+ export function parseResultUrls(resultJson) {
72
+ if (!resultJson)
73
+ return [];
74
+ try {
75
+ const result = JSON.parse(resultJson);
76
+ if (Array.isArray(result.resultUrls) && result.resultUrls.length > 0) {
77
+ return result.resultUrls;
78
+ }
79
+ if (typeof result.resultUrl === 'string')
80
+ return [result.resultUrl];
81
+ if (typeof result.url === 'string')
82
+ return [result.url];
83
+ }
84
+ catch {
85
+ /* ignore */
86
+ }
87
+ return [];
88
+ }
89
+ export async function downloadResultImages(urls, outputDir) {
90
+ if (!fs.existsSync(outputDir)) {
91
+ fs.mkdirSync(outputDir, { recursive: true });
92
+ }
93
+ const saved = [];
94
+ let imgIndex = 1;
95
+ for (const url of urls) {
96
+ console.log(chalk.cyan(` 🔗 ${url}`));
97
+ try {
98
+ const spinnerDl = ora({
99
+ text: '正在连接…',
100
+ discardStdin: false,
101
+ }).start();
102
+ const buffer = await downloadUrlToBuffer(url, (label) => {
103
+ spinnerDl.text = label;
104
+ });
105
+ const ext = url.split('.').pop()?.split('?')[0].toLowerCase() || 'png';
106
+ const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext) ? ext : 'png';
107
+ const fileName = `kie_img_${Date.now()}_${imgIndex}.${safeExt}`;
108
+ const filePath = path.join(outputDir, fileName);
109
+ fs.writeFileSync(filePath, buffer);
110
+ spinnerDl.succeed(`已保存: ${filePath} (${formatDownloadSize(buffer.length)})`);
111
+ saved.push(filePath);
112
+ imgIndex++;
113
+ }
114
+ catch (err) {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ console.log(chalk.red(` ⚠️ 下载失败: ${msg}`));
117
+ console.log(chalk.gray(` 可手动打开: ${url}`));
118
+ }
119
+ }
120
+ return saved;
121
+ }
122
+ export function printJobStatus(info, taskId) {
123
+ console.log(chalk.cyan('\n任务详情:\n'));
124
+ console.log(` 任务 ID: ${chalk.bold(taskId)}`);
125
+ console.log(` 状态: ${chalk.bold(info.state)}`);
126
+ console.log(` 进度: ${info.progress ?? 0}%`);
127
+ if (info.model)
128
+ console.log(` 模型: ${info.model}`);
129
+ if (info.failMsg)
130
+ console.log(chalk.red(` 失败: ${info.failMsg}`));
131
+ const urls = parseResultUrls(info.resultJson);
132
+ if (urls.length > 0) {
133
+ console.log(chalk.green('\n 结果链接:'));
134
+ urls.forEach((u) => console.log(chalk.cyan(` ${u}`)));
135
+ }
136
+ console.log();
137
+ }
138
+ /** 提交任务 → 轮询 → 可选下载 */
139
+ export async function runImageGeneration(options) {
140
+ const { config, apiKey, baseUrl, prompt, aspectRatio, quality, imageUrls, download = true, signal, onTaskCreated, } = options;
141
+ const model = resolveImageModel(config, options.model);
142
+ const input = buildImageTaskInput(model, {
143
+ prompt,
144
+ aspectRatio,
145
+ quality,
146
+ imageUrls,
147
+ });
148
+ const spinner = ora({
149
+ text: `正在提交任务 (${formatImageModelLabel(model)})...`,
150
+ discardStdin: false,
151
+ }).start();
152
+ try {
153
+ const taskId = await createJob({
154
+ apiKey,
155
+ baseUrl,
156
+ model,
157
+ input,
158
+ signal,
159
+ });
160
+ onTaskCreated?.(taskId);
161
+ spinner.text = `任务已提交 (${taskId}),等待处理...`;
162
+ const finalJob = await pollJob({
163
+ apiKey,
164
+ baseUrl,
165
+ taskId,
166
+ signal,
167
+ onProgress: (progress, state) => {
168
+ spinner.text = `处理中… 状态: ${state} | 进度: ${progress}%`;
169
+ },
170
+ });
171
+ spinner.succeed('图片生成成功!');
172
+ const urls = parseResultUrls(finalJob.resultJson);
173
+ const outputDir = options.outputDir ?? path.join(process.cwd(), 'kie-image');
174
+ let savedPaths = [];
175
+ if (urls.length === 0) {
176
+ console.log(chalk.yellow('\n任务成功,但未在 resultJson 中找到图片链接。\n'));
177
+ }
178
+ else if (download) {
179
+ console.log(chalk.green(`\n🎨 共 ${urls.length} 张图片,保存到 ${outputDir}:\n`));
180
+ savedPaths = await downloadResultImages(urls, outputDir);
181
+ }
182
+ else {
183
+ console.log(chalk.green('\n🎨 生成结果:\n'));
184
+ urls.forEach((u) => console.log(chalk.cyan(` ${u}`)));
185
+ console.log();
186
+ }
187
+ return { taskId, job: finalJob, urls, savedPaths };
188
+ }
189
+ catch (err) {
190
+ spinner.fail('图片任务失败');
191
+ throw err;
192
+ }
193
+ }
194
+ /** 仅查询一次任务状态 */
195
+ export async function queryImageTask(apiKey, baseUrl, taskId) {
196
+ return getJobInfo(apiKey, baseUrl, taskId);
197
+ }
198
+ /** 等待已有任务完成并下载 */
199
+ export async function waitImageTask(options) {
200
+ const spinner = ora({
201
+ text: `等待任务 ${options.taskId}...`,
202
+ discardStdin: false,
203
+ }).start();
204
+ try {
205
+ const finalJob = await pollJob({
206
+ apiKey: options.apiKey,
207
+ baseUrl: options.baseUrl,
208
+ taskId: options.taskId,
209
+ signal: options.signal,
210
+ onProgress: (progress, state) => {
211
+ spinner.text = `处理中… 状态: ${state} | 进度: ${progress}%`;
212
+ },
213
+ });
214
+ spinner.succeed('任务完成!');
215
+ const urls = parseResultUrls(finalJob.resultJson);
216
+ const outputDir = options.outputDir ?? path.join(process.cwd(), 'kie-image');
217
+ let savedPaths = [];
218
+ if (urls.length > 0 && options.download !== false) {
219
+ console.log(chalk.green(`\n🎨 下载到 ${outputDir}:\n`));
220
+ savedPaths = await downloadResultImages(urls, outputDir);
221
+ }
222
+ else if (urls.length > 0) {
223
+ console.log(chalk.green('\n🎨 结果链接:\n'));
224
+ urls.forEach((u) => console.log(chalk.cyan(` ${u}`)));
225
+ console.log();
226
+ }
227
+ return { job: finalJob, urls, savedPaths };
228
+ }
229
+ catch (err) {
230
+ spinner.fail('任务失败');
231
+ throw err;
232
+ }
233
+ }
package/dist/index.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import Conf from 'conf';
4
+ const config = new Conf({ projectName: 'kie-cli' });
5
+ const program = new Command();
6
+ program
7
+ .name('kie')
8
+ .description('Kie 内部 AI 命令行调用工具')
9
+ .version('1.0.0')
10
+ .addHelpText('before', `
11
+ \x1b[38;5;33m _ __ ___ _____ \x1b[38;5;39m____ _ ___
12
+ \x1b[38;5;33m | |/ / |_ _| | ____| \x1b[38;5;39m/ ___| | |_ _|
13
+ \x1b[38;5;33m | < | | | _| \x1b[38;5;39m| | | | | |
14
+ \x1b[38;5;33m | |\\ \\ | | | |___ \x1b[38;5;39m| |___| |___ | |
15
+ \x1b[38;5;33m |_| \\_\\ |___| |_____| \x1b[38;5;39m \\____|_____|___|
16
+ \x1b[38;5;244mAdvanced Coding Intelligence CLI\x1b[0m
17
+ `);
18
+ async function readStdin() {
19
+ return new Promise((resolve) => {
20
+ let data = '';
21
+ if (process.stdin.isTTY) {
22
+ resolve(data);
23
+ return;
24
+ }
25
+ process.stdin.setEncoding('utf-8');
26
+ process.stdin.on('data', (chunk) => (data += chunk));
27
+ process.stdin.on('end', () => resolve(data.trim()));
28
+ });
29
+ }
30
+ program
31
+ .command('config')
32
+ .description('设置全局配置参数 (apiKey / baseUrl / 模型)')
33
+ .option('--set-api-key <key>', '设置 API 密钥')
34
+ .option('--set-base-url <url>', '设置 API BaseURL')
35
+ .option('--set-model <model>', '设置默认对话模型 (Claude model id)')
36
+ .option('--set-image-model <model>', '设置默认图片生成模型')
37
+ .option('--set-image-assist-model <model>', '设置 image 工作台助手模型')
38
+ .action(async (options) => {
39
+ const { configAction } = await import('./commands/config.js');
40
+ await configAction(options, config);
41
+ });
42
+ program
43
+ .command('sessions')
44
+ .description('列出本地保存的对话会话')
45
+ .action(async () => {
46
+ const { sessionsAction } = await import('./commands/config.js');
47
+ await sessionsAction(config);
48
+ });
49
+ program
50
+ .command('models')
51
+ .description('选择或列出对话 Chat 模型')
52
+ .option('--list', '仅打印列表')
53
+ .option('--set', '保存为默认模型')
54
+ .action(async (options) => {
55
+ const { modelsAction } = await import('./commands/config.js');
56
+ await modelsAction(options, config);
57
+ });
58
+ program
59
+ .command('chat')
60
+ .description('与 AI 进行流式对话 (支持连续对话和管道输入)')
61
+ .argument('[message]', '发送给 AI 的消息内容')
62
+ .option('--new', '开始新会话')
63
+ .option('--resume [id]', '恢复指定会话')
64
+ .option('-m, --model <model>', '指定对话模型')
65
+ .action(async (message, options) => {
66
+ const { chatAction } = await import('./commands/chat.js');
67
+ await chatAction(message, options, config, readStdin);
68
+ });
69
+ program
70
+ .command('image')
71
+ .description('生成 AI 图片')
72
+ .argument('[prompt]', '提示词')
73
+ .option('-m, --model <model>', '图片模型')
74
+ .option('-r, --ratio <ratio>', '图片纵横比 (1:1 / 16:9 / 4:3等)')
75
+ .option('-q, --quality <quality>', '生成质量 (standard / hd)')
76
+ .option('-i, --image-url <url...>', '参考图 URL 或本地图片文件路径 (本地文件会自动上传)')
77
+ .option('--no-wait', '不等待任务生成结束')
78
+ .option('--seed <number>', '生成随机种子 (Seed)')
79
+ .option('-d, --download <dir>', '生成成功后自动下载到指定目录')
80
+ .action(async (prompt, options) => {
81
+ const { imageAction } = await import('./commands/image.js');
82
+ await imageAction(prompt, options, config);
83
+ });
84
+ program
85
+ .command('image-models')
86
+ .description('选择或列出图片模型')
87
+ .option('--list', '仅打印列表')
88
+ .option('--set', '保存为默认')
89
+ .action(async (options) => {
90
+ const { imageModelsAction } = await import('./commands/image.js');
91
+ await imageModelsAction(options, config);
92
+ });
93
+ program
94
+ .command('job')
95
+ .description('查询异步任务状态')
96
+ .argument('<jobId>', '任务 ID')
97
+ .option('--json', 'JSON 输出')
98
+ .action(async (jobId, options) => {
99
+ const { jobAction } = await import('./commands/job.js');
100
+ await jobAction(jobId, options, config);
101
+ });
102
+ program
103
+ .command('upload')
104
+ .description('上传本地图片或文件并获取临时下载 URL')
105
+ .argument('<filepath>', '本地文件路径')
106
+ .option('--path <uploadPath>', '服务器上的上传路径', 'images/user-uploads')
107
+ .action(async (filepath, options) => {
108
+ const { uploadAction } = await import('./commands/upload.js');
109
+ await uploadAction(filepath, options, config);
110
+ });
111
+ program
112
+ .command('code')
113
+ .description('代码模式')
114
+ .argument('[task]', '任务描述')
115
+ .option('-m, --model <model>', '指定代码模型')
116
+ .option('--cwd <dir>', '代码工作目录')
117
+ .option('--init', '初始化当前 code 工作目录')
118
+ .option('--run <cmd>', '先执行命令并注入上下文', (val, prev) => [...prev, val], [])
119
+ .option('--agent', '启用自动执行代理循环')
120
+ .option('--chat', '切换为纯对话模式')
121
+ .option('--route <mode>', '模型路由')
122
+ .option('--max-steps <n>', 'agent 最大执行轮数', '25')
123
+ .option('-y, --yes', 'agent 模式自动确认执行命令')
124
+ .option('--session <id>', '使用指定 code 会话')
125
+ .option('--new-session', '创建新的 code 会话')
126
+ .option('-f, --file <path>', '附加文件内容', (val, prev) => [...prev, val], [])
127
+ .option('--no-tree', '不附带项目结构摘要')
128
+ .option('--tree-limit <n>', '项目结构最大条目数', '120')
129
+ .option('--file-max-chars <n>', '每个附加文件最大字符数', '8000')
130
+ .allowUnknownOption()
131
+ .action(async (task, options, cmd) => {
132
+ const { codeAction } = await import('./commands/code-command.js');
133
+ await codeAction(task, cmd.opts(), config, readStdin);
134
+ });
135
+ program.parse(process.argv);
package/dist/job.js ADDED
@@ -0,0 +1,121 @@
1
+ import { ofetch } from 'ofetch';
2
+ import { formatKieError, isAbortError } from './kie-http.js';
3
+ /**
4
+ * 提交异步任务
5
+ */
6
+ export async function createJob(options) {
7
+ const { apiKey, baseUrl, model, input, callBackUrl, signal } = options;
8
+ const endpoint = `${baseUrl.replace(/\/+$/, '')}/api/v1/jobs/createTask`;
9
+ let response;
10
+ try {
11
+ response = await ofetch(endpoint, {
12
+ method: 'POST',
13
+ headers: {
14
+ Authorization: `Bearer ${apiKey}`,
15
+ 'Content-Type': 'application/json',
16
+ },
17
+ body: {
18
+ model,
19
+ input,
20
+ ...(callBackUrl ? { callBackUrl } : {}),
21
+ },
22
+ signal,
23
+ retry: 0,
24
+ });
25
+ }
26
+ catch (error) {
27
+ if (isAbortError(error))
28
+ throw error;
29
+ throw new Error(formatKieError(error));
30
+ }
31
+ if (response.code === 200 && response.data?.taskId) {
32
+ return response.data.taskId;
33
+ }
34
+ throw new Error(`创建任务失败: ${response.msg || JSON.stringify(response)}`);
35
+ }
36
+ /**
37
+ * 获取任务详情
38
+ */
39
+ export async function getJobInfo(apiKey, baseUrl, taskId, signal) {
40
+ const endpoint = `${baseUrl.replace(/\/+$/, '')}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`;
41
+ let response;
42
+ try {
43
+ response = await ofetch(endpoint, {
44
+ method: 'GET',
45
+ headers: {
46
+ Authorization: `Bearer ${apiKey}`,
47
+ },
48
+ signal,
49
+ retry: 0,
50
+ });
51
+ }
52
+ catch (error) {
53
+ if (isAbortError(error))
54
+ throw error;
55
+ throw new Error(formatKieError(error));
56
+ }
57
+ if ((response.code === 200 || response.code === 505) && response.data) {
58
+ return response.data;
59
+ }
60
+ throw new Error(`获取任务详情失败: ${response.msg || JSON.stringify(response)}`);
61
+ }
62
+ /**
63
+ * 轮询任务直至完成
64
+ */
65
+ export async function pollJob(options) {
66
+ const { apiKey, baseUrl, taskId, onProgress, intervalMs = 2000, timeoutMs = 600_000, signal, } = options;
67
+ const startTime = Date.now();
68
+ return new Promise((resolve, reject) => {
69
+ let timer = null;
70
+ const cleanup = () => {
71
+ if (timer) {
72
+ clearInterval(timer);
73
+ timer = null;
74
+ }
75
+ };
76
+ const onAbort = () => {
77
+ cleanup();
78
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
79
+ };
80
+ if (signal?.aborted) {
81
+ onAbort();
82
+ return;
83
+ }
84
+ signal?.addEventListener('abort', onAbort, { once: true });
85
+ timer = setInterval(async () => {
86
+ if (signal?.aborted) {
87
+ onAbort();
88
+ return;
89
+ }
90
+ try {
91
+ if (Date.now() - startTime > timeoutMs) {
92
+ cleanup();
93
+ signal?.removeEventListener('abort', onAbort);
94
+ reject(new Error('任务轮询超时'));
95
+ return;
96
+ }
97
+ const info = await getJobInfo(apiKey, baseUrl, taskId, signal);
98
+ if (onProgress) {
99
+ onProgress(info.progress || 0, info.state);
100
+ }
101
+ if (info.state === 'success') {
102
+ cleanup();
103
+ signal?.removeEventListener('abort', onAbort);
104
+ resolve(info);
105
+ }
106
+ else if (info.state === 'failed' || info.state === 'error') {
107
+ cleanup();
108
+ signal?.removeEventListener('abort', onAbort);
109
+ reject(new Error(`任务执行失败: ${info.failMsg || '未知原因'}`));
110
+ }
111
+ }
112
+ catch (err) {
113
+ if (isAbortError(err)) {
114
+ onAbort();
115
+ return;
116
+ }
117
+ // 网络抖动:继续轮询
118
+ }
119
+ }, intervalMs);
120
+ });
121
+ }
@@ -0,0 +1,182 @@
1
+ import { ofetch } from 'ofetch';
2
+ import chalk from 'chalk';
3
+ /** 单次对话请求超时(毫秒) */
4
+ export const KIE_REQUEST_TIMEOUT_MS = 120_000;
5
+ /** 结果图片 CDN 下载超时(毫秒) */
6
+ export const IMAGE_DOWNLOAD_TIMEOUT_MS = 180_000;
7
+ function extractErrorDetail(data, fallback) {
8
+ if (typeof data === 'string' && data.trim()) {
9
+ return data.trim().slice(0, 500);
10
+ }
11
+ if (data && typeof data === 'object') {
12
+ const obj = data;
13
+ const msg = obj.msg ?? obj.message ?? obj.error ?? obj.failMsg ?? obj.detail;
14
+ if (typeof msg === 'string' && msg.trim())
15
+ return msg.trim();
16
+ if (typeof msg === 'object' && msg !== null) {
17
+ return JSON.stringify(msg).slice(0, 500);
18
+ }
19
+ }
20
+ return fallback;
21
+ }
22
+ /** 将 ofetch / 网络错误转为可读信息 */
23
+ export function isAbortError(error) {
24
+ if (error instanceof Error && error.name === 'AbortError')
25
+ return true;
26
+ if (typeof DOMException !== 'undefined' && error instanceof DOMException) {
27
+ return error.name === 'AbortError';
28
+ }
29
+ const msg = error instanceof Error ? error.message : String(error);
30
+ return /aborted|AbortError/i.test(msg);
31
+ }
32
+ export function formatKieError(error) {
33
+ if (error && typeof error === 'object' && 'name' in error && error.name === 'FetchError') {
34
+ const fe = error;
35
+ const status = fe.response?.status;
36
+ const detail = extractErrorDetail(fe.data, fe.message || '请求失败');
37
+ if (status)
38
+ return `HTTP ${status}: ${detail}`;
39
+ if (fe.message?.includes('timeout') || fe.message?.includes('aborted')) {
40
+ return `请求超时(${KIE_REQUEST_TIMEOUT_MS / 1000}s),中转站可能已无响应`;
41
+ }
42
+ return detail;
43
+ }
44
+ if (error instanceof Error) {
45
+ if (error.message.includes('timeout') || error.message.includes('aborted')) {
46
+ return `请求超时(${KIE_REQUEST_TIMEOUT_MS / 1000}s),中转站可能已无响应`;
47
+ }
48
+ return error.message;
49
+ }
50
+ return String(error);
51
+ }
52
+ export async function kiePostJson(url, init) {
53
+ try {
54
+ return await ofetch(url, {
55
+ method: 'POST',
56
+ headers: {
57
+ Authorization: `Bearer ${init.apiKey}`,
58
+ 'Content-Type': 'application/json',
59
+ },
60
+ body: init.body,
61
+ signal: init.signal,
62
+ timeout: KIE_REQUEST_TIMEOUT_MS,
63
+ retry: 3,
64
+ retryDelay: 1000,
65
+ onResponseError({ response }) {
66
+ // Log or handle specific errors if needed
67
+ if (response.status === 429) {
68
+ console.warn(chalk.yellow('\n⚠️ Rate limited by server, retrying...'));
69
+ }
70
+ }
71
+ });
72
+ }
73
+ catch (error) {
74
+ if (isAbortError(error))
75
+ throw error;
76
+ throw new Error(formatKieError(error));
77
+ }
78
+ }
79
+ export async function kiePostStream(url, init, maxRetries = 3) {
80
+ let attempt = 0;
81
+ while (true) {
82
+ try {
83
+ const response = await ofetch.raw(url, {
84
+ method: 'POST',
85
+ headers: {
86
+ Authorization: `Bearer ${init.apiKey}`,
87
+ 'Content-Type': 'application/json',
88
+ },
89
+ body: init.body,
90
+ signal: init.signal,
91
+ timeout: KIE_REQUEST_TIMEOUT_MS,
92
+ retry: 0,
93
+ });
94
+ if (!response.ok) {
95
+ let detail = '';
96
+ try {
97
+ const text = await response.text();
98
+ try {
99
+ detail = extractErrorDetail(JSON.parse(text), text);
100
+ }
101
+ catch {
102
+ detail = text.trim().slice(0, 500);
103
+ }
104
+ }
105
+ catch {
106
+ detail = response.statusText || '未知错误';
107
+ }
108
+ if ((response.status >= 500 || response.status === 429) && attempt < maxRetries) {
109
+ throw new Error(`Retryable HTTP ${response.status}: ${detail}`);
110
+ }
111
+ throw new Error(`HTTP ${response.status}: ${detail}`);
112
+ }
113
+ if (!response.body) {
114
+ throw new Error(`HTTP ${response.status}: 响应体为空`);
115
+ }
116
+ return response.body;
117
+ }
118
+ catch (error) {
119
+ if (isAbortError(error))
120
+ throw error;
121
+ const isRetryable = error instanceof Error && (error.message.includes('timeout') ||
122
+ error.message.includes('Retryable HTTP') ||
123
+ error.name === 'FetchError' ||
124
+ error.message.includes('ECONNRESET') ||
125
+ error.message.includes('ENOTFOUND') ||
126
+ error.message.includes('ETIMEDOUT'));
127
+ if (!isRetryable || attempt >= maxRetries) {
128
+ throw new Error(formatKieError(error));
129
+ }
130
+ attempt++;
131
+ const delayMs = Math.min(1000 * Math.pow(2, attempt - 1) + Math.random() * 500, 10000);
132
+ console.warn(chalk.yellow(`\n⚠️ 网络请求异常,等待 ${Math.round(delayMs)}ms 后进行第 ${attempt} 次重试...`));
133
+ await new Promise(resolve => setTimeout(resolve, delayMs));
134
+ }
135
+ }
136
+ }
137
+ export async function kieUploadFile(url, init) {
138
+ const fs = await import('fs');
139
+ const path = await import('path');
140
+ const resolvedPath = path.resolve(init.filePath);
141
+ if (!fs.existsSync(resolvedPath)) {
142
+ throw new Error(`文件不存在: ${init.filePath}`);
143
+ }
144
+ const fileName = path.basename(resolvedPath);
145
+ const fileBuffer = fs.readFileSync(resolvedPath);
146
+ // Guess mimeType based on extension
147
+ const ext = path.extname(fileName).toLowerCase();
148
+ let mimeType = 'application/octet-stream';
149
+ if (ext === '.png')
150
+ mimeType = 'image/png';
151
+ else if (ext === '.jpg' || ext === '.jpeg')
152
+ mimeType = 'image/jpeg';
153
+ else if (ext === '.gif')
154
+ mimeType = 'image/gif';
155
+ else if (ext === '.webp')
156
+ mimeType = 'image/webp';
157
+ else if (ext === '.svg')
158
+ mimeType = 'image/svg+xml';
159
+ else if (ext === '.bmp')
160
+ mimeType = 'image/bmp';
161
+ const formData = new FormData();
162
+ // Node 18+ Globals: Blob & FormData
163
+ const blob = new Blob([fileBuffer], { type: mimeType });
164
+ formData.append('file', blob, fileName);
165
+ formData.append('uploadPath', init.uploadPath || 'images/user-uploads');
166
+ formData.append('fileName', fileName);
167
+ try {
168
+ return await ofetch(url, {
169
+ method: 'POST',
170
+ headers: {
171
+ Authorization: `Bearer ${init.apiKey}`,
172
+ },
173
+ body: formData,
174
+ timeout: KIE_REQUEST_TIMEOUT_MS,
175
+ });
176
+ }
177
+ catch (error) {
178
+ if (isAbortError(error))
179
+ throw error;
180
+ throw new Error(formatKieError(error));
181
+ }
182
+ }