byuckchon-frontend-cli 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/bin/index.js +1 -1
- package/package.json +1 -1
- package/src/ai/systemPrompt.js +26 -3
- package/src/ai/tools.js +236 -0
- package/src/commands/chat.js +56 -5
- package/src/ui/ChatApp.js +108 -10
package/README.md
CHANGED
|
@@ -62,6 +62,35 @@ bc init
|
|
|
62
62
|
프로젝트 이름, 프레임워크, **기본 AI 모델, Figma URL, OpenAPI URL** 을 묻고
|
|
63
63
|
새 폴더에 코드 + `bc.config.json` 까지 만들어 줍니다.
|
|
64
64
|
|
|
65
|
+
### 에이전트 모드 — AI 가 실제 파일을 만들고 고친다 (v1.5+)
|
|
66
|
+
|
|
67
|
+
`bc chat` 은 더 이상 채팅창에 코드 블록을 출력만 하지 않습니다. **모델이 직접 툴을 호출해서
|
|
68
|
+
파일을 만들고/고칩니다** (Codex CLI / Cursor agent 와 같은 컨셉).
|
|
69
|
+
|
|
70
|
+
내장된 툴:
|
|
71
|
+
|
|
72
|
+
| 툴 | 동작 |
|
|
73
|
+
| ------------- | ------------------------------------------------------ |
|
|
74
|
+
| `read_file` | 프로젝트 내 파일/디렉터리 내용 읽기 |
|
|
75
|
+
| `list_files` | 글롭 패턴으로 파일 나열 |
|
|
76
|
+
| `search_code` | RAG 인덱스 의미 기반 검색 (인덱스 있어야 함) |
|
|
77
|
+
| `write_file` | 새 파일 생성 또는 통째 덮어쓰기 |
|
|
78
|
+
| `edit_file` | 유일한 `old_string → new_string` 으로 부분 수정 (안전) |
|
|
79
|
+
|
|
80
|
+
모델은 한 턴 안에서 **최대 12 step** 까지 툴을 자유롭게 호출합니다. 일반적인 흐름:
|
|
81
|
+
1. `list_files` 로 `src/api/` 구조 파악
|
|
82
|
+
2. `read_file` 로 기존 모듈 2~3개 읽고 컨벤션 학습
|
|
83
|
+
3. `search_code` 로 fetch 래퍼 / hook 패턴 검색
|
|
84
|
+
4. `write_file` 로 `api/`, `service/`, `hook/`, `schema/`, `types/` 파일들을 한꺼번에 생성
|
|
85
|
+
5. 마지막에 만든 파일 목록과 import 가이드를 짧게 요약
|
|
86
|
+
|
|
87
|
+
모든 파일 경로는 `bc.config.json` 이 있는 디렉터리(=프로젝트 루트) 하위로만 강제됩니다.
|
|
88
|
+
`../` 이나 절대경로 탈출은 에러로 거부.
|
|
89
|
+
|
|
90
|
+
> **승인 게이트 (Phase 4 예정):** 지금은 모델이 write/edit 을 호출하면 즉시 디스크에 반영됩니다.
|
|
91
|
+
> 안전망은 git diff. 매 작업 후 `git status` / `git diff` 로 확인하고, 마음에 안 들면 `git checkout .` 으로 되돌리세요.
|
|
92
|
+
> 다음 버전에서 per-file 승인(`y/n/v`) 옵션 추가 예정.
|
|
93
|
+
|
|
65
94
|
### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
|
|
66
95
|
|
|
67
96
|
`bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
|
|
@@ -233,6 +262,8 @@ bc config set-gateway # 게이트웨이 해제 (BYOK 모
|
|
|
233
262
|
- [x] Phase 3b: `bc gen api-types` (OpenAPI → TS 타입), `/paste` 클립보드 이미지, 한글 IME 수정
|
|
234
263
|
- [x] Phase 3c-1: chat 시작 시 인덱스 자동 빌드, OpenAPI 자동 fetch+캐시+시스템 프롬프트 주입
|
|
235
264
|
- [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
|
|
265
|
+
- [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
|
|
266
|
+
- [ ] v1.6.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
|
|
236
267
|
- [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
|
|
237
268
|
- [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
|
|
238
269
|
|
package/bin/index.js
CHANGED
package/package.json
CHANGED
package/src/ai/systemPrompt.js
CHANGED
|
@@ -10,9 +10,32 @@ import { CONFIG_PATHS } from '../config/index.js';
|
|
|
10
10
|
*/
|
|
11
11
|
export function buildSystemPrompt({ effective, paths, project }) {
|
|
12
12
|
const lines = [
|
|
13
|
-
'너는 Byuckchon 프론트엔드 팀의 페어 프로그래밍 AI 다.',
|
|
14
|
-
'한국어로 친근하고 간결하게 답한다.
|
|
15
|
-
'추측 대신 모르면 모른다고 말한다.
|
|
13
|
+
'너는 Byuckchon 프론트엔드 팀의 페어 프로그래밍 AI 이자 **에이전트** 다.',
|
|
14
|
+
'한국어로 친근하고 간결하게 답한다. 파일 경로는 백틱으로 감싼다.',
|
|
15
|
+
'추측 대신 모르면 모른다고 말한다.',
|
|
16
|
+
'',
|
|
17
|
+
'## 작업 방식 (중요)',
|
|
18
|
+
'너는 채팅에 코드를 출력하는 게 아니라, **툴을 호출해서 실제로 파일을 만들고 고친다.**',
|
|
19
|
+
'코드를 작성/수정해달라는 요청을 받으면 다음 순서를 지킨다:',
|
|
20
|
+
' 1) `list_files` / `read_file` / `search_code` 로 **기존 컨벤션을 먼저 학습**한다.',
|
|
21
|
+
' - 비슷한 도메인의 폴더 구조, 파일 이름, import 순서, barrel(`index.ts`) 패턴, ',
|
|
22
|
+
' 에러 처리 방식, 상태관리/쿼리 패턴 등을 그대로 따라간다.',
|
|
23
|
+
' - "기존 api 폴더 참고해서" 같은 요청을 받으면 그 폴더를 list_files 로 훑고',
|
|
24
|
+
' 대표 파일 2~3개를 read_file 로 반드시 읽는다.',
|
|
25
|
+
' 2) 필요한 파일을 `write_file` (신규/덮어쓰기) 또는 `edit_file` (부분 수정) 로 **직접 만든다**.',
|
|
26
|
+
' - 한 번의 요청에 여러 파일(예: api / service / hook / type / zod schema) 이 필요하면',
|
|
27
|
+
' 모두 차례로 생성한다. 사용자가 명시하지 않아도 같이 만들 때가 적절하면 만든다.',
|
|
28
|
+
' - 자동 생성된 `*.gen.ts` 가 있다면 거기서 타입을 import 해서 재정의를 피한다.',
|
|
29
|
+
' 3) 마지막으로 **만든 파일 목록과 다음 액션(어디서 import 하면 되는지 등)** 을 한국어로 짧게 요약.',
|
|
30
|
+
'',
|
|
31
|
+
'"코드 짜줘" 라는 표현은 채팅창에 코드 블록을 출력하라는 의미가 **아니다**.',
|
|
32
|
+
'항상 툴을 사용해 실제 파일을 만들어라. 채팅에는 진행 상황과 결과 요약만 짧게 적는다.',
|
|
33
|
+
'',
|
|
34
|
+
'## 안전 규칙',
|
|
35
|
+
'- 절대 프로젝트 루트 밖을 읽거나 쓰지 않는다.',
|
|
36
|
+
'- 기존 파일을 덮어쓸 때는 먼저 `read_file` 로 현재 내용을 보고, 의도된 덮어쓰기인지 확인.',
|
|
37
|
+
'- 큰 변경은 `edit_file` 여러 번이 안전. 통째 덮어쓰기는 새 파일이거나 작은 파일에만.',
|
|
38
|
+
'- 코드 컨벤션이 모호하면 사용자에게 한 번 물어볼 것 (툴 호출 멈추고 메시지로).',
|
|
16
39
|
];
|
|
17
40
|
|
|
18
41
|
const stack = describeStack(project);
|
package/src/ai/tools.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import fg from 'fast-glob';
|
|
5
|
+
import { tool } from 'ai';
|
|
6
|
+
|
|
7
|
+
import { searchIndex } from '../indexer/search.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Agentic chat 용 툴 정의.
|
|
11
|
+
*
|
|
12
|
+
* 설계 원칙:
|
|
13
|
+
* - **모든 파일 경로는 projectRoot 하위로 강제** (탈출 시도는 에러).
|
|
14
|
+
* - read/list/search 는 always-allow (안전).
|
|
15
|
+
* - write/edit 는 `safeWrite` 가 디스크에 쓰고 onWrite 콜백으로 UI 에 알린다.
|
|
16
|
+
* 승인 게이트를 추후 끼우려면 onWrite 안에서 await 로 막으면 된다.
|
|
17
|
+
* - 모든 결과는 plain JSON 으로 돌려준다 (모델이 다시 추론하기 좋게).
|
|
18
|
+
*
|
|
19
|
+
* 사용:
|
|
20
|
+
* const tools = buildTools({ projectRoot, effective, onEvent });
|
|
21
|
+
* streamText({ tools, stopWhen: stepCountIs(12), ... });
|
|
22
|
+
*/
|
|
23
|
+
export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
24
|
+
const root = path.resolve(projectRoot);
|
|
25
|
+
|
|
26
|
+
function safePath(p) {
|
|
27
|
+
if (!p || typeof p !== 'string') {
|
|
28
|
+
throw new Error('path 가 비어있습니다');
|
|
29
|
+
}
|
|
30
|
+
const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(root, p);
|
|
31
|
+
const rel = path.relative(root, abs);
|
|
32
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
33
|
+
throw new Error(`프로젝트 루트(${root}) 밖의 경로는 접근할 수 없습니다: ${p}`);
|
|
34
|
+
}
|
|
35
|
+
return { abs, rel: rel || '.' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readFile({ path: p }) {
|
|
39
|
+
const { abs, rel } = safePath(p);
|
|
40
|
+
let stat;
|
|
41
|
+
try {
|
|
42
|
+
stat = await fs.stat(abs);
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false, error: `파일이 존재하지 않습니다: ${rel}` };
|
|
45
|
+
}
|
|
46
|
+
if (stat.isDirectory()) {
|
|
47
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
kind: 'directory',
|
|
51
|
+
path: rel,
|
|
52
|
+
entries: entries.map((e) => (e.isDirectory() ? e.name + '/' : e.name)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (stat.size > 256 * 1024) {
|
|
56
|
+
return { ok: false, error: `파일이 너무 큽니다 (${stat.size}B). 256KB 이하만 지원.` };
|
|
57
|
+
}
|
|
58
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
59
|
+
return { ok: true, kind: 'file', path: rel, lines: content.split('\n').length, content };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function listFiles({ pattern = '**/*', limit = 100 }) {
|
|
63
|
+
const matches = await fg(pattern, {
|
|
64
|
+
cwd: root,
|
|
65
|
+
ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', '.bc/**', '.git/**'],
|
|
66
|
+
onlyFiles: false,
|
|
67
|
+
dot: false,
|
|
68
|
+
followSymbolicLinks: false,
|
|
69
|
+
});
|
|
70
|
+
const truncated = matches.length > limit;
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
total: matches.length,
|
|
74
|
+
truncated,
|
|
75
|
+
matches: matches.slice(0, limit),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function searchCode({ query, k = 8 }) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await searchIndex(query, effective, { topK: Math.min(20, Math.max(1, k)), minScore: 0.15 });
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
return { ok: false, error: res.reason ?? 'index 없음 — /index 로 빌드하라고 안내할 것' };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
hits: res.results.map((r) => ({
|
|
88
|
+
file: r.chunk.file,
|
|
89
|
+
range: `${r.chunk.startLine}-${r.chunk.endLine}`,
|
|
90
|
+
score: Number(r.score.toFixed(3)),
|
|
91
|
+
snippet: r.chunk.text.slice(0, 1200),
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeFile({ path: p, content }) {
|
|
100
|
+
const { abs, rel } = safePath(p);
|
|
101
|
+
let existed = false;
|
|
102
|
+
let prevContent = '';
|
|
103
|
+
try {
|
|
104
|
+
prevContent = await fs.readFile(abs, 'utf8');
|
|
105
|
+
existed = true;
|
|
106
|
+
} catch {
|
|
107
|
+
/* 새 파일 */
|
|
108
|
+
}
|
|
109
|
+
if (existed && prevContent === content) {
|
|
110
|
+
onEvent({ kind: 'write_skipped', path: rel, reason: '동일' });
|
|
111
|
+
return { ok: true, path: rel, action: 'noop', reason: '내용 동일' };
|
|
112
|
+
}
|
|
113
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
114
|
+
await fs.writeFile(abs, content, 'utf8');
|
|
115
|
+
onEvent({
|
|
116
|
+
kind: existed ? 'write_overwritten' : 'write_created',
|
|
117
|
+
path: rel,
|
|
118
|
+
lines: content.split('\n').length,
|
|
119
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
path: rel,
|
|
124
|
+
action: existed ? 'overwritten' : 'created',
|
|
125
|
+
lines: content.split('\n').length,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function editFile({ path: p, old_string, new_string }) {
|
|
130
|
+
const { abs, rel } = safePath(p);
|
|
131
|
+
let content;
|
|
132
|
+
try {
|
|
133
|
+
content = await fs.readFile(abs, 'utf8');
|
|
134
|
+
} catch {
|
|
135
|
+
return { ok: false, error: `편집할 파일이 없습니다: ${rel}` };
|
|
136
|
+
}
|
|
137
|
+
if (typeof old_string !== 'string' || old_string.length === 0) {
|
|
138
|
+
return { ok: false, error: 'old_string 이 비어있습니다' };
|
|
139
|
+
}
|
|
140
|
+
// 정확 일치 횟수 계산
|
|
141
|
+
let count = 0;
|
|
142
|
+
let idx = 0;
|
|
143
|
+
while ((idx = content.indexOf(old_string, idx)) !== -1) {
|
|
144
|
+
count++;
|
|
145
|
+
idx += old_string.length;
|
|
146
|
+
}
|
|
147
|
+
if (count === 0) {
|
|
148
|
+
return { ok: false, error: `old_string 을 ${rel} 에서 찾지 못했습니다. 주변 라인을 더 포함해서 다시 시도.` };
|
|
149
|
+
}
|
|
150
|
+
if (count > 1) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `old_string 이 ${rel} 에 ${count}번 등장합니다. 더 많은 컨텍스트로 유일해지게 만들어 주세요.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const updated = content.replace(old_string, new_string);
|
|
157
|
+
await fs.writeFile(abs, updated, 'utf8');
|
|
158
|
+
onEvent({
|
|
159
|
+
kind: 'edit',
|
|
160
|
+
path: rel,
|
|
161
|
+
removed: old_string.split('\n').length,
|
|
162
|
+
added: new_string.split('\n').length,
|
|
163
|
+
});
|
|
164
|
+
return { ok: true, path: rel, action: 'edited' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
read_file: tool({
|
|
169
|
+
description:
|
|
170
|
+
'프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
|
|
174
|
+
required: ['path'],
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
},
|
|
177
|
+
execute: readFile,
|
|
178
|
+
}),
|
|
179
|
+
list_files: tool({
|
|
180
|
+
description:
|
|
181
|
+
'글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
pattern: { type: 'string', default: '**/*' },
|
|
186
|
+
limit: { type: 'number', default: 100 },
|
|
187
|
+
},
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
},
|
|
190
|
+
execute: listFiles,
|
|
191
|
+
}),
|
|
192
|
+
search_code: tool({
|
|
193
|
+
description:
|
|
194
|
+
'코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
query: { type: 'string' },
|
|
199
|
+
k: { type: 'number', default: 8 },
|
|
200
|
+
},
|
|
201
|
+
required: ['query'],
|
|
202
|
+
additionalProperties: false,
|
|
203
|
+
},
|
|
204
|
+
execute: searchCode,
|
|
205
|
+
}),
|
|
206
|
+
write_file: tool({
|
|
207
|
+
description:
|
|
208
|
+
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
path: { type: 'string' },
|
|
213
|
+
content: { type: 'string' },
|
|
214
|
+
},
|
|
215
|
+
required: ['path', 'content'],
|
|
216
|
+
additionalProperties: false,
|
|
217
|
+
},
|
|
218
|
+
execute: writeFile,
|
|
219
|
+
}),
|
|
220
|
+
edit_file: tool({
|
|
221
|
+
description:
|
|
222
|
+
'기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
|
|
223
|
+
inputSchema: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: {
|
|
226
|
+
path: { type: 'string' },
|
|
227
|
+
old_string: { type: 'string' },
|
|
228
|
+
new_string: { type: 'string' },
|
|
229
|
+
},
|
|
230
|
+
required: ['path', 'old_string', 'new_string'],
|
|
231
|
+
additionalProperties: false,
|
|
232
|
+
},
|
|
233
|
+
execute: editFile,
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
}
|
package/src/commands/chat.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import { streamText } from 'ai';
|
|
4
|
+
import { streamText, stepCountIs } from 'ai';
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
|
|
6
8
|
import { loadEffectiveConfig } from '../config/index.js';
|
|
7
9
|
import { resolveModel } from '../ai/provider.js';
|
|
8
10
|
import { TokenMeter } from '../ai/tokenMeter.js';
|
|
9
11
|
import { buildSystemPrompt } from '../ai/systemPrompt.js';
|
|
10
12
|
import { findModel } from '../ai/models.js';
|
|
13
|
+
import { buildTools } from '../ai/tools.js';
|
|
11
14
|
import {
|
|
12
15
|
createSession,
|
|
13
16
|
saveSession,
|
|
@@ -189,15 +192,39 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
|
|
|
189
192
|
|
|
190
193
|
async function runOnce({ cfg, resolved, system, prompt }) {
|
|
191
194
|
const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
|
|
195
|
+
const projectRoot = cfg.paths.projectFile
|
|
196
|
+
? path.dirname(cfg.paths.projectFile)
|
|
197
|
+
: process.cwd();
|
|
198
|
+
const tools = buildTools({
|
|
199
|
+
projectRoot,
|
|
200
|
+
effective: cfg.effective,
|
|
201
|
+
onEvent: (ev) => {
|
|
202
|
+
const label =
|
|
203
|
+
ev.kind === 'write_created'
|
|
204
|
+
? '🆕'
|
|
205
|
+
: ev.kind === 'write_overwritten'
|
|
206
|
+
? '✏️ '
|
|
207
|
+
: ev.kind === 'edit'
|
|
208
|
+
? '✏️ '
|
|
209
|
+
: '·';
|
|
210
|
+
console.log(chalk.dim(` ${label} ${ev.path}`));
|
|
211
|
+
},
|
|
212
|
+
});
|
|
192
213
|
const result = streamText({
|
|
193
214
|
model: resolved.model,
|
|
194
215
|
system,
|
|
195
216
|
messages: [{ role: 'user', content: prompt }],
|
|
217
|
+
tools,
|
|
218
|
+
stopWhen: stepCountIs(12),
|
|
196
219
|
onError: ({ error }) => {
|
|
197
220
|
console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
|
|
198
221
|
},
|
|
199
222
|
});
|
|
200
|
-
for await (const
|
|
223
|
+
for await (const part of result.fullStream) {
|
|
224
|
+
if (part.type === 'text-delta') process.stdout.write(part.text);
|
|
225
|
+
else if (part.type === 'tool-call')
|
|
226
|
+
process.stdout.write(chalk.dim(`\n 🔧 ${part.toolName}\n`));
|
|
227
|
+
}
|
|
201
228
|
process.stdout.write('\n');
|
|
202
229
|
try {
|
|
203
230
|
meter.add(await result.usage);
|
|
@@ -257,10 +284,30 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
257
284
|
history.push({ role: 'user', content: line });
|
|
258
285
|
rl.pause();
|
|
259
286
|
|
|
287
|
+
const projectRoot = cfg.paths.projectFile
|
|
288
|
+
? path.dirname(cfg.paths.projectFile)
|
|
289
|
+
: process.cwd();
|
|
290
|
+
const tools = buildTools({
|
|
291
|
+
projectRoot,
|
|
292
|
+
effective: cfg.effective,
|
|
293
|
+
onEvent: (ev) => {
|
|
294
|
+
const label =
|
|
295
|
+
ev.kind === 'write_created'
|
|
296
|
+
? '🆕 생성'
|
|
297
|
+
: ev.kind === 'write_overwritten'
|
|
298
|
+
? '✏️ 덮어씀'
|
|
299
|
+
: ev.kind === 'edit'
|
|
300
|
+
? '✏️ 편집'
|
|
301
|
+
: '·';
|
|
302
|
+
console.log(chalk.dim(`\n ${label} ${ev.path}`));
|
|
303
|
+
},
|
|
304
|
+
});
|
|
260
305
|
const result = streamText({
|
|
261
306
|
model: resolved.model,
|
|
262
307
|
system,
|
|
263
308
|
messages: history,
|
|
309
|
+
tools,
|
|
310
|
+
stopWhen: stepCountIs(12),
|
|
264
311
|
onError: ({ error }) => {
|
|
265
312
|
console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
|
|
266
313
|
},
|
|
@@ -268,9 +315,13 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
268
315
|
process.stdout.write(chalk.bold.green('\n bc › '));
|
|
269
316
|
let acc = '';
|
|
270
317
|
try {
|
|
271
|
-
for await (const
|
|
272
|
-
|
|
273
|
-
|
|
318
|
+
for await (const part of result.fullStream) {
|
|
319
|
+
if (part.type === 'text-delta') {
|
|
320
|
+
acc += part.text;
|
|
321
|
+
process.stdout.write(part.text);
|
|
322
|
+
} else if (part.type === 'tool-call') {
|
|
323
|
+
process.stdout.write(chalk.dim(`\n 🔧 ${part.toolName}`));
|
|
324
|
+
}
|
|
274
325
|
}
|
|
275
326
|
} catch (err) {
|
|
276
327
|
console.error('\n' + chalk.red(' 스트리밍 중단: ') + (err?.message ?? err));
|
package/src/ui/ChatApp.js
CHANGED
|
@@ -5,17 +5,45 @@ import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
|
5
5
|
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
6
6
|
import TextInput from 'ink-text-input';
|
|
7
7
|
import Spinner from 'ink-spinner';
|
|
8
|
-
import { streamText } from 'ai';
|
|
8
|
+
import { streamText, stepCountIs } from 'ai';
|
|
9
9
|
|
|
10
10
|
import { resolveModel } from '../ai/provider.js';
|
|
11
11
|
import { TokenMeter } from '../ai/tokenMeter.js';
|
|
12
12
|
import { findModel, MODEL_CATALOG } from '../ai/models.js';
|
|
13
13
|
import { toSdkMessages, isImagePath } from '../ai/messageContent.js';
|
|
14
|
+
import { buildTools } from '../ai/tools.js';
|
|
14
15
|
import { searchIndex } from '../indexer/search.js';
|
|
15
16
|
import { loadIndex, buildIndex } from '../indexer/store.js';
|
|
16
17
|
|
|
17
18
|
const h = React.createElement;
|
|
18
19
|
|
|
20
|
+
/** 툴 호출의 input 을 채팅 한 줄에 보여줄 수 있게 압축. content 같은 대형 필드는 길이만 표시. */
|
|
21
|
+
function summarizeToolInput(name, input) {
|
|
22
|
+
if (!input || typeof input !== 'object') return '';
|
|
23
|
+
switch (name) {
|
|
24
|
+
case 'read_file':
|
|
25
|
+
case 'write_file':
|
|
26
|
+
case 'edit_file':
|
|
27
|
+
return JSON.stringify(input.path ?? '');
|
|
28
|
+
case 'list_files':
|
|
29
|
+
return JSON.stringify(input.pattern ?? '**/*');
|
|
30
|
+
case 'search_code':
|
|
31
|
+
return JSON.stringify(input.query ?? '');
|
|
32
|
+
default:
|
|
33
|
+
// 일반 케이스 — 너무 긴 필드는 잘라낸다.
|
|
34
|
+
try {
|
|
35
|
+
const small = {};
|
|
36
|
+
for (const [k, v] of Object.entries(input)) {
|
|
37
|
+
if (typeof v === 'string' && v.length > 60) small[k] = v.slice(0, 60) + '…';
|
|
38
|
+
else small[k] = v;
|
|
39
|
+
}
|
|
40
|
+
return JSON.stringify(small);
|
|
41
|
+
} catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
/**
|
|
20
48
|
* macOS 클립보드의 이미지(예: 스크린샷)를 임시 파일로 떨궈 절대경로를 돌려준다.
|
|
21
49
|
*
|
|
@@ -673,10 +701,45 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
673
701
|
|
|
674
702
|
try {
|
|
675
703
|
const sdkMessages = await toSdkMessages(newMessages);
|
|
704
|
+
|
|
705
|
+
// 프로젝트 루트 결정: bc.config.json 이 있는 디렉터리, 없으면 cwd.
|
|
706
|
+
const projectRoot = cfg.paths.projectFile
|
|
707
|
+
? path.dirname(cfg.paths.projectFile)
|
|
708
|
+
: process.cwd();
|
|
709
|
+
|
|
710
|
+
// 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
|
|
711
|
+
const onToolEvent = (ev) => {
|
|
712
|
+
const labels = {
|
|
713
|
+
write_created: '🆕 생성',
|
|
714
|
+
write_overwritten: '✏️ 덮어씀',
|
|
715
|
+
write_skipped: '⏭ 스킵',
|
|
716
|
+
edit: '✏️ 편집',
|
|
717
|
+
};
|
|
718
|
+
const label = labels[ev.kind] ?? ev.kind;
|
|
719
|
+
const detail =
|
|
720
|
+
ev.lines != null
|
|
721
|
+
? `(${ev.lines} lines)`
|
|
722
|
+
: ev.added != null
|
|
723
|
+
? `(+${ev.added} / -${ev.removed} lines)`
|
|
724
|
+
: '';
|
|
725
|
+
setMessages((m) => [
|
|
726
|
+
...m,
|
|
727
|
+
{ role: 'system-info', text: `${label} ${ev.path} ${detail}`.trim() },
|
|
728
|
+
]);
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const tools = buildTools({
|
|
732
|
+
projectRoot,
|
|
733
|
+
effective: cfg.effective,
|
|
734
|
+
onEvent: onToolEvent,
|
|
735
|
+
});
|
|
736
|
+
|
|
676
737
|
const result = streamText({
|
|
677
738
|
model: resolved.model,
|
|
678
739
|
system: systemWithContext,
|
|
679
740
|
messages: sdkMessages,
|
|
741
|
+
tools,
|
|
742
|
+
stopWhen: stepCountIs(12),
|
|
680
743
|
onError: ({ error }) => {
|
|
681
744
|
replaceWithError('AI 호출 에러: ' + (error?.message ?? String(error)));
|
|
682
745
|
},
|
|
@@ -685,13 +748,36 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
685
748
|
setState('streaming');
|
|
686
749
|
let acc = '';
|
|
687
750
|
try {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
751
|
+
// fullStream 으로 tool-call / tool-result / text-delta 다 다룸.
|
|
752
|
+
for await (const part of result.fullStream) {
|
|
753
|
+
if (part.type === 'text-delta') {
|
|
754
|
+
acc += part.text;
|
|
755
|
+
setMessages((m) => {
|
|
756
|
+
const next = [...m];
|
|
757
|
+
next[assistantIdx] = { role: 'assistant', text: acc, streaming: true };
|
|
758
|
+
return next;
|
|
759
|
+
});
|
|
760
|
+
} else if (part.type === 'tool-call') {
|
|
761
|
+
// 모델이 툴을 호출하는 순간 — 한 줄로 표시.
|
|
762
|
+
const argSummary = summarizeToolInput(part.toolName, part.input);
|
|
763
|
+
setMessages((m) => [
|
|
764
|
+
...m,
|
|
765
|
+
{
|
|
766
|
+
role: 'system-info',
|
|
767
|
+
text: `🔧 ${part.toolName}(${argSummary})`,
|
|
768
|
+
},
|
|
769
|
+
]);
|
|
770
|
+
} else if (part.type === 'tool-error') {
|
|
771
|
+
setMessages((m) => [
|
|
772
|
+
...m,
|
|
773
|
+
{
|
|
774
|
+
role: 'system-error',
|
|
775
|
+
text: `툴 에러 (${part.toolName ?? '?'}): ${part.error?.message ?? part.error}`,
|
|
776
|
+
},
|
|
777
|
+
]);
|
|
778
|
+
} else if (part.type === 'error') {
|
|
779
|
+
replaceWithError('스트림 에러: ' + (part.error?.message ?? String(part.error)));
|
|
780
|
+
}
|
|
695
781
|
}
|
|
696
782
|
} catch (streamErr) {
|
|
697
783
|
replaceWithError('스트리밍 중단: ' + (streamErr?.message ?? String(streamErr)));
|
|
@@ -700,14 +786,26 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
|
|
|
700
786
|
// 에러가 안 났을 때만 final assistant 로 마무리.
|
|
701
787
|
if (!errorText) {
|
|
702
788
|
if (acc.length === 0) {
|
|
703
|
-
//
|
|
789
|
+
// 텍스트가 없어도 툴만 호출하고 끝났을 수 있음 — 그건 정상.
|
|
790
|
+
// finishReason 으로 진짜 비정상인지 분기.
|
|
704
791
|
let reason = 'unknown';
|
|
705
792
|
try {
|
|
706
793
|
reason = await result.finishReason;
|
|
707
794
|
} catch {
|
|
708
795
|
/* noop */
|
|
709
796
|
}
|
|
710
|
-
|
|
797
|
+
if (reason === 'tool-calls' || reason === 'stop') {
|
|
798
|
+
// 툴만 호출하고 자연스럽게 멈춤 → placeholder 제거.
|
|
799
|
+
setMessages((m) => {
|
|
800
|
+
const next = [...m];
|
|
801
|
+
if (next[assistantIdx]?.role === 'assistant' && !next[assistantIdx].text) {
|
|
802
|
+
next.splice(assistantIdx, 1);
|
|
803
|
+
}
|
|
804
|
+
return next;
|
|
805
|
+
});
|
|
806
|
+
} else {
|
|
807
|
+
replaceWithError(`빈 응답 (finishReason=${reason}). API 키/크레딧/모델을 확인하세요.`);
|
|
808
|
+
}
|
|
711
809
|
} else {
|
|
712
810
|
setMessages((m) => {
|
|
713
811
|
const next = [...m];
|