byuckchon-frontend-cli 1.9.0 → 1.9.2
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 +10 -0
- package/bin/index.js +1 -1
- package/package.json +1 -1
- package/src/ai/tools.js +117 -6
- package/src/commands/chat.js +102 -21
- package/src/history/management.js +30 -0
- package/src/history/store.js +33 -5
- package/src/openapi/cache.js +7 -2
- package/src/ui/ChatApp.js +188 -6
package/README.md
CHANGED
|
@@ -193,6 +193,10 @@ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙
|
|
|
193
193
|
|
|
194
194
|
- **OpenAPI**: chat 시작 시 자동 fetch + 1시간 디스크 캐시 → 엔드포인트 요약을 시스템 프롬프트에 박음.
|
|
195
195
|
- 헤더에 `openapi` 줄로 표시. 캐시 hit 면 `(cached)`, fresh fetch 면 `(live)`.
|
|
196
|
+
- **세션 중 서버가 스펙을 바꿔도 자동 대응 (v1.10+)**: `search_openapi` / `get_openapi_endpoint` 가
|
|
197
|
+
캐시에서 엔드포인트를 못 찾으면 **딱 한 번 최신본을 다시 받아 재검색**합니다 (`🔄 OpenAPI 스펙 새로고침`).
|
|
198
|
+
남용 방지를 위해 세션당 횟수·간격이 제한됩니다. "방금 스웨거 업데이트했어, 다시 읽어줘" 라고 하면
|
|
199
|
+
즉시 강제 새로고침(`refresh_openapi`)합니다.
|
|
196
200
|
- **코드 인덱스**: chat 시작 시 인덱스 파일이 없으면 **백그라운드에서 자동 빌드**.
|
|
197
201
|
- 빌드 중에는 화면에 `📚 인덱싱 중 ...` 진행 표시. 끝나면 `✓` 메시지 한 줄.
|
|
198
202
|
- OpenAI 키가 없으면 빌드를 건너뛰고 도움 메시지를 띄움 (Anthropic 은 임베딩 API 미제공).
|
|
@@ -284,6 +288,8 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
|
|
|
284
288
|
| ------------------- | ------------------------------------------ |
|
|
285
289
|
| `/help` | 도움말 |
|
|
286
290
|
| `/clear` | 대화 컨텍스트 초기화 |
|
|
291
|
+
| `/history` | 이전 대화 선택 후 해당 컨텍스트 이어가기 |
|
|
292
|
+
| `/retry` | 마지막 사용자 요청 다시 실행 |
|
|
287
293
|
| `/model [id]` | 세션 모델 변경 (인자 없으면 목록) |
|
|
288
294
|
| `/cost` | 누적 토큰/비용 |
|
|
289
295
|
| `/image <path>` | 다음 메시지에 이미지 첨부 (Vision 모델 권장) |
|
|
@@ -294,6 +300,10 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
|
|
|
294
300
|
| `/rag on\|off` | RAG 컨텍스트 주입 즉석 토글 |
|
|
295
301
|
| `/exit` | 종료 (`Ctrl+C` 도 가능) |
|
|
296
302
|
|
|
303
|
+
Ink 모드에서 `/history`를 실행하면 `↑↓`로 세션을 선택하고 `Enter`로 불러올 수 있습니다.
|
|
304
|
+
선택한 세션에서 `d`를 누른 뒤 `y`로 확인하면 해당 기록을 삭제합니다. 메시지를 한 번도
|
|
305
|
+
보내지 않고 종료한 빈 세션은 저장되거나 목록에 표시되지 않습니다.
|
|
306
|
+
|
|
297
307
|
이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
|
|
298
308
|
Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
|
|
299
309
|
|
package/bin/index.js
CHANGED
package/package.json
CHANGED
package/src/ai/tools.js
CHANGED
|
@@ -44,6 +44,39 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
44
44
|
return _openapiDocPromise;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ── live refetch (서버가 세션 도중 스펙을 바꾼 경우 대비) ──
|
|
48
|
+
// 남용 방지: 세션당 최대 횟수 + 최소 간격 throttle.
|
|
49
|
+
const REFRESH_MAX = 6;
|
|
50
|
+
const REFRESH_MIN_INTERVAL_MS = 10_000;
|
|
51
|
+
let _refreshCount = 0;
|
|
52
|
+
let _lastRefreshAt = 0;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 캐시를 무시하고 OpenAPI 스펙을 다시 fetch 한다.
|
|
56
|
+
* @returns {Promise<{ doc: object|null, refreshed: boolean, reason?: string }>}
|
|
57
|
+
*/
|
|
58
|
+
async function refreshOpenApiDoc() {
|
|
59
|
+
if (!openapiSource) return { doc: null, refreshed: false, reason: 'no-source' };
|
|
60
|
+
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (_refreshCount >= REFRESH_MAX) {
|
|
63
|
+
return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'limit' };
|
|
64
|
+
}
|
|
65
|
+
if (now - _lastRefreshAt < REFRESH_MIN_INTERVAL_MS) {
|
|
66
|
+
return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'throttled' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_refreshCount += 1;
|
|
70
|
+
_lastRefreshAt = now;
|
|
71
|
+
_openapiDocPromise = getCachedOpenApi(openapiSource, { force: true })
|
|
72
|
+
.then((res) => res.doc ?? null)
|
|
73
|
+
.catch(() => null);
|
|
74
|
+
|
|
75
|
+
const doc = await _openapiDocPromise;
|
|
76
|
+
onEvent({ kind: 'openapi_refreshed', ok: !!doc });
|
|
77
|
+
return { doc, refreshed: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
function safePath(p) {
|
|
48
81
|
if (!p || typeof p !== 'string') {
|
|
49
82
|
throw new Error('path 가 비어있습니다');
|
|
@@ -188,7 +221,11 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
188
221
|
// ─────────── OpenAPI 툴 ───────────
|
|
189
222
|
|
|
190
223
|
async function searchOpenApi({ query, limit = 40 }) {
|
|
191
|
-
|
|
224
|
+
let doc = await loadOpenApiDoc();
|
|
225
|
+
if (!doc) {
|
|
226
|
+
// 첫 로드 실패면 한 번 live refetch 시도.
|
|
227
|
+
({ doc } = await refreshOpenApiDoc());
|
|
228
|
+
}
|
|
192
229
|
if (!doc) {
|
|
193
230
|
return {
|
|
194
231
|
ok: false,
|
|
@@ -197,11 +234,25 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
197
234
|
'(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
|
|
198
235
|
};
|
|
199
236
|
}
|
|
200
|
-
|
|
237
|
+
|
|
238
|
+
let hits = searchEndpoints(doc, query, { limit });
|
|
239
|
+
let refreshed = false;
|
|
240
|
+
|
|
241
|
+
// 캐시된 스펙에서 못 찾으면 → 서버에서 방금 추가됐을 수 있으니 딱 한 번 live refetch 후 재검색.
|
|
242
|
+
if (hits.length === 0) {
|
|
243
|
+
const r = await refreshOpenApiDoc();
|
|
244
|
+
if (r.refreshed && r.doc) {
|
|
245
|
+
refreshed = true;
|
|
246
|
+
doc = r.doc;
|
|
247
|
+
hits = searchEndpoints(doc, query, { limit });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
201
251
|
return {
|
|
202
252
|
ok: true,
|
|
203
253
|
query,
|
|
204
254
|
count: hits.length,
|
|
255
|
+
refreshed,
|
|
205
256
|
endpoints: hits.map((e) => ({
|
|
206
257
|
method: e.method,
|
|
207
258
|
path: e.path,
|
|
@@ -210,17 +261,63 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
210
261
|
})),
|
|
211
262
|
hint:
|
|
212
263
|
hits.length === 0
|
|
213
|
-
?
|
|
214
|
-
|
|
264
|
+
? (refreshed
|
|
265
|
+
? '최신 스펙을 다시 받아왔는데도 매치가 없습니다. 다른 키워드로 재시도하거나 query 를 비워 전체 목록을 확인하세요.'
|
|
266
|
+
: '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.')
|
|
267
|
+
: (refreshed
|
|
268
|
+
? '캐시엔 없던 항목을 최신 스펙에서 찾았습니다. 상세는 get_openapi_endpoint(path, method).'
|
|
269
|
+
: '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.'),
|
|
215
270
|
};
|
|
216
271
|
}
|
|
217
272
|
|
|
218
273
|
async function getOpenApiEndpoint({ path: epPath, method }) {
|
|
219
|
-
|
|
274
|
+
let doc = await loadOpenApiDoc();
|
|
275
|
+
if (!doc) {
|
|
276
|
+
({ doc } = await refreshOpenApiDoc());
|
|
277
|
+
}
|
|
220
278
|
if (!doc) {
|
|
221
279
|
return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
|
|
222
280
|
}
|
|
223
|
-
|
|
281
|
+
|
|
282
|
+
let result = getEndpoint(doc, epPath, method);
|
|
283
|
+
|
|
284
|
+
// 못 찾으면 → 최신 스펙으로 한 번 더.
|
|
285
|
+
if (!result.ok) {
|
|
286
|
+
const r = await refreshOpenApiDoc();
|
|
287
|
+
if (r.refreshed && r.doc) {
|
|
288
|
+
const retry = getEndpoint(r.doc, epPath, method);
|
|
289
|
+
if (retry.ok) result = { ...retry, refreshed: true };
|
|
290
|
+
else result = { ...retry, refreshed: true };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function refreshOpenApi() {
|
|
297
|
+
if (!openapiSource) {
|
|
298
|
+
return { ok: false, error: 'bc.config.json 에 api.openapi 가 설정되어 있지 않습니다.' };
|
|
299
|
+
}
|
|
300
|
+
const r = await refreshOpenApiDoc();
|
|
301
|
+
if (!r.doc) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
refreshed: r.refreshed,
|
|
305
|
+
error:
|
|
306
|
+
r.reason === 'throttled'
|
|
307
|
+
? '방금 새로고침했습니다. 잠시 후 다시 시도하세요.'
|
|
308
|
+
: '스펙을 다시 받아오지 못했습니다 (네트워크/URL 확인).',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const count = (r.doc.paths ? Object.keys(r.doc.paths).length : 0);
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
refreshed: r.refreshed,
|
|
315
|
+
reason: r.refreshed ? undefined : r.reason,
|
|
316
|
+
paths: count,
|
|
317
|
+
message: r.refreshed
|
|
318
|
+
? `최신 OpenAPI 스펙을 다시 받아왔습니다 (path ${count}개).`
|
|
319
|
+
: '최근에 이미 새로고침되어 캐시를 재사용했습니다.',
|
|
320
|
+
};
|
|
224
321
|
}
|
|
225
322
|
|
|
226
323
|
// ─────────── Figma 툴 ───────────
|
|
@@ -381,6 +478,20 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
381
478
|
}),
|
|
382
479
|
execute: getOpenApiEndpoint,
|
|
383
480
|
}),
|
|
481
|
+
refresh_openapi: tool({
|
|
482
|
+
description:
|
|
483
|
+
'연결된 OpenAPI(Swagger) 스펙을 캐시 무시하고 서버에서 다시 받아온다. ' +
|
|
484
|
+
'사용자가 "방금 스웨거(백엔드 API) 를 업데이트했다 / 다시 읽어라" 라고 하거나, ' +
|
|
485
|
+
'search_openapi 가 분명히 있어야 할 엔드포인트를 못 찾을 때 호출. ' +
|
|
486
|
+
'(search_openapi / get_openapi_endpoint 는 못 찾으면 자동으로 한 번 새로고침하므로, ' +
|
|
487
|
+
'명시적 요청이 있을 때만 직접 부르면 된다.)',
|
|
488
|
+
inputSchema: jsonSchema({
|
|
489
|
+
type: 'object',
|
|
490
|
+
properties: {},
|
|
491
|
+
additionalProperties: false,
|
|
492
|
+
}),
|
|
493
|
+
execute: refreshOpenApi,
|
|
494
|
+
}),
|
|
384
495
|
write_file: tool({
|
|
385
496
|
description:
|
|
386
497
|
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
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
|
|
|
@@ -119,6 +121,9 @@ export async function chatCommand(opts = {}) {
|
|
|
119
121
|
'가져와서 zod/타입/요청 함수를 만든다. ' +
|
|
120
122
|
'예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
|
|
121
123
|
'`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
|
|
124
|
+
'스펙은 캐시(최대 1시간)라 서버가 방금 바꿨으면 오래됐을 수 있다 — ' +
|
|
125
|
+
'search_openapi/get_openapi_endpoint 는 못 찾으면 자동으로 한 번 최신본을 다시 받아온다. ' +
|
|
126
|
+
'사용자가 "방금 스웨거 업데이트했어/다시 읽어" 라고 하면 `refresh_openapi()` 를 먼저 호출한다. ' +
|
|
122
127
|
'이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
|
|
123
128
|
}
|
|
124
129
|
} catch {
|
|
@@ -162,8 +167,6 @@ export async function chatCommand(opts = {}) {
|
|
|
162
167
|
// 모델은 사용자가 명시했거나 글로벌 설정으로 갱신 가능 — 세션의 model 은 표시용.
|
|
163
168
|
session.model = resolved.meta.id;
|
|
164
169
|
}
|
|
165
|
-
await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
|
|
166
|
-
|
|
167
170
|
// ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
|
|
168
171
|
// - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
|
|
169
172
|
// - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
|
|
@@ -206,9 +209,36 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
|
|
|
206
209
|
|
|
207
210
|
const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
|
|
208
211
|
|
|
212
|
+
let activeSession = session;
|
|
213
|
+
|
|
209
214
|
const onSessionUpdate = async (messages) => {
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
const target = activeSession;
|
|
216
|
+
const hasConversation = messages.some(
|
|
217
|
+
(message) =>
|
|
218
|
+
(message.role === 'user' || message.role === 'assistant') &&
|
|
219
|
+
typeof message.text === 'string' &&
|
|
220
|
+
message.text.trim(),
|
|
221
|
+
);
|
|
222
|
+
if (!hasConversation && !target._persisted) return;
|
|
223
|
+
target.messages = messages;
|
|
224
|
+
await saveSession(target);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const onSessionSwitch = async (id) => {
|
|
228
|
+
const loaded = await loadSession(id);
|
|
229
|
+
loaded.model = resolved.meta.id;
|
|
230
|
+
activeSession = loaded;
|
|
231
|
+
return loaded;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const onSessionDelete = async (id) => {
|
|
235
|
+
const deletingActive = activeSession.id === id;
|
|
236
|
+
const result = await deleteSession(id);
|
|
237
|
+
if (!deletingActive) return { ...result, replacementSession: null };
|
|
238
|
+
|
|
239
|
+
const replacementSession = await createSession({ model: resolved.meta.id });
|
|
240
|
+
activeSession = replacementSession;
|
|
241
|
+
return { ...result, replacementSession };
|
|
212
242
|
};
|
|
213
243
|
|
|
214
244
|
const { waitUntilExit } = render(
|
|
@@ -217,6 +247,8 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
|
|
|
217
247
|
initialResolved: resolved,
|
|
218
248
|
session,
|
|
219
249
|
onSessionUpdate,
|
|
250
|
+
onSessionSwitch,
|
|
251
|
+
onSessionDelete,
|
|
220
252
|
}),
|
|
221
253
|
{ exitOnCtrlC: false },
|
|
222
254
|
);
|
|
@@ -235,6 +267,12 @@ async function runOnce({ cfg, resolved, system, prompt }) {
|
|
|
235
267
|
effective: cfg.effective,
|
|
236
268
|
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
237
269
|
onEvent: (ev) => {
|
|
270
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
271
|
+
console.log(
|
|
272
|
+
chalk.dim(ev.ok ? ' 🔄 OpenAPI 스펙 새로고침' : ' ⚠️ OpenAPI 새로고침 실패'),
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
238
276
|
const label =
|
|
239
277
|
ev.kind === 'write_created'
|
|
240
278
|
? '🆕'
|
|
@@ -291,7 +329,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
291
329
|
console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
|
|
292
330
|
}
|
|
293
331
|
console.log(
|
|
294
|
-
chalk.dim(
|
|
332
|
+
chalk.dim(
|
|
333
|
+
' /history · /retry · /image <경로> · /paste · /clear-attach · /exit\n',
|
|
334
|
+
),
|
|
295
335
|
);
|
|
296
336
|
|
|
297
337
|
// 다음 메시지에 함께 보낼 이미지 첨부 목록.
|
|
@@ -341,6 +381,21 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
341
381
|
const history = (session?.messages ?? [])
|
|
342
382
|
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
343
383
|
.map((m) => ({ role: m.role, content: m.text ?? '' }));
|
|
384
|
+
const persistHistory = async () => {
|
|
385
|
+
if (!session) return;
|
|
386
|
+
session.messages = history.map((message) => {
|
|
387
|
+
// 이미지 바이트는 세션 파일에 저장하지 않고 사용자 텍스트만 보존한다.
|
|
388
|
+
if (Array.isArray(message.content)) {
|
|
389
|
+
const textPart = message.content.find((part) => part.type === 'text');
|
|
390
|
+
return {
|
|
391
|
+
role: message.role,
|
|
392
|
+
text: textPart?.text ?? '[이미지 첨부]',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return { role: message.role, text: message.content };
|
|
396
|
+
});
|
|
397
|
+
await saveSession(session);
|
|
398
|
+
};
|
|
344
399
|
const ask = () => rl.prompt();
|
|
345
400
|
|
|
346
401
|
rl.on('close', () => {
|
|
@@ -359,6 +414,29 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
359
414
|
rl.close();
|
|
360
415
|
return;
|
|
361
416
|
}
|
|
417
|
+
if (line === '/history') {
|
|
418
|
+
try {
|
|
419
|
+
const sessions = await listSessions();
|
|
420
|
+
console.log(chalk.dim('\n' + formatSessionList(sessions)));
|
|
421
|
+
console.log(chalk.dim('\n 이어가기: bc chat --resume <id>'));
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.log(chalk.red(' 대화 목록을 불러올 수 없습니다: ' + err.message));
|
|
424
|
+
}
|
|
425
|
+
ask();
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
let retryContent = null;
|
|
429
|
+
if (line === '/retry') {
|
|
430
|
+
const retry = findLastRetryableUser(history);
|
|
431
|
+
if (!retry) {
|
|
432
|
+
console.log(chalk.red(' 다시 실행할 사용자 요청이 없습니다.'));
|
|
433
|
+
ask();
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
retryContent = retry.message.content;
|
|
437
|
+
history.splice(retry.index);
|
|
438
|
+
console.log(chalk.dim(' 마지막 요청을 다시 실행합니다.'));
|
|
439
|
+
}
|
|
362
440
|
// 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
|
|
363
441
|
if (line.startsWith('/image')) {
|
|
364
442
|
await addImage(line.slice('/image'.length).trim());
|
|
@@ -388,7 +466,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
388
466
|
}
|
|
389
467
|
|
|
390
468
|
// 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
|
|
391
|
-
if (
|
|
469
|
+
if (retryContent != null) {
|
|
470
|
+
history.push({ role: 'user', content: retryContent });
|
|
471
|
+
} else if (pendingAttachments.length) {
|
|
392
472
|
const parts = [{ type: 'text', text: line }];
|
|
393
473
|
try {
|
|
394
474
|
for (const att of pendingAttachments) {
|
|
@@ -403,6 +483,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
403
483
|
} else {
|
|
404
484
|
history.push({ role: 'user', content: line });
|
|
405
485
|
}
|
|
486
|
+
try {
|
|
487
|
+
await persistHistory();
|
|
488
|
+
} catch {
|
|
489
|
+
/* 저장 실패가 AI 요청을 막지는 않게 한다. */
|
|
490
|
+
}
|
|
406
491
|
rl.pause();
|
|
407
492
|
|
|
408
493
|
const projectRoot = cfg.paths.projectFile
|
|
@@ -413,6 +498,12 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
413
498
|
effective: cfg.effective,
|
|
414
499
|
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
415
500
|
onEvent: (ev) => {
|
|
501
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
502
|
+
console.log(
|
|
503
|
+
chalk.dim(ev.ok ? '\n 🔄 OpenAPI 스펙 새로고침' : '\n ⚠️ OpenAPI 새로고침 실패'),
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
416
507
|
const label =
|
|
417
508
|
ev.kind === 'write_created'
|
|
418
509
|
? '🆕 생성'
|
|
@@ -457,21 +548,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
457
548
|
}
|
|
458
549
|
if (acc) history.push({ role: 'assistant', content: acc });
|
|
459
550
|
|
|
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
|
-
}
|
|
551
|
+
// 응답까지 포함한 최신 상태로 다시 저장한다.
|
|
552
|
+
try {
|
|
553
|
+
await persistHistory();
|
|
554
|
+
} catch {
|
|
555
|
+
/* noop */
|
|
475
556
|
}
|
|
476
557
|
|
|
477
558
|
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/openapi/cache.js
CHANGED
|
@@ -23,8 +23,13 @@ function keyFor(input) {
|
|
|
23
23
|
*
|
|
24
24
|
* - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
|
|
25
25
|
* - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} input OpenAPI URL 또는 파일 경로
|
|
28
|
+
* @param {object} [opts]
|
|
29
|
+
* @param {boolean} [opts.force] true 면 TTL 무시하고 무조건 live refetch (캐시 갱신).
|
|
30
|
+
* 서버가 세션 도중 스펙을 바꿨을 때 사용.
|
|
26
31
|
*/
|
|
27
|
-
export async function getCachedOpenApi(input) {
|
|
32
|
+
export async function getCachedOpenApi(input, { force = false } = {}) {
|
|
28
33
|
const dir = await getCacheDir();
|
|
29
34
|
const file = path.join(dir, `openapi-${keyFor(input)}.json`);
|
|
30
35
|
|
|
@@ -39,7 +44,7 @@ export async function getCachedOpenApi(input) {
|
|
|
39
44
|
/* miss */
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
if (cachedFresh && cached) {
|
|
47
|
+
if (!force && cachedFresh && cached) {
|
|
43
48
|
return { doc: cached, cached: true, source: input };
|
|
44
49
|
}
|
|
45
50
|
|
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');
|
|
@@ -717,6 +871,18 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
717
871
|
|
|
718
872
|
// 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
|
|
719
873
|
const onToolEvent = (ev) => {
|
|
874
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
875
|
+
setMessages((m) => [
|
|
876
|
+
...m,
|
|
877
|
+
{
|
|
878
|
+
role: 'system-info',
|
|
879
|
+
text: ev.ok
|
|
880
|
+
? '🔄 OpenAPI 스펙 새로고침 (서버에서 최신본 다시 받음)'
|
|
881
|
+
: '⚠️ OpenAPI 새로고침 실패 (네트워크/URL 확인)',
|
|
882
|
+
},
|
|
883
|
+
]);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
720
886
|
const labels = {
|
|
721
887
|
write_created: '🆕 생성',
|
|
722
888
|
write_overwritten: '✏️ 덮어씀',
|
|
@@ -896,12 +1062,20 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
896
1062
|
}),
|
|
897
1063
|
),
|
|
898
1064
|
h(AttachBar, { pending: pendingAttachments }),
|
|
1065
|
+
historyOpen
|
|
1066
|
+
? h(HistoryMenu, {
|
|
1067
|
+
items: historyItems,
|
|
1068
|
+
activeIndex: historyIndex,
|
|
1069
|
+
activeSessionId,
|
|
1070
|
+
deleteId: historyDeleteId,
|
|
1071
|
+
})
|
|
1072
|
+
: null,
|
|
899
1073
|
slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
|
|
900
1074
|
h(
|
|
901
1075
|
Box,
|
|
902
1076
|
{ marginTop: 0 },
|
|
903
1077
|
h(Text, { color: 'magenta', bold: true }, 'you › '),
|
|
904
|
-
state === 'idle'
|
|
1078
|
+
state === 'idle' && !historyOpen
|
|
905
1079
|
? h(TextInput, {
|
|
906
1080
|
value: input,
|
|
907
1081
|
onChange: setInput,
|
|
@@ -912,7 +1086,15 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
912
1086
|
// 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
|
|
913
1087
|
showCursor: false,
|
|
914
1088
|
})
|
|
915
|
-
: h(
|
|
1089
|
+
: h(
|
|
1090
|
+
Text,
|
|
1091
|
+
{ dimColor: true },
|
|
1092
|
+
historyOpen
|
|
1093
|
+
? historyDeleteId
|
|
1094
|
+
? '(y 또는 n을 입력하세요)'
|
|
1095
|
+
: '(이전 대화를 선택하세요)'
|
|
1096
|
+
: '(응답 받는 중 — 잠시만)',
|
|
1097
|
+
),
|
|
916
1098
|
),
|
|
917
1099
|
);
|
|
918
1100
|
}
|