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.
@@ -0,0 +1,552 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ import chalk from 'chalk';
5
+ import { initRenderer, marked } from '../utils/renderer.js';
6
+ import { sendChatMessage } from '../api.js';
7
+ import { readMultilineFromEditor } from '../editor.js';
8
+ import { isAbortError } from '../kie-http.js';
9
+ import { formatModelLabel, printModelPickerMenu, resolveChatModel, resolveModelPickInput, setChatModel, } from '../models.js';
10
+ import { clearSession, canCompress, compressMessages, createSessionId, estimateSessionSize, getActiveSessionId, listSessions, loadSession, maybeCompressSession, printSessionList, resolveSessionPick, saveSession, setActiveSessionId, } from '../session.js';
11
+ const SLASH_ALIASES = {
12
+ '/chear': '/clear',
13
+ '/clea': '/clear',
14
+ '/cleer': '/clear',
15
+ '/cls': '/clear',
16
+ '/models': '/model',
17
+ '/compresss': '/compress',
18
+ '/h': '/help',
19
+ '/?': '/help',
20
+ '/session': '/sessions',
21
+ };
22
+ function normalizeSlashInput(input) {
23
+ const space = input.indexOf(' ');
24
+ const cmd = (space === -1 ? input : input.slice(0, space)).toLowerCase();
25
+ const alias = SLASH_ALIASES[cmd];
26
+ if (!alias)
27
+ return input;
28
+ return alias + (space === -1 ? '' : input.slice(space));
29
+ }
30
+ function printReplHelp() {
31
+ console.log(chalk.cyan('\n可用指令:\n'));
32
+ const rows = [
33
+ ['/help', '显示此帮助'],
34
+ ['/new', '新建会话(保存当前会话并切换到新 ID)'],
35
+ ['/sessions [id|序号]', '列出或切换已保存会话'],
36
+ ['/model [id|序号]', '切换对话模型'],
37
+ ['/clear', '清空当前会话上下文'],
38
+ ['/compress', '手动压缩对话历史'],
39
+ ['/file <路径>', '读取文件加入上下文'],
40
+ ['/run <命令>', '执行命令并将输出加入上下文'],
41
+ ['/edit', '用编辑器编写多行消息(EDITOR 环境变量)'],
42
+ ['行末 \\', '多行输入:续行,空行发送'],
43
+ ['Ctrl+C', '取消进行中的 AI 请求;空闲时取消当前输入'],
44
+ ['exit / quit', '退出并保存会话'],
45
+ ];
46
+ for (const [cmd, desc] of rows) {
47
+ console.log(` ${chalk.white(cmd.padEnd(22))} ${chalk.gray(desc)}`);
48
+ }
49
+ console.log();
50
+ }
51
+ export async function chatAction(message, options, config, readStdin) {
52
+ // Initialize renderer only when needed
53
+ initRenderer();
54
+ const ora = (await import('ora')).default;
55
+ const apiKey = config.get('apiKey');
56
+ const baseUrl = config.get('baseUrl') || 'https://api.kie.ai';
57
+ if (!apiKey) {
58
+ console.log(chalk.red('❌ 缺少必要配置,请先运行以下命令配置 API Key:'));
59
+ console.log(chalk.yellow('kie config --set-api-key <KEY>'));
60
+ process.exit(1);
61
+ }
62
+ let sessionModel = resolveChatModel(config, { cli: options.model });
63
+ const pipedInput = await readStdin();
64
+ const isInteractive = !message && !pipedInput;
65
+ let sessionId = getActiveSessionId(config);
66
+ if (options.new) {
67
+ sessionId = createSessionId();
68
+ }
69
+ else if (options.resume !== undefined) {
70
+ sessionId =
71
+ typeof options.resume === 'string' && options.resume.length > 0
72
+ ? options.resume
73
+ : getActiveSessionId(config);
74
+ }
75
+ setActiveSessionId(config, sessionId);
76
+ const messagesHistory = isInteractive
77
+ ? options.new
78
+ ? []
79
+ : loadSession(config, sessionId)
80
+ : [];
81
+ const persistSession = () => {
82
+ if (isInteractive) {
83
+ saveSession(config, sessionId, messagesHistory);
84
+ }
85
+ };
86
+ const applyCompressed = (compressed) => {
87
+ const next = compressed.map((m) => ({ ...m }));
88
+ messagesHistory.length = 0;
89
+ messagesHistory.push(...next);
90
+ };
91
+ const persistAndMaybeCompress = async (quiet = false) => {
92
+ persistSession();
93
+ if (!isInteractive)
94
+ return;
95
+ const beforeSize = estimateSessionSize(messagesHistory);
96
+ const maxChars = config.get('sessionMaxChars') || 16_000;
97
+ if (beforeSize <= maxChars)
98
+ return;
99
+ const spinner = quiet ? null : ora('会话较长,正在自动压缩历史...').start();
100
+ try {
101
+ const compressed = await maybeCompressSession(messagesHistory, {
102
+ apiKey,
103
+ baseUrl,
104
+ model: sessionModel,
105
+ config,
106
+ });
107
+ applyCompressed(compressed);
108
+ persistSession();
109
+ spinner?.succeed(chalk.green(`会话已压缩:${beforeSize} → ${estimateSessionSize(messagesHistory)} 字符`));
110
+ }
111
+ catch (err) {
112
+ spinner?.fail(chalk.yellow(`会话压缩失败: ${err.message}`));
113
+ }
114
+ };
115
+ let chatInFlight = false;
116
+ const doChat = async (userText, opts) => {
117
+ if (chatInFlight)
118
+ return;
119
+ chatInFlight = true;
120
+ const userMsg = { role: 'user', content: userText };
121
+ messagesHistory.push(userMsg);
122
+ const spinner = ora({
123
+ text: `AI 思考中 (${sessionModel})...`,
124
+ discardStdin: true,
125
+ }).start();
126
+ let fullResponse = '';
127
+ let gotChunk = false;
128
+ let wroteHeader = false;
129
+ try {
130
+ await sendChatMessage({
131
+ apiKey,
132
+ baseUrl,
133
+ model: sessionModel,
134
+ messages: messagesHistory,
135
+ signal: opts?.signal,
136
+ onChunk: (text) => {
137
+ if (opts?.signal?.aborted)
138
+ return;
139
+ if (!text)
140
+ return;
141
+ gotChunk = true;
142
+ if (!wroteHeader) {
143
+ spinner.stop();
144
+ process.stdout.write(chalk.green.bold('\nKie AI: '));
145
+ wroteHeader = true;
146
+ }
147
+ process.stdout.write(chalk.white(text));
148
+ fullResponse += text;
149
+ },
150
+ });
151
+ if (!wroteHeader) {
152
+ spinner.stop();
153
+ if (!gotChunk) {
154
+ console.log(chalk.yellow('\n(模型未返回文本内容)\n'));
155
+ }
156
+ }
157
+ else {
158
+ console.log('\n');
159
+ }
160
+ messagesHistory.push({ role: 'assistant', content: fullResponse });
161
+ if (isInteractive) {
162
+ await persistAndMaybeCompress(true);
163
+ }
164
+ if (!isInteractive) {
165
+ console.log(chalk.gray('\n====== 格式化结果 ======'));
166
+ console.log(marked.parse(fullResponse || '(空)'));
167
+ }
168
+ }
169
+ catch (error) {
170
+ if (isAbortError(error)) {
171
+ spinner.stop();
172
+ const last = messagesHistory[messagesHistory.length - 1];
173
+ if (gotChunk && fullResponse.trim()) {
174
+ messagesHistory.push({ role: 'assistant', content: fullResponse });
175
+ if (isInteractive)
176
+ persistSession();
177
+ console.log(chalk.yellow('\n\n(已取消,已保留部分回复)\n'));
178
+ }
179
+ else if (last === userMsg) {
180
+ messagesHistory.pop();
181
+ console.log(chalk.yellow('\n(已取消)\n'));
182
+ }
183
+ else {
184
+ console.log(chalk.yellow('\n(已取消)\n'));
185
+ }
186
+ return;
187
+ }
188
+ const last = messagesHistory[messagesHistory.length - 1];
189
+ if (last === userMsg) {
190
+ messagesHistory.pop();
191
+ }
192
+ spinner.fail(chalk.red('请求失败'));
193
+ const msg = error instanceof Error ? error.message : '未知错误';
194
+ console.error(chalk.red(`\n❌ ${msg}\n`));
195
+ if (!isInteractive)
196
+ process.exit(1);
197
+ }
198
+ finally {
199
+ spinner.stop();
200
+ chatInFlight = false;
201
+ }
202
+ };
203
+ if (!isInteractive) {
204
+ let finalMessage = '';
205
+ if (pipedInput && message)
206
+ finalMessage = `${pipedInput}\n\n${message}`;
207
+ else if (pipedInput)
208
+ finalMessage = pipedInput;
209
+ else if (message)
210
+ finalMessage = message;
211
+ await doChat(finalMessage);
212
+ return;
213
+ }
214
+ console.log(chalk.cyan('=================================================='));
215
+ console.log(chalk.cyan(' 🚀 欢迎进入 Kie 沉浸式对话模式!(输入 exit 退出) '));
216
+ console.log(chalk.cyan('=================================================='));
217
+ console.log(chalk.gray(`会话 ID: ${sessionId}`));
218
+ console.log(chalk.gray(`对话模型: ${formatModelLabel(sessionModel)}`));
219
+ if (messagesHistory.length > 0) {
220
+ console.log(chalk.gray(`已恢复 ${messagesHistory.length} 条历史消息(约 ${estimateSessionSize(messagesHistory)} 字符)`));
221
+ }
222
+ console.log(chalk.gray('指令: /help 查看全部 | /new 新会话 | /sessions 切换 | 行末 \\ 多行 | Ctrl+C 取消\n'));
223
+ return new Promise((resolve) => {
224
+ let exiting = false;
225
+ let isProcessing = false;
226
+ let replMode = 'chat';
227
+ let multilineBuffer = [];
228
+ let activeAbort = null;
229
+ let lastIdleSigintAt = 0;
230
+ const PROMPT_CHAT = '(You) > ';
231
+ const PROMPT_PICK = '序号 > ';
232
+ const PROMPT_SESSION = '会话序号 > ';
233
+ const PROMPT_MULTILINE = '... > ';
234
+ const rl = readline.createInterface({
235
+ input: process.stdin,
236
+ output: process.stdout,
237
+ terminal: true,
238
+ prompt: PROMPT_CHAT,
239
+ });
240
+ const showPrompt = () => {
241
+ rl.prompt();
242
+ };
243
+ const resetReplInputMode = () => {
244
+ replMode = 'chat';
245
+ multilineBuffer = [];
246
+ rl.setPrompt(PROMPT_CHAT);
247
+ };
248
+ const switchToSession = (targetId) => {
249
+ persistSession();
250
+ sessionId = targetId;
251
+ setActiveSessionId(config, sessionId);
252
+ messagesHistory.length = 0;
253
+ messagesHistory.push(...loadSession(config, sessionId));
254
+ console.log(chalk.green(`✅ 已切换到会话: ${sessionId}(${messagesHistory.length} 条消息,约 ${estimateSessionSize(messagesHistory)} 字符)\n`));
255
+ };
256
+ const startNewSession = () => {
257
+ persistSession();
258
+ switchToSession(createSessionId());
259
+ };
260
+ const submitChatText = async (text) => {
261
+ if (!acquireProcessing()) {
262
+ showPrompt();
263
+ return;
264
+ }
265
+ const ac = new AbortController();
266
+ activeAbort = ac;
267
+ try {
268
+ await doChat(text, { signal: ac.signal });
269
+ }
270
+ finally {
271
+ activeAbort = null;
272
+ isProcessing = false;
273
+ showPrompt();
274
+ }
275
+ };
276
+ rl.on('close', () => {
277
+ if (exiting)
278
+ resolve();
279
+ });
280
+ rl.on('SIGINT', () => {
281
+ if (activeAbort) {
282
+ activeAbort.abort();
283
+ return;
284
+ }
285
+ if (replMode !== 'chat') {
286
+ resetReplInputMode();
287
+ console.log(chalk.gray('\n已取消当前输入。\n'));
288
+ showPrompt();
289
+ return;
290
+ }
291
+ const now = Date.now();
292
+ if (now - lastIdleSigintAt < 1500) {
293
+ exiting = true;
294
+ persistSession();
295
+ console.log(chalk.gray(`\n👋 再见!会话已保存 (${sessionId})`));
296
+ rl.close();
297
+ return;
298
+ }
299
+ lastIdleSigintAt = now;
300
+ console.log(chalk.gray('\n再按一次 Ctrl+C 退出,或输入 exit\n'));
301
+ showPrompt();
302
+ });
303
+ const acquireProcessing = () => {
304
+ if (isProcessing)
305
+ return false;
306
+ isProcessing = true;
307
+ return true;
308
+ };
309
+ rl.on('line', async (line) => {
310
+ const input = normalizeSlashInput(line.trim());
311
+ if (isProcessing) {
312
+ if (input) {
313
+ console.log(chalk.gray('\n请等待当前操作完成(Ctrl+C 可取消)...\n'));
314
+ }
315
+ showPrompt();
316
+ return;
317
+ }
318
+ if (replMode === 'multiline') {
319
+ if (!input) {
320
+ const text = multilineBuffer.join('\n').trim();
321
+ resetReplInputMode();
322
+ if (!text) {
323
+ console.log(chalk.gray('已取消多行输入。\n'));
324
+ showPrompt();
325
+ return;
326
+ }
327
+ await submitChatText(text);
328
+ return;
329
+ }
330
+ if (input.endsWith('\\') && input.length > 1) {
331
+ multilineBuffer.push(input.slice(0, -1));
332
+ }
333
+ else {
334
+ multilineBuffer.push(input);
335
+ }
336
+ showPrompt();
337
+ return;
338
+ }
339
+ if (replMode === 'pick_session') {
340
+ replMode = 'chat';
341
+ rl.setPrompt(PROMPT_CHAT);
342
+ const sessions = listSessions(config);
343
+ const picked = resolveSessionPick(input, sessions);
344
+ if (!picked) {
345
+ console.log(chalk.gray('已取消。\n'));
346
+ }
347
+ else if (picked === sessionId) {
348
+ console.log(chalk.gray('已在当前会话。\n'));
349
+ }
350
+ else {
351
+ switchToSession(picked);
352
+ }
353
+ showPrompt();
354
+ return;
355
+ }
356
+ if (replMode === 'pick_model') {
357
+ replMode = 'chat';
358
+ rl.setPrompt(PROMPT_CHAT);
359
+ const picked = resolveModelPickInput(input);
360
+ if (!picked) {
361
+ console.log(chalk.gray('已取消。\n'));
362
+ }
363
+ else {
364
+ sessionModel = picked;
365
+ setChatModel(config, picked);
366
+ console.log(chalk.green(`✅ 已切换模型: ${formatModelLabel(picked)}\n`));
367
+ }
368
+ showPrompt();
369
+ return;
370
+ }
371
+ if (!input) {
372
+ showPrompt();
373
+ return;
374
+ }
375
+ if (input.toLowerCase() === 'exit' || input.toLowerCase() === 'quit') {
376
+ exiting = true;
377
+ persistSession();
378
+ console.log(chalk.gray(`👋 再见!会话已保存 (${sessionId})`));
379
+ rl.close();
380
+ return;
381
+ }
382
+ if (input.endsWith('\\') && input.length > 1) {
383
+ multilineBuffer = [input.slice(0, -1)];
384
+ replMode = 'multiline';
385
+ rl.setPrompt(PROMPT_MULTILINE);
386
+ console.log(chalk.gray('多行模式:继续输入,空行发送,Ctrl+C 取消\n'));
387
+ showPrompt();
388
+ return;
389
+ }
390
+ if (input === '/help') {
391
+ printReplHelp();
392
+ showPrompt();
393
+ return;
394
+ }
395
+ if (input === '/new') {
396
+ startNewSession();
397
+ showPrompt();
398
+ return;
399
+ }
400
+ if (input === '/edit') {
401
+ const text = readMultilineFromEditor();
402
+ if (!text) {
403
+ console.log(chalk.gray('已取消编辑。\n'));
404
+ showPrompt();
405
+ return;
406
+ }
407
+ await submitChatText(text);
408
+ return;
409
+ }
410
+ if (input === '/sessions' || input.startsWith('/sessions ')) {
411
+ const arg = input.slice(9).trim();
412
+ const sessions = listSessions(config);
413
+ if (!arg) {
414
+ printSessionList(sessions, sessionId);
415
+ if (sessions.length > 0) {
416
+ replMode = 'pick_session';
417
+ rl.setPrompt(PROMPT_SESSION);
418
+ }
419
+ showPrompt();
420
+ return;
421
+ }
422
+ const picked = resolveSessionPick(arg, sessions);
423
+ if (!picked) {
424
+ console.log(chalk.red(`❌ 未找到会话: ${arg}\n`));
425
+ }
426
+ else if (picked === sessionId) {
427
+ console.log(chalk.gray('已在当前会话。\n'));
428
+ }
429
+ else {
430
+ switchToSession(picked);
431
+ }
432
+ showPrompt();
433
+ return;
434
+ }
435
+ if (input === '/model' || input.startsWith('/model ')) {
436
+ const arg = input.slice(6).trim();
437
+ if (!arg) {
438
+ printModelPickerMenu(sessionModel);
439
+ replMode = 'pick_model';
440
+ rl.setPrompt(PROMPT_PICK);
441
+ showPrompt();
442
+ return;
443
+ }
444
+ sessionModel = arg;
445
+ setChatModel(config, arg);
446
+ console.log(chalk.green(`✅ 已切换模型: ${formatModelLabel(arg)}\n`));
447
+ showPrompt();
448
+ return;
449
+ }
450
+ if (input === '/clear') {
451
+ messagesHistory.length = 0;
452
+ clearSession(config, sessionId);
453
+ console.log(chalk.green('✅ 当前会话上下文已清空。\n'));
454
+ showPrompt();
455
+ return;
456
+ }
457
+ if (input === '/compress') {
458
+ if (!acquireProcessing()) {
459
+ showPrompt();
460
+ return;
461
+ }
462
+ const ac = new AbortController();
463
+ activeAbort = ac;
464
+ try {
465
+ if (messagesHistory.length === 0) {
466
+ console.log(chalk.yellow('⚠️ 当前没有可压缩的对话历史。\n'));
467
+ }
468
+ else if (!canCompress(messagesHistory, true)) {
469
+ console.log(chalk.yellow('⚠️ 对话太短(不足 2 轮),无需压缩。\n'));
470
+ }
471
+ else {
472
+ const spinner = ora('正在压缩会话...').start();
473
+ try {
474
+ const beforeCount = messagesHistory.length;
475
+ const beforeSize = estimateSessionSize(messagesHistory);
476
+ const compressed = await compressMessages(messagesHistory, {
477
+ apiKey,
478
+ baseUrl,
479
+ model: sessionModel,
480
+ signal: ac.signal,
481
+ force: true,
482
+ maxChars: config.get('sessionMaxChars') || 16_000,
483
+ keepRecentPairs: config.get('sessionKeepRecent') || 6,
484
+ });
485
+ applyCompressed(compressed);
486
+ persistSession();
487
+ spinner.succeed(chalk.green(`压缩完成:${beforeCount} → ${messagesHistory.length} 条消息,${beforeSize} → ${estimateSessionSize(messagesHistory)} 字符`));
488
+ }
489
+ catch (err) {
490
+ if (isAbortError(err)) {
491
+ spinner.fail(chalk.yellow('压缩已取消'));
492
+ }
493
+ else {
494
+ const msg = err instanceof Error ? err.message : String(err);
495
+ spinner.fail(chalk.red(`压缩失败: ${msg}`));
496
+ }
497
+ }
498
+ }
499
+ }
500
+ finally {
501
+ activeAbort = null;
502
+ isProcessing = false;
503
+ console.log();
504
+ showPrompt();
505
+ }
506
+ return;
507
+ }
508
+ if (input.startsWith('/file ')) {
509
+ let filePath = input.slice(6).trim();
510
+ if (filePath.startsWith('"') && filePath.endsWith('"')) {
511
+ filePath = filePath.slice(1, -1);
512
+ }
513
+ else if (filePath.startsWith("'") && filePath.endsWith("'")) {
514
+ filePath = filePath.slice(1, -1);
515
+ }
516
+ try {
517
+ const content = fs.readFileSync(path.resolve(process.cwd(), filePath), 'utf-8');
518
+ messagesHistory.push({
519
+ role: 'user',
520
+ content: `[System] The user has provided the contents of the file '${filePath}':\n\n${content}`,
521
+ });
522
+ console.log(chalk.green(`✅ 已读取文件 ${filePath} 并加入记忆上下文。你可以针对它进行提问了。\n`));
523
+ persistSession();
524
+ }
525
+ catch (err) {
526
+ console.log(chalk.red(`❌ 读取文件失败: ${err.message}\n`));
527
+ }
528
+ showPrompt();
529
+ return;
530
+ }
531
+ if (input.startsWith('/run ')) {
532
+ const cmd = input.slice(5).trim();
533
+ try {
534
+ const { execSync } = await import('child_process');
535
+ const output = execSync(cmd, { encoding: 'utf-8' });
536
+ messagesHistory.push({
537
+ role: 'user',
538
+ content: `[System] The user executed the command '${cmd}' and the output was:\n\n${output}`,
539
+ });
540
+ console.log(chalk.green(`✅ 已执行命令并将其输出加入记忆上下文。\n`));
541
+ persistSession();
542
+ }
543
+ catch (err) {
544
+ console.log(chalk.red(`❌ 执行命令失败: ${err.message}\n`));
545
+ }
546
+ showPrompt();
547
+ return;
548
+ }
549
+ await submitChatText(line);
550
+ });
551
+ });
552
+ }