byuckchon-frontend-cli 1.7.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 +36 -0
- package/bin/index.js +1 -1
- package/package.json +2 -1
- package/src/commands/adopt.js +24 -1
- package/src/commands/chat.js +121 -27
- package/src/config/index.js +6 -0
- package/src/context/conventions.js +114 -0
- package/src/generators/apiConventionDoc.js +49 -0
- package/src/generators/createBcConfig.js +4 -0
- package/src/generators/createProject.js +3 -0
- package/src/history/management.js +30 -0
- package/src/history/store.js +33 -5
- package/src/ui/ChatApp.js +186 -7
- package/templates/conventions/api-codegen.md +400 -0
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
|
|
|
@@ -115,7 +117,7 @@ async function pasteClipboardImage() {
|
|
|
115
117
|
* 슬래시 명령은 Input 컴포넌트의 onSubmit 에서 가로채서 처리.
|
|
116
118
|
*/
|
|
117
119
|
|
|
118
|
-
function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo }) {
|
|
120
|
+
function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo, conventionFiles }) {
|
|
119
121
|
let ragLabel;
|
|
120
122
|
if (!hasIndex) ragLabel = '(준비 중 — 자동 빌드 또는 /index)';
|
|
121
123
|
else if (ragOn) ragLabel = 'on (관련 코드 자동 주입)';
|
|
@@ -167,6 +169,14 @@ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo
|
|
|
167
169
|
h(Text, null, openapiLabel),
|
|
168
170
|
)
|
|
169
171
|
: null,
|
|
172
|
+
conventionFiles?.length
|
|
173
|
+
? h(
|
|
174
|
+
Box,
|
|
175
|
+
null,
|
|
176
|
+
h(Text, { dimColor: true }, 'docs '),
|
|
177
|
+
h(Text, null, conventionFiles.join(', ')),
|
|
178
|
+
)
|
|
179
|
+
: null,
|
|
170
180
|
gateway
|
|
171
181
|
? h(
|
|
172
182
|
Box,
|
|
@@ -279,6 +289,8 @@ function AttachBar({ pending }) {
|
|
|
279
289
|
const SLASH_COMMANDS = [
|
|
280
290
|
{ cmd: '/help', hint: '', desc: '명령 도움말' },
|
|
281
291
|
{ cmd: '/clear', hint: '', desc: '대화 컨텍스트 비우기' },
|
|
292
|
+
{ cmd: '/history', hint: '', desc: '프로젝트의 이전 대화 목록' },
|
|
293
|
+
{ cmd: '/retry', hint: '', desc: '마지막 사용자 요청 다시 실행' },
|
|
282
294
|
{ cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
|
|
283
295
|
{ cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
|
|
284
296
|
{ cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
|
|
@@ -342,7 +354,45 @@ function SlashMenu({ items, activeIndex }) {
|
|
|
342
354
|
);
|
|
343
355
|
}
|
|
344
356
|
|
|
345
|
-
|
|
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
|
+
}) {
|
|
346
396
|
const app = useApp();
|
|
347
397
|
const { stdout } = useStdout();
|
|
348
398
|
const [cfg, setCfg] = useState(initialConfig);
|
|
@@ -372,12 +422,17 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
372
422
|
const [indexProgress, setIndexProgress] = useState('');
|
|
373
423
|
const [ragEnabled, setRagEnabled] = useState(true);
|
|
374
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);
|
|
375
429
|
const [, force] = useState(0);
|
|
376
430
|
const rerender = useCallback(() => force((n) => n + 1), []);
|
|
377
431
|
|
|
378
432
|
// 슬래시 메뉴: input 상태에 따라 동적으로 계산.
|
|
379
433
|
const slashItems = state === 'idle' ? filterSlashCommands(input) : [];
|
|
380
434
|
const slashOpen = slashItems.length > 0;
|
|
435
|
+
const historyOpen = state === 'idle' && historyItems.length > 0;
|
|
381
436
|
// input 이 바뀌면 선택 인덱스를 0 으로 리셋 (필터 변경 시 자연스럽게).
|
|
382
437
|
useEffect(() => {
|
|
383
438
|
setMenuIndex(0);
|
|
@@ -499,6 +554,29 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
499
554
|
app.exit();
|
|
500
555
|
return;
|
|
501
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
|
+
}
|
|
502
580
|
if (!slashOpen) return;
|
|
503
581
|
if (key.upArrow) {
|
|
504
582
|
setMenuIndex((i) => Math.max(0, i - 1));
|
|
@@ -544,6 +622,33 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
544
622
|
pushSystemInfo('대화 컨텍스트를 비웠습니다.');
|
|
545
623
|
return true;
|
|
546
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
|
+
}
|
|
547
652
|
if (cmd === 'cost') {
|
|
548
653
|
pushSystemInfo(meterRef.current.format());
|
|
549
654
|
return true;
|
|
@@ -648,13 +753,70 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
648
753
|
return true;
|
|
649
754
|
};
|
|
650
755
|
|
|
651
|
-
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
|
+
) => {
|
|
652
814
|
const userMsg = {
|
|
653
815
|
role: 'user',
|
|
654
816
|
text,
|
|
655
|
-
attachments
|
|
817
|
+
attachments,
|
|
656
818
|
};
|
|
657
|
-
const newMessages = [...
|
|
819
|
+
const newMessages = [...baseMessages, userMsg];
|
|
658
820
|
setMessages(newMessages);
|
|
659
821
|
setPendingAttachments([]);
|
|
660
822
|
setState('thinking');
|
|
@@ -870,6 +1032,7 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
870
1032
|
ragOn: ragEnabled,
|
|
871
1033
|
hasIndex,
|
|
872
1034
|
openapiInfo: cfg.openapiInfo,
|
|
1035
|
+
conventionFiles: cfg.conventionFiles,
|
|
873
1036
|
}),
|
|
874
1037
|
h(
|
|
875
1038
|
Box,
|
|
@@ -887,12 +1050,20 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
887
1050
|
}),
|
|
888
1051
|
),
|
|
889
1052
|
h(AttachBar, { pending: pendingAttachments }),
|
|
1053
|
+
historyOpen
|
|
1054
|
+
? h(HistoryMenu, {
|
|
1055
|
+
items: historyItems,
|
|
1056
|
+
activeIndex: historyIndex,
|
|
1057
|
+
activeSessionId,
|
|
1058
|
+
deleteId: historyDeleteId,
|
|
1059
|
+
})
|
|
1060
|
+
: null,
|
|
890
1061
|
slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
|
|
891
1062
|
h(
|
|
892
1063
|
Box,
|
|
893
1064
|
{ marginTop: 0 },
|
|
894
1065
|
h(Text, { color: 'magenta', bold: true }, 'you › '),
|
|
895
|
-
state === 'idle'
|
|
1066
|
+
state === 'idle' && !historyOpen
|
|
896
1067
|
? h(TextInput, {
|
|
897
1068
|
value: input,
|
|
898
1069
|
onChange: setInput,
|
|
@@ -903,7 +1074,15 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
903
1074
|
// 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
|
|
904
1075
|
showCursor: false,
|
|
905
1076
|
})
|
|
906
|
-
: h(
|
|
1077
|
+
: h(
|
|
1078
|
+
Text,
|
|
1079
|
+
{ dimColor: true },
|
|
1080
|
+
historyOpen
|
|
1081
|
+
? historyDeleteId
|
|
1082
|
+
? '(y 또는 n을 입력하세요)'
|
|
1083
|
+
: '(이전 대화를 선택하세요)'
|
|
1084
|
+
: '(응답 받는 중 — 잠시만)',
|
|
1085
|
+
),
|
|
907
1086
|
),
|
|
908
1087
|
);
|
|
909
1088
|
}
|