byuckchon-frontend-cli 1.9.0 → 1.9.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/README.md +6 -0
- package/package.json +1 -1
- package/src/commands/chat.js +87 -21
- package/src/history/management.js +30 -0
- package/src/history/store.js +33 -5
- package/src/ui/ChatApp.js +176 -6
package/README.md
CHANGED
|
@@ -284,6 +284,8 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
|
|
|
284
284
|
| ------------------- | ------------------------------------------ |
|
|
285
285
|
| `/help` | 도움말 |
|
|
286
286
|
| `/clear` | 대화 컨텍스트 초기화 |
|
|
287
|
+
| `/history` | 이전 대화 선택 후 해당 컨텍스트 이어가기 |
|
|
288
|
+
| `/retry` | 마지막 사용자 요청 다시 실행 |
|
|
287
289
|
| `/model [id]` | 세션 모델 변경 (인자 없으면 목록) |
|
|
288
290
|
| `/cost` | 누적 토큰/비용 |
|
|
289
291
|
| `/image <path>` | 다음 메시지에 이미지 첨부 (Vision 모델 권장) |
|
|
@@ -294,6 +296,10 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
|
|
|
294
296
|
| `/rag on\|off` | RAG 컨텍스트 주입 즉석 토글 |
|
|
295
297
|
| `/exit` | 종료 (`Ctrl+C` 도 가능) |
|
|
296
298
|
|
|
299
|
+
Ink 모드에서 `/history`를 실행하면 `↑↓`로 세션을 선택하고 `Enter`로 불러올 수 있습니다.
|
|
300
|
+
선택한 세션에서 `d`를 누른 뒤 `y`로 확인하면 해당 기록을 삭제합니다. 메시지를 한 번도
|
|
301
|
+
보내지 않고 종료한 빈 세션은 저장되거나 목록에 표시되지 않습니다.
|
|
302
|
+
|
|
297
303
|
이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
|
|
298
304
|
Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
|
|
299
305
|
|
package/package.json
CHANGED
package/src/commands/chat.js
CHANGED
|
@@ -17,10 +17,12 @@ import { printInlineThumbnail } from '../ai/imagePreview.js';
|
|
|
17
17
|
import {
|
|
18
18
|
createSession,
|
|
19
19
|
saveSession,
|
|
20
|
+
deleteSession,
|
|
20
21
|
listSessions,
|
|
21
22
|
loadSession,
|
|
22
23
|
loadLatestSession,
|
|
23
24
|
} from '../history/store.js';
|
|
25
|
+
import { findLastRetryableUser, formatSessionList } from '../history/management.js';
|
|
24
26
|
import { getCachedOpenApi } from '../openapi/cache.js';
|
|
25
27
|
import { summarizeOpenApi } from '../openapi/summary.js';
|
|
26
28
|
|
|
@@ -162,8 +164,6 @@ export async function chatCommand(opts = {}) {
|
|
|
162
164
|
// 모델은 사용자가 명시했거나 글로벌 설정으로 갱신 가능 — 세션의 model 은 표시용.
|
|
163
165
|
session.model = resolved.meta.id;
|
|
164
166
|
}
|
|
165
|
-
await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
|
|
166
|
-
|
|
167
167
|
// ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
|
|
168
168
|
// - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
|
|
169
169
|
// - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
|
|
@@ -206,9 +206,36 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
|
|
|
206
206
|
|
|
207
207
|
const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
|
|
208
208
|
|
|
209
|
+
let activeSession = session;
|
|
210
|
+
|
|
209
211
|
const onSessionUpdate = async (messages) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
const target = activeSession;
|
|
213
|
+
const hasConversation = messages.some(
|
|
214
|
+
(message) =>
|
|
215
|
+
(message.role === 'user' || message.role === 'assistant') &&
|
|
216
|
+
typeof message.text === 'string' &&
|
|
217
|
+
message.text.trim(),
|
|
218
|
+
);
|
|
219
|
+
if (!hasConversation && !target._persisted) return;
|
|
220
|
+
target.messages = messages;
|
|
221
|
+
await saveSession(target);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const onSessionSwitch = async (id) => {
|
|
225
|
+
const loaded = await loadSession(id);
|
|
226
|
+
loaded.model = resolved.meta.id;
|
|
227
|
+
activeSession = loaded;
|
|
228
|
+
return loaded;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const onSessionDelete = async (id) => {
|
|
232
|
+
const deletingActive = activeSession.id === id;
|
|
233
|
+
const result = await deleteSession(id);
|
|
234
|
+
if (!deletingActive) return { ...result, replacementSession: null };
|
|
235
|
+
|
|
236
|
+
const replacementSession = await createSession({ model: resolved.meta.id });
|
|
237
|
+
activeSession = replacementSession;
|
|
238
|
+
return { ...result, replacementSession };
|
|
212
239
|
};
|
|
213
240
|
|
|
214
241
|
const { waitUntilExit } = render(
|
|
@@ -217,6 +244,8 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
|
|
|
217
244
|
initialResolved: resolved,
|
|
218
245
|
session,
|
|
219
246
|
onSessionUpdate,
|
|
247
|
+
onSessionSwitch,
|
|
248
|
+
onSessionDelete,
|
|
220
249
|
}),
|
|
221
250
|
{ exitOnCtrlC: false },
|
|
222
251
|
);
|
|
@@ -291,7 +320,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
291
320
|
console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
|
|
292
321
|
}
|
|
293
322
|
console.log(
|
|
294
|
-
chalk.dim(
|
|
323
|
+
chalk.dim(
|
|
324
|
+
' /history · /retry · /image <경로> · /paste · /clear-attach · /exit\n',
|
|
325
|
+
),
|
|
295
326
|
);
|
|
296
327
|
|
|
297
328
|
// 다음 메시지에 함께 보낼 이미지 첨부 목록.
|
|
@@ -341,6 +372,21 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
341
372
|
const history = (session?.messages ?? [])
|
|
342
373
|
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
343
374
|
.map((m) => ({ role: m.role, content: m.text ?? '' }));
|
|
375
|
+
const persistHistory = async () => {
|
|
376
|
+
if (!session) return;
|
|
377
|
+
session.messages = history.map((message) => {
|
|
378
|
+
// 이미지 바이트는 세션 파일에 저장하지 않고 사용자 텍스트만 보존한다.
|
|
379
|
+
if (Array.isArray(message.content)) {
|
|
380
|
+
const textPart = message.content.find((part) => part.type === 'text');
|
|
381
|
+
return {
|
|
382
|
+
role: message.role,
|
|
383
|
+
text: textPart?.text ?? '[이미지 첨부]',
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return { role: message.role, text: message.content };
|
|
387
|
+
});
|
|
388
|
+
await saveSession(session);
|
|
389
|
+
};
|
|
344
390
|
const ask = () => rl.prompt();
|
|
345
391
|
|
|
346
392
|
rl.on('close', () => {
|
|
@@ -359,6 +405,29 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
359
405
|
rl.close();
|
|
360
406
|
return;
|
|
361
407
|
}
|
|
408
|
+
if (line === '/history') {
|
|
409
|
+
try {
|
|
410
|
+
const sessions = await listSessions();
|
|
411
|
+
console.log(chalk.dim('\n' + formatSessionList(sessions)));
|
|
412
|
+
console.log(chalk.dim('\n 이어가기: bc chat --resume <id>'));
|
|
413
|
+
} catch (err) {
|
|
414
|
+
console.log(chalk.red(' 대화 목록을 불러올 수 없습니다: ' + err.message));
|
|
415
|
+
}
|
|
416
|
+
ask();
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
let retryContent = null;
|
|
420
|
+
if (line === '/retry') {
|
|
421
|
+
const retry = findLastRetryableUser(history);
|
|
422
|
+
if (!retry) {
|
|
423
|
+
console.log(chalk.red(' 다시 실행할 사용자 요청이 없습니다.'));
|
|
424
|
+
ask();
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
retryContent = retry.message.content;
|
|
428
|
+
history.splice(retry.index);
|
|
429
|
+
console.log(chalk.dim(' 마지막 요청을 다시 실행합니다.'));
|
|
430
|
+
}
|
|
362
431
|
// 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
|
|
363
432
|
if (line.startsWith('/image')) {
|
|
364
433
|
await addImage(line.slice('/image'.length).trim());
|
|
@@ -388,7 +457,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
388
457
|
}
|
|
389
458
|
|
|
390
459
|
// 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
|
|
391
|
-
if (
|
|
460
|
+
if (retryContent != null) {
|
|
461
|
+
history.push({ role: 'user', content: retryContent });
|
|
462
|
+
} else if (pendingAttachments.length) {
|
|
392
463
|
const parts = [{ type: 'text', text: line }];
|
|
393
464
|
try {
|
|
394
465
|
for (const att of pendingAttachments) {
|
|
@@ -403,6 +474,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
403
474
|
} else {
|
|
404
475
|
history.push({ role: 'user', content: line });
|
|
405
476
|
}
|
|
477
|
+
try {
|
|
478
|
+
await persistHistory();
|
|
479
|
+
} catch {
|
|
480
|
+
/* 저장 실패가 AI 요청을 막지는 않게 한다. */
|
|
481
|
+
}
|
|
406
482
|
rl.pause();
|
|
407
483
|
|
|
408
484
|
const projectRoot = cfg.paths.projectFile
|
|
@@ -457,21 +533,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
457
533
|
}
|
|
458
534
|
if (acc) history.push({ role: 'assistant', content: acc });
|
|
459
535
|
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const textPart = h.content.find((p) => p.type === 'text');
|
|
466
|
-
return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
|
|
467
|
-
}
|
|
468
|
-
return { role: h.role, text: h.content };
|
|
469
|
-
});
|
|
470
|
-
try {
|
|
471
|
-
await saveSession(session);
|
|
472
|
-
} catch {
|
|
473
|
-
/* noop */
|
|
474
|
-
}
|
|
536
|
+
// 응답까지 포함한 최신 상태로 다시 저장한다.
|
|
537
|
+
try {
|
|
538
|
+
await persistHistory();
|
|
539
|
+
} catch {
|
|
540
|
+
/* noop */
|
|
475
541
|
}
|
|
476
542
|
|
|
477
543
|
rl.resume();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function messageText(message) {
|
|
2
|
+
if (typeof message?.text === 'string') return message.text;
|
|
3
|
+
if (typeof message?.content === 'string') return message.content;
|
|
4
|
+
if (Array.isArray(message?.content)) {
|
|
5
|
+
return message.content.find((part) => part.type === 'text')?.text ?? '[이미지 첨부]';
|
|
6
|
+
}
|
|
7
|
+
return '';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function findLastRetryableUser(messages) {
|
|
11
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
12
|
+
const message = messages[index];
|
|
13
|
+
if (message.role === 'user' && messageText(message).trim()) {
|
|
14
|
+
return { index, message, text: messageText(message) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatSessionList(sessions, { limit = 10 } = {}) {
|
|
21
|
+
if (!sessions.length) return '저장된 이전 세션이 없습니다.';
|
|
22
|
+
return sessions
|
|
23
|
+
.slice(0, limit)
|
|
24
|
+
.map((session) => {
|
|
25
|
+
const when = session.updatedAt?.replace('T', ' ').slice(0, 19) ?? '';
|
|
26
|
+
const preview = session.preview || '(빈 세션)';
|
|
27
|
+
return `${session.id} ${when} ${session.turns} turns\n ${preview}`;
|
|
28
|
+
})
|
|
29
|
+
.join('\n');
|
|
30
|
+
}
|
package/src/history/store.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function createSession({ model, cwd = process.cwd() } = {}) {
|
|
|
49
49
|
cwd,
|
|
50
50
|
messages: [],
|
|
51
51
|
};
|
|
52
|
-
return { ...session, _file: path.join(dir, `${id}.json`) };
|
|
52
|
+
return { ...session, _file: path.join(dir, `${id}.json`), _persisted: false };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export async function saveSession(session) {
|
|
@@ -64,6 +64,7 @@ export async function saveSession(session) {
|
|
|
64
64
|
messages: session.messages,
|
|
65
65
|
};
|
|
66
66
|
await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
67
|
+
session._persisted = true;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
|
|
@@ -77,19 +78,27 @@ export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
|
|
|
77
78
|
}
|
|
78
79
|
const files = entries.filter((f) => f.endsWith('.json')).sort().reverse();
|
|
79
80
|
const out = [];
|
|
80
|
-
for (const name of files
|
|
81
|
+
for (const name of files) {
|
|
82
|
+
if (out.length >= limit) break;
|
|
81
83
|
const file = path.join(dir, name);
|
|
82
84
|
try {
|
|
83
85
|
const raw = await fs.readFile(file, 'utf8');
|
|
84
86
|
const data = JSON.parse(raw);
|
|
85
|
-
const
|
|
87
|
+
const conversation = (data.messages ?? []).filter(
|
|
88
|
+
(message) =>
|
|
89
|
+
(message.role === 'user' || message.role === 'assistant') &&
|
|
90
|
+
typeof message.text === 'string' &&
|
|
91
|
+
message.text.trim(),
|
|
92
|
+
);
|
|
93
|
+
if (conversation.length === 0) continue;
|
|
94
|
+
const firstUser = conversation.find((message) => message.role === 'user');
|
|
86
95
|
out.push({
|
|
87
96
|
id: data.id,
|
|
88
97
|
file,
|
|
89
98
|
startedAt: data.startedAt,
|
|
90
99
|
updatedAt: data.updatedAt,
|
|
91
100
|
model: data.model,
|
|
92
|
-
turns:
|
|
101
|
+
turns: conversation.length,
|
|
93
102
|
preview: firstUser?.text?.slice(0, 60) ?? '(빈 세션)',
|
|
94
103
|
});
|
|
95
104
|
} catch {
|
|
@@ -107,7 +116,7 @@ export async function loadSession(idOrFile, cwd = process.cwd()) {
|
|
|
107
116
|
}
|
|
108
117
|
const raw = await fs.readFile(file, 'utf8');
|
|
109
118
|
const data = JSON.parse(raw);
|
|
110
|
-
return { ...data, _file: file };
|
|
119
|
+
return { ...data, _file: file, _persisted: true };
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
export async function loadLatestSession(cwd = process.cwd()) {
|
|
@@ -115,3 +124,22 @@ export async function loadLatestSession(cwd = process.cwd()) {
|
|
|
115
124
|
if (list.length === 0) return null;
|
|
116
125
|
return loadSession(list[0].id, cwd);
|
|
117
126
|
}
|
|
127
|
+
|
|
128
|
+
export async function deleteSession(id, cwd = process.cwd()) {
|
|
129
|
+
const normalized = String(id ?? '').replace(/\.json$/, '');
|
|
130
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
|
|
131
|
+
const error = new Error(`잘못된 세션 ID: ${id}`);
|
|
132
|
+
error.code = 'BC_INVALID_SESSION_ID';
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const dir = await getHistoryDir(cwd);
|
|
137
|
+
const file = path.join(dir, `${normalized}.json`);
|
|
138
|
+
try {
|
|
139
|
+
await fs.unlink(file);
|
|
140
|
+
return { id: normalized, file, deleted: true };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code === 'ENOENT') return { id: normalized, file, deleted: false };
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/ui/ChatApp.js
CHANGED
|
@@ -14,6 +14,8 @@ import { toSdkMessages, isImagePath } from '../ai/messageContent.js';
|
|
|
14
14
|
import { buildTools } from '../ai/tools.js';
|
|
15
15
|
import { searchIndex } from '../indexer/search.js';
|
|
16
16
|
import { loadIndex, buildIndex } from '../indexer/store.js';
|
|
17
|
+
import { listSessions } from '../history/store.js';
|
|
18
|
+
import { findLastRetryableUser } from '../history/management.js';
|
|
17
19
|
|
|
18
20
|
const h = React.createElement;
|
|
19
21
|
|
|
@@ -287,6 +289,8 @@ function AttachBar({ pending }) {
|
|
|
287
289
|
const SLASH_COMMANDS = [
|
|
288
290
|
{ cmd: '/help', hint: '', desc: '명령 도움말' },
|
|
289
291
|
{ cmd: '/clear', hint: '', desc: '대화 컨텍스트 비우기' },
|
|
292
|
+
{ cmd: '/history', hint: '', desc: '프로젝트의 이전 대화 목록' },
|
|
293
|
+
{ cmd: '/retry', hint: '', desc: '마지막 사용자 요청 다시 실행' },
|
|
290
294
|
{ cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
|
|
291
295
|
{ cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
|
|
292
296
|
{ cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
|
|
@@ -350,7 +354,45 @@ function SlashMenu({ items, activeIndex }) {
|
|
|
350
354
|
);
|
|
351
355
|
}
|
|
352
356
|
|
|
353
|
-
|
|
357
|
+
function HistoryMenu({ items, activeIndex, activeSessionId, deleteId }) {
|
|
358
|
+
if (items.length === 0) return null;
|
|
359
|
+
return h(
|
|
360
|
+
Box,
|
|
361
|
+
{
|
|
362
|
+
flexDirection: 'column',
|
|
363
|
+
borderStyle: 'single',
|
|
364
|
+
borderColor: 'gray',
|
|
365
|
+
paddingX: 1,
|
|
366
|
+
},
|
|
367
|
+
h(Text, { bold: true }, '이전 대화'),
|
|
368
|
+
...items.map((item, index) => {
|
|
369
|
+
const active = index === activeIndex;
|
|
370
|
+
const current = item.id === activeSessionId;
|
|
371
|
+
const when = item.updatedAt?.replace('T', ' ').slice(0, 16) ?? '';
|
|
372
|
+
return h(
|
|
373
|
+
Text,
|
|
374
|
+
{
|
|
375
|
+
key: item.id,
|
|
376
|
+
color: item.id === deleteId ? 'red' : active ? 'cyan' : undefined,
|
|
377
|
+
bold: active,
|
|
378
|
+
},
|
|
379
|
+
`${active ? '›' : ' '} ${item.id}${current ? ' (현재)' : ''} ${when} ${item.turns} turns\n ${item.preview}`,
|
|
380
|
+
);
|
|
381
|
+
}),
|
|
382
|
+
deleteId
|
|
383
|
+
? h(Text, { color: 'red' }, `${deleteId} 세션을 삭제할까요? y/n`)
|
|
384
|
+
: h(Text, { dimColor: true }, '↑↓ 선택 · Enter 불러오기 · d 삭제 · Esc 취소'),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function ChatApp({
|
|
389
|
+
initialConfig,
|
|
390
|
+
initialResolved,
|
|
391
|
+
session,
|
|
392
|
+
onSessionUpdate,
|
|
393
|
+
onSessionSwitch,
|
|
394
|
+
onSessionDelete,
|
|
395
|
+
}) {
|
|
354
396
|
const app = useApp();
|
|
355
397
|
const { stdout } = useStdout();
|
|
356
398
|
const [cfg, setCfg] = useState(initialConfig);
|
|
@@ -380,12 +422,17 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
380
422
|
const [indexProgress, setIndexProgress] = useState('');
|
|
381
423
|
const [ragEnabled, setRagEnabled] = useState(true);
|
|
382
424
|
const [menuIndex, setMenuIndex] = useState(0);
|
|
425
|
+
const [historyItems, setHistoryItems] = useState([]);
|
|
426
|
+
const [historyIndex, setHistoryIndex] = useState(0);
|
|
427
|
+
const [historyDeleteId, setHistoryDeleteId] = useState(null);
|
|
428
|
+
const [activeSessionId, setActiveSessionId] = useState(session?.id ?? null);
|
|
383
429
|
const [, force] = useState(0);
|
|
384
430
|
const rerender = useCallback(() => force((n) => n + 1), []);
|
|
385
431
|
|
|
386
432
|
// 슬래시 메뉴: input 상태에 따라 동적으로 계산.
|
|
387
433
|
const slashItems = state === 'idle' ? filterSlashCommands(input) : [];
|
|
388
434
|
const slashOpen = slashItems.length > 0;
|
|
435
|
+
const historyOpen = state === 'idle' && historyItems.length > 0;
|
|
389
436
|
// input 이 바뀌면 선택 인덱스를 0 으로 리셋 (필터 변경 시 자연스럽게).
|
|
390
437
|
useEffect(() => {
|
|
391
438
|
setMenuIndex(0);
|
|
@@ -507,6 +554,29 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
507
554
|
app.exit();
|
|
508
555
|
return;
|
|
509
556
|
}
|
|
557
|
+
if (historyOpen) {
|
|
558
|
+
if (historyDeleteId) {
|
|
559
|
+
if (char.toLowerCase() === 'y') {
|
|
560
|
+
void deleteHistorySession();
|
|
561
|
+
} else if (char.toLowerCase() === 'n' || key.escape) {
|
|
562
|
+
setHistoryDeleteId(null);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (key.upArrow) {
|
|
567
|
+
setHistoryIndex((index) => Math.max(0, index - 1));
|
|
568
|
+
} else if (key.downArrow) {
|
|
569
|
+
setHistoryIndex((index) => Math.min(historyItems.length - 1, index + 1));
|
|
570
|
+
} else if (key.escape) {
|
|
571
|
+
setHistoryItems([]);
|
|
572
|
+
} else if (char.toLowerCase() === 'd') {
|
|
573
|
+
const selected = historyItems[historyIndex];
|
|
574
|
+
if (selected) setHistoryDeleteId(selected.id);
|
|
575
|
+
} else if (key.return) {
|
|
576
|
+
void selectHistorySession();
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
510
580
|
if (!slashOpen) return;
|
|
511
581
|
if (key.upArrow) {
|
|
512
582
|
setMenuIndex((i) => Math.max(0, i - 1));
|
|
@@ -552,6 +622,33 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
552
622
|
pushSystemInfo('대화 컨텍스트를 비웠습니다.');
|
|
553
623
|
return true;
|
|
554
624
|
}
|
|
625
|
+
if (cmd === 'history') {
|
|
626
|
+
try {
|
|
627
|
+
const sessions = await listSessions();
|
|
628
|
+
if (!sessions.length) {
|
|
629
|
+
pushSystemInfo('저장된 이전 세션이 없습니다.');
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
setHistoryItems(sessions.slice(0, 10));
|
|
633
|
+
setHistoryIndex(0);
|
|
634
|
+
setHistoryDeleteId(null);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
pushSystemError('대화 목록을 불러올 수 없습니다: ' + err.message);
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
if (cmd === 'retry') {
|
|
641
|
+
const retry = findLastRetryableUser(messages);
|
|
642
|
+
if (!retry) {
|
|
643
|
+
pushSystemError('다시 실행할 사용자 요청이 없습니다.');
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
await sendMessage(retry.text, {
|
|
647
|
+
baseMessages: messages.slice(0, retry.index),
|
|
648
|
+
attachments: retry.message.attachments ?? [],
|
|
649
|
+
});
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
555
652
|
if (cmd === 'cost') {
|
|
556
653
|
pushSystemInfo(meterRef.current.format());
|
|
557
654
|
return true;
|
|
@@ -656,13 +753,70 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
656
753
|
return true;
|
|
657
754
|
};
|
|
658
755
|
|
|
659
|
-
const
|
|
756
|
+
const selectHistorySession = async () => {
|
|
757
|
+
const selected = historyItems[historyIndex];
|
|
758
|
+
if (!selected || !onSessionSwitch) return;
|
|
759
|
+
|
|
760
|
+
setHistoryItems([]);
|
|
761
|
+
setState('thinking');
|
|
762
|
+
try {
|
|
763
|
+
const loaded = await onSessionSwitch(selected.id);
|
|
764
|
+
setActiveSessionId(loaded.id);
|
|
765
|
+
setPendingAttachments([]);
|
|
766
|
+
setMessages([
|
|
767
|
+
...(loaded.messages ?? []),
|
|
768
|
+
{
|
|
769
|
+
role: 'system-info',
|
|
770
|
+
text: `세션 ${loaded.id} 컨텍스트를 불러왔습니다.`,
|
|
771
|
+
},
|
|
772
|
+
]);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
pushSystemError('세션을 불러올 수 없습니다: ' + (err?.message ?? String(err)));
|
|
775
|
+
} finally {
|
|
776
|
+
setState('idle');
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const deleteHistorySession = async () => {
|
|
781
|
+
const id = historyDeleteId;
|
|
782
|
+
if (!id || !onSessionDelete) return;
|
|
783
|
+
|
|
784
|
+
setHistoryDeleteId(null);
|
|
785
|
+
try {
|
|
786
|
+
const result = await onSessionDelete(id);
|
|
787
|
+
const remaining = historyItems.filter((item) => item.id !== id);
|
|
788
|
+
setHistoryItems(remaining);
|
|
789
|
+
setHistoryIndex((index) => Math.min(index, Math.max(0, remaining.length - 1)));
|
|
790
|
+
|
|
791
|
+
if (result.replacementSession) {
|
|
792
|
+
setActiveSessionId(result.replacementSession.id);
|
|
793
|
+
setPendingAttachments([]);
|
|
794
|
+
setMessages([
|
|
795
|
+
{
|
|
796
|
+
role: 'system-info',
|
|
797
|
+
text: `세션 ${id}을 삭제하고 새 세션을 시작했습니다.`,
|
|
798
|
+
},
|
|
799
|
+
]);
|
|
800
|
+
} else {
|
|
801
|
+
pushSystemInfo(
|
|
802
|
+
result.deleted ? `세션 ${id}을 삭제했습니다.` : `세션 ${id}을 찾을 수 없습니다.`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
} catch (err) {
|
|
806
|
+
pushSystemError('세션을 삭제할 수 없습니다: ' + (err?.message ?? String(err)));
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const sendMessage = async (
|
|
811
|
+
text,
|
|
812
|
+
{ baseMessages = messages, attachments = pendingAttachments } = {},
|
|
813
|
+
) => {
|
|
660
814
|
const userMsg = {
|
|
661
815
|
role: 'user',
|
|
662
816
|
text,
|
|
663
|
-
attachments
|
|
817
|
+
attachments,
|
|
664
818
|
};
|
|
665
|
-
const newMessages = [...
|
|
819
|
+
const newMessages = [...baseMessages, userMsg];
|
|
666
820
|
setMessages(newMessages);
|
|
667
821
|
setPendingAttachments([]);
|
|
668
822
|
setState('thinking');
|
|
@@ -896,12 +1050,20 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
896
1050
|
}),
|
|
897
1051
|
),
|
|
898
1052
|
h(AttachBar, { pending: pendingAttachments }),
|
|
1053
|
+
historyOpen
|
|
1054
|
+
? h(HistoryMenu, {
|
|
1055
|
+
items: historyItems,
|
|
1056
|
+
activeIndex: historyIndex,
|
|
1057
|
+
activeSessionId,
|
|
1058
|
+
deleteId: historyDeleteId,
|
|
1059
|
+
})
|
|
1060
|
+
: null,
|
|
899
1061
|
slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
|
|
900
1062
|
h(
|
|
901
1063
|
Box,
|
|
902
1064
|
{ marginTop: 0 },
|
|
903
1065
|
h(Text, { color: 'magenta', bold: true }, 'you › '),
|
|
904
|
-
state === 'idle'
|
|
1066
|
+
state === 'idle' && !historyOpen
|
|
905
1067
|
? h(TextInput, {
|
|
906
1068
|
value: input,
|
|
907
1069
|
onChange: setInput,
|
|
@@ -912,7 +1074,15 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
912
1074
|
// 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
|
|
913
1075
|
showCursor: false,
|
|
914
1076
|
})
|
|
915
|
-
: h(
|
|
1077
|
+
: h(
|
|
1078
|
+
Text,
|
|
1079
|
+
{ dimColor: true },
|
|
1080
|
+
historyOpen
|
|
1081
|
+
? historyDeleteId
|
|
1082
|
+
? '(y 또는 n을 입력하세요)'
|
|
1083
|
+
: '(이전 대화를 선택하세요)'
|
|
1084
|
+
: '(응답 받는 중 — 잠시만)',
|
|
1085
|
+
),
|
|
916
1086
|
),
|
|
917
1087
|
);
|
|
918
1088
|
}
|