byuckchon-frontend-cli 1.6.0 → 1.6.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 +11 -2
- package/bin/index.js +1 -1
- package/package.json +1 -1
- package/src/ai/clipboard.js +75 -0
- package/src/ai/imagePreview.js +79 -0
- package/src/ai/tools.js +17 -17
- package/src/commands/chat.js +95 -3
package/README.md
CHANGED
|
@@ -264,12 +264,20 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
|
|
|
264
264
|
이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
|
|
265
265
|
Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
|
|
266
266
|
|
|
267
|
-
**이미지 첨부 3가지 방법:**
|
|
267
|
+
**이미지 첨부 3가지 방법:** (ink·plain 모드 모두 지원 — v1.6.1+)
|
|
268
268
|
1. `/image ./shot.png` — 경로 직접
|
|
269
269
|
2. **드래그 & 드롭** — `/image ` 까지 입력 후, Finder 에서 파일을 터미널 위로 끌어다 놓으면 절대경로가 자동 입력됩니다. Enter.
|
|
270
|
-
3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
|
|
270
|
+
3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷 또는 Finder 에서 `Cmd+C` 한 이미지)를 바로 첨부.
|
|
271
271
|
- 사전에 `brew install pngpaste` 한 번 필요.
|
|
272
272
|
|
|
273
|
+
> **터미널에서 `Cmd+V` 로 직접 붙이기는 왜 안 되나?** 터미널 앱은 클립보드의 "이미지 바이트" 를
|
|
274
|
+
> 앱에 전달하지 않고 텍스트만 줍니다 (OS/터미널 공통 제약). 그래서 클립보드 이미지를 붙이려면
|
|
275
|
+
> `/paste` 가 `pngpaste` 로 클립보드를 직접 읽어 첨부합니다 — `Cmd+C` → 입력창에 `/paste` → Enter.
|
|
276
|
+
|
|
277
|
+
> **썸네일 미리보기:** iTerm2 · kitty · WezTerm 에서는 plain 모드에서 첨부 직후 작은 썸네일이
|
|
278
|
+
> 인라인으로 표시됩니다. 그 외 터미널은 파일명 + 용량만 표시됩니다 (터미널이 이미지 렌더링을
|
|
279
|
+
> 지원하지 않기 때문).
|
|
280
|
+
|
|
273
281
|
### 한글 입력이 자꾸 씹힐 때 (v1.6+)
|
|
274
282
|
|
|
275
283
|
`ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
|
|
@@ -342,6 +350,7 @@ bc config set-ui ink # 풀 TUI 복귀
|
|
|
342
350
|
- [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
|
|
343
351
|
- [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
|
|
344
352
|
- [x] v1.6.0 — Figma 툴 (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
|
|
353
|
+
- [x] v1.6.1 — 툴 스키마 `jsonSchema()` 래핑 (`schema is not a function` 수정), plain 모드 이미지 첨부(`/image`·`/paste`) + iTerm2/kitty 인라인 썸네일
|
|
345
354
|
- [ ] v1.7.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
|
|
346
355
|
- [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
|
|
347
356
|
- [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
|
package/bin/index.js
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* macOS 클립보드의 이미지(스크린샷 등)를 임시 PNG 로 떨궈 절대경로를 돌려준다.
|
|
8
|
+
*
|
|
9
|
+
* 터미널 앱은 Cmd+V 로 "이미지 바이트" 를 받지 못한다 (텍스트만 받음). 그래서
|
|
10
|
+
* 클립보드 이미지를 붙이려면 OS 클립보드를 직접 읽는 외부 도구가 필요하다.
|
|
11
|
+
* - macOS: `pngpaste` (brew install pngpaste)
|
|
12
|
+
*
|
|
13
|
+
* 다른 OS 거나 도구가 없으면 도움이 되는 에러로 throw.
|
|
14
|
+
*/
|
|
15
|
+
export async function pasteClipboardImage() {
|
|
16
|
+
if (process.platform !== 'darwin') {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'클립보드 이미지 붙여넣기는 현재 macOS 만 지원합니다. 다른 OS 에서는 파일로 저장하고 /image <경로> 를 쓰세요.',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const checkOk = await new Promise((resolve) => {
|
|
23
|
+
const p = spawn('which', ['pngpaste']);
|
|
24
|
+
p.on('close', (code) => resolve(code === 0));
|
|
25
|
+
p.on('error', () => resolve(false));
|
|
26
|
+
});
|
|
27
|
+
if (!checkOk) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'pngpaste 가 필요합니다. 설치: `brew install pngpaste` (Homebrew 가 없으면 https://brew.sh)',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tmp = path.join(os.tmpdir(), `bc-paste-${Date.now()}.png`);
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const p = spawn('pngpaste', [tmp]);
|
|
36
|
+
let stderr = '';
|
|
37
|
+
p.stderr.on('data', (d) => (stderr += d.toString()));
|
|
38
|
+
p.on('close', (code) => {
|
|
39
|
+
if (code === 0) resolve();
|
|
40
|
+
else
|
|
41
|
+
reject(
|
|
42
|
+
new Error(
|
|
43
|
+
'클립보드에 이미지가 없거나 읽을 수 없습니다.' +
|
|
44
|
+
(stderr ? ' (' + stderr.trim() + ')' : ''),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
p.on('error', (e) => reject(e));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const stat = await fs.stat(tmp);
|
|
52
|
+
if (!stat.isFile() || stat.size === 0) {
|
|
53
|
+
throw new Error('클립보드 이미지 저장 실패 (파일이 비어있음).');
|
|
54
|
+
}
|
|
55
|
+
return tmp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 드래그&드롭 / 붙여넣기로 들어온 경로 문자열을 정리한다.
|
|
60
|
+
* - 앞뒤 따옴표 제거
|
|
61
|
+
* - 백슬래시 이스케이프(`\ `) 제거
|
|
62
|
+
* - 양끝 공백 제거
|
|
63
|
+
*/
|
|
64
|
+
export function cleanDroppedPath(raw) {
|
|
65
|
+
if (!raw) return '';
|
|
66
|
+
let s = raw.trim();
|
|
67
|
+
if (
|
|
68
|
+
(s.startsWith("'") && s.endsWith("'")) ||
|
|
69
|
+
(s.startsWith('"') && s.endsWith('"'))
|
|
70
|
+
) {
|
|
71
|
+
s = s.slice(1, -1);
|
|
72
|
+
}
|
|
73
|
+
s = s.replace(/\\ /g, ' ');
|
|
74
|
+
return s.trim();
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 터미널 인라인 이미지 프리뷰.
|
|
6
|
+
*
|
|
7
|
+
* 일반 터미널은 이미지를 못 그린다. 하지만 일부는 자체 프로토콜이 있다:
|
|
8
|
+
* - iTerm2 : OSC 1337 ; File=... (base64) BEL
|
|
9
|
+
* - kitty : APC _G ... ST (kitty graphics protocol)
|
|
10
|
+
* - WezTerm : iTerm2 프로토콜 호환
|
|
11
|
+
*
|
|
12
|
+
* 지원되면 작은 썸네일을 직접 stdout 에 그려준다. 안 되면 false 반환 → 호출측에서
|
|
13
|
+
* 파일명/용량만 텍스트로 표시.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ ink TUI 안에서는 re-render 가 escape 시퀀스를 덮어쓰므로 쓰지 않는다.
|
|
16
|
+
* readline(plain) 모드처럼 stdout 을 직접 쓰는 곳에서만 사용.
|
|
17
|
+
*/
|
|
18
|
+
export function detectInlineImageSupport() {
|
|
19
|
+
const term = process.env.TERM ?? '';
|
|
20
|
+
const prog = process.env.TERM_PROGRAM ?? '';
|
|
21
|
+
if (prog === 'iTerm.app' || prog === 'WezTerm') return 'iterm2';
|
|
22
|
+
if (term.includes('kitty') || process.env.KITTY_WINDOW_ID) return 'kitty';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ESC = '\u001B';
|
|
27
|
+
const BEL = '\u0007';
|
|
28
|
+
|
|
29
|
+
/** iTerm2 inline image. heightCells 만큼만 차지하도록 제한해 작은 썸네일로. */
|
|
30
|
+
function itermInline(base64, { heightCells = 6 } = {}) {
|
|
31
|
+
const args = [
|
|
32
|
+
'inline=1',
|
|
33
|
+
`height=${heightCells}`,
|
|
34
|
+
'preserveAspectRatio=1',
|
|
35
|
+
].join(';');
|
|
36
|
+
return `${ESC}]1337;File=${args}:${base64}${BEL}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** kitty graphics — 한 번에 전송(a=T), 직접 표시. */
|
|
40
|
+
function kittyInline(base64) {
|
|
41
|
+
// kitty 는 4096 바이트 청크로 쪼개야 한다.
|
|
42
|
+
const CHUNK = 4096;
|
|
43
|
+
let out = '';
|
|
44
|
+
let first = true;
|
|
45
|
+
for (let i = 0; i < base64.length; i += CHUNK) {
|
|
46
|
+
const piece = base64.slice(i, i + CHUNK);
|
|
47
|
+
const more = i + CHUNK < base64.length ? 1 : 0;
|
|
48
|
+
if (first) {
|
|
49
|
+
out += `${ESC}_Ga=T,f=100,m=${more};${piece}${ESC}\\`;
|
|
50
|
+
first = false;
|
|
51
|
+
} else {
|
|
52
|
+
out += `${ESC}_Gm=${more};${piece}${ESC}\\`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 이미지 파일을 작은 썸네일로 터미널에 그린다.
|
|
60
|
+
* @returns {Promise<boolean>} 그렸으면 true, 미지원이면 false
|
|
61
|
+
*/
|
|
62
|
+
export async function printInlineThumbnail(filePath, { heightCells = 6 } = {}) {
|
|
63
|
+
const kind = detectInlineImageSupport();
|
|
64
|
+
if (!kind) return false;
|
|
65
|
+
try {
|
|
66
|
+
const buf = await fs.readFile(path.resolve(filePath));
|
|
67
|
+
// 너무 크면 프리뷰 스킵 (전송 비용). 2MB 초과면 패스.
|
|
68
|
+
if (buf.byteLength > 2 * 1024 * 1024) return false;
|
|
69
|
+
const base64 = buf.toString('base64');
|
|
70
|
+
if (kind === 'iterm2') {
|
|
71
|
+
process.stdout.write(' ' + itermInline(base64, { heightCells }) + '\n');
|
|
72
|
+
} else if (kind === 'kitty') {
|
|
73
|
+
process.stdout.write(' ' + kittyInline(base64) + '\n');
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/ai/tools.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import fg from 'fast-glob';
|
|
5
|
-
import { tool } from 'ai';
|
|
5
|
+
import { tool, jsonSchema } from 'ai';
|
|
6
6
|
|
|
7
7
|
import { searchIndex } from '../indexer/search.js';
|
|
8
8
|
import {
|
|
@@ -260,31 +260,31 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
260
260
|
read_file: tool({
|
|
261
261
|
description:
|
|
262
262
|
'프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
|
|
263
|
-
inputSchema: {
|
|
263
|
+
inputSchema: jsonSchema({
|
|
264
264
|
type: 'object',
|
|
265
265
|
properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
|
|
266
266
|
required: ['path'],
|
|
267
267
|
additionalProperties: false,
|
|
268
|
-
},
|
|
268
|
+
}),
|
|
269
269
|
execute: readFile,
|
|
270
270
|
}),
|
|
271
271
|
list_files: tool({
|
|
272
272
|
description:
|
|
273
273
|
'글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
|
|
274
|
-
inputSchema: {
|
|
274
|
+
inputSchema: jsonSchema({
|
|
275
275
|
type: 'object',
|
|
276
276
|
properties: {
|
|
277
277
|
pattern: { type: 'string', default: '**/*' },
|
|
278
278
|
limit: { type: 'number', default: 100 },
|
|
279
279
|
},
|
|
280
280
|
additionalProperties: false,
|
|
281
|
-
},
|
|
281
|
+
}),
|
|
282
282
|
execute: listFiles,
|
|
283
283
|
}),
|
|
284
284
|
search_code: tool({
|
|
285
285
|
description:
|
|
286
286
|
'코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
|
|
287
|
-
inputSchema: {
|
|
287
|
+
inputSchema: jsonSchema({
|
|
288
288
|
type: 'object',
|
|
289
289
|
properties: {
|
|
290
290
|
query: { type: 'string' },
|
|
@@ -292,13 +292,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
292
292
|
},
|
|
293
293
|
required: ['query'],
|
|
294
294
|
additionalProperties: false,
|
|
295
|
-
},
|
|
295
|
+
}),
|
|
296
296
|
execute: searchCode,
|
|
297
297
|
}),
|
|
298
298
|
write_file: tool({
|
|
299
299
|
description:
|
|
300
300
|
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
|
301
|
-
inputSchema: {
|
|
301
|
+
inputSchema: jsonSchema({
|
|
302
302
|
type: 'object',
|
|
303
303
|
properties: {
|
|
304
304
|
path: { type: 'string' },
|
|
@@ -306,13 +306,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
306
306
|
},
|
|
307
307
|
required: ['path', 'content'],
|
|
308
308
|
additionalProperties: false,
|
|
309
|
-
},
|
|
309
|
+
}),
|
|
310
310
|
execute: writeFile,
|
|
311
311
|
}),
|
|
312
312
|
edit_file: tool({
|
|
313
313
|
description:
|
|
314
314
|
'기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
|
|
315
|
-
inputSchema: {
|
|
315
|
+
inputSchema: jsonSchema({
|
|
316
316
|
type: 'object',
|
|
317
317
|
properties: {
|
|
318
318
|
path: { type: 'string' },
|
|
@@ -321,7 +321,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
321
321
|
},
|
|
322
322
|
required: ['path', 'old_string', 'new_string'],
|
|
323
323
|
additionalProperties: false,
|
|
324
|
-
},
|
|
324
|
+
}),
|
|
325
325
|
execute: editFile,
|
|
326
326
|
}),
|
|
327
327
|
fetch_figma: tool({
|
|
@@ -330,7 +330,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
330
330
|
'간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
|
|
331
331
|
'없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
|
|
332
332
|
'디자인 의도를 학습한 뒤 코드를 짠다.',
|
|
333
|
-
inputSchema: {
|
|
333
|
+
inputSchema: jsonSchema({
|
|
334
334
|
type: 'object',
|
|
335
335
|
properties: {
|
|
336
336
|
url: { type: 'string', description: 'Figma share/copy link' },
|
|
@@ -338,14 +338,14 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
338
338
|
},
|
|
339
339
|
required: ['url'],
|
|
340
340
|
additionalProperties: false,
|
|
341
|
-
},
|
|
341
|
+
}),
|
|
342
342
|
execute: fetchFigma,
|
|
343
343
|
}),
|
|
344
344
|
fetch_figma_image: tool({
|
|
345
345
|
description:
|
|
346
346
|
'Figma 프레임을 PNG/JPG/SVG 이미지로 export. savePath 를 주면 프로젝트 폴더 안에 파일로 저장 ' +
|
|
347
347
|
'(스토리북 배경, public asset 등). 안 주면 임시 URL 만 반환.',
|
|
348
|
-
inputSchema: {
|
|
348
|
+
inputSchema: jsonSchema({
|
|
349
349
|
type: 'object',
|
|
350
350
|
properties: {
|
|
351
351
|
url: { type: 'string' },
|
|
@@ -355,19 +355,19 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
355
355
|
},
|
|
356
356
|
required: ['url'],
|
|
357
357
|
additionalProperties: false,
|
|
358
|
-
},
|
|
358
|
+
}),
|
|
359
359
|
execute: fetchFigmaImage,
|
|
360
360
|
}),
|
|
361
361
|
fetch_figma_styles: tool({
|
|
362
362
|
description:
|
|
363
363
|
'Figma 파일의 로컬 스타일(컬러/타이포/이펙트 토큰) 목록을 가져온다. 디자인 토큰 추출 / ' +
|
|
364
364
|
'Tailwind 테마 설정 / theme.ts 생성 시 사용.',
|
|
365
|
-
inputSchema: {
|
|
365
|
+
inputSchema: jsonSchema({
|
|
366
366
|
type: 'object',
|
|
367
367
|
properties: { url: { type: 'string' } },
|
|
368
368
|
required: ['url'],
|
|
369
369
|
additionalProperties: false,
|
|
370
|
-
},
|
|
370
|
+
}),
|
|
371
371
|
execute: fetchFigmaStylesTool,
|
|
372
372
|
}),
|
|
373
373
|
};
|
package/src/commands/chat.js
CHANGED
|
@@ -11,6 +11,9 @@ import { TokenMeter } from '../ai/tokenMeter.js';
|
|
|
11
11
|
import { buildSystemPrompt } from '../ai/systemPrompt.js';
|
|
12
12
|
import { findModel } from '../ai/models.js';
|
|
13
13
|
import { buildTools } from '../ai/tools.js';
|
|
14
|
+
import { toSdkMessages, isImagePath, imagePartFromFile } from '../ai/messageContent.js';
|
|
15
|
+
import { pasteClipboardImage, cleanDroppedPath } from '../ai/clipboard.js';
|
|
16
|
+
import { printInlineThumbnail } from '../ai/imagePreview.js';
|
|
14
17
|
import {
|
|
15
18
|
createSession,
|
|
16
19
|
saveSession,
|
|
@@ -254,7 +257,46 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
254
257
|
if (session?.messages?.length) {
|
|
255
258
|
console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
|
|
256
259
|
}
|
|
257
|
-
console.log(
|
|
260
|
+
console.log(
|
|
261
|
+
chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// 다음 메시지에 함께 보낼 이미지 첨부 목록.
|
|
265
|
+
let pendingAttachments = [];
|
|
266
|
+
|
|
267
|
+
const printAttachments = () => {
|
|
268
|
+
if (!pendingAttachments.length) {
|
|
269
|
+
console.log(chalk.dim(' 첨부 없음.'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
console.log(chalk.dim(' 현재 첨부:'));
|
|
273
|
+
for (const a of pendingAttachments) {
|
|
274
|
+
console.log(chalk.dim(` - ${path.basename(a.path)} (${a.sizeKb}KB)`));
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const addImage = async (rawPath) => {
|
|
279
|
+
const p = cleanDroppedPath(rawPath);
|
|
280
|
+
if (!p) {
|
|
281
|
+
console.log(chalk.red(' 사용법: /image <파일 경로>'));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!isImagePath(p)) {
|
|
285
|
+
console.log(chalk.red(' 지원 확장자: png, jpg, jpeg, gif, webp'));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const { default: fsp } = await import('node:fs/promises');
|
|
290
|
+
const stat = await fsp.stat(path.resolve(p));
|
|
291
|
+
const sizeKb = Math.max(1, Math.round(stat.size / 1024));
|
|
292
|
+
pendingAttachments.push({ kind: 'image', path: path.resolve(p), sizeKb });
|
|
293
|
+
console.log(chalk.green(` 📎 첨부: ${path.basename(p)} (${sizeKb}KB)`));
|
|
294
|
+
// iTerm2 / kitty / WezTerm 이면 작은 썸네일을 바로 보여준다.
|
|
295
|
+
await printInlineThumbnail(path.resolve(p), { heightCells: 6 });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.log(chalk.red(' 이미지 읽기 실패: ' + (e?.message ?? e)));
|
|
298
|
+
}
|
|
299
|
+
};
|
|
258
300
|
|
|
259
301
|
const rl = readline.createInterface({
|
|
260
302
|
input: process.stdin,
|
|
@@ -284,7 +326,50 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
284
326
|
rl.close();
|
|
285
327
|
return;
|
|
286
328
|
}
|
|
287
|
-
|
|
329
|
+
// 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
|
|
330
|
+
if (line.startsWith('/image')) {
|
|
331
|
+
await addImage(line.slice('/image'.length).trim());
|
|
332
|
+
ask();
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (line === '/paste') {
|
|
336
|
+
try {
|
|
337
|
+
const file = await pasteClipboardImage();
|
|
338
|
+
await addImage(file);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.log(chalk.red(' ' + (e?.message ?? e)));
|
|
341
|
+
}
|
|
342
|
+
ask();
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (line === '/attachments') {
|
|
346
|
+
printAttachments();
|
|
347
|
+
ask();
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (line === '/clear-attach') {
|
|
351
|
+
pendingAttachments = [];
|
|
352
|
+
console.log(chalk.dim(' 첨부를 비웠습니다.'));
|
|
353
|
+
ask();
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
|
|
358
|
+
if (pendingAttachments.length) {
|
|
359
|
+
const parts = [{ type: 'text', text: line }];
|
|
360
|
+
try {
|
|
361
|
+
for (const att of pendingAttachments) {
|
|
362
|
+
parts.push(await imagePartFromFile(att.path));
|
|
363
|
+
}
|
|
364
|
+
history.push({ role: 'user', content: parts });
|
|
365
|
+
} catch (e) {
|
|
366
|
+
console.log(chalk.red(' 첨부 처리 실패: ' + (e?.message ?? e)));
|
|
367
|
+
history.push({ role: 'user', content: line });
|
|
368
|
+
}
|
|
369
|
+
pendingAttachments = [];
|
|
370
|
+
} else {
|
|
371
|
+
history.push({ role: 'user', content: line });
|
|
372
|
+
}
|
|
288
373
|
rl.pause();
|
|
289
374
|
|
|
290
375
|
const projectRoot = cfg.paths.projectFile
|
|
@@ -340,7 +425,14 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
340
425
|
|
|
341
426
|
// 세션 자동 저장 — readline 모드에서도 끊김 대비.
|
|
342
427
|
if (session) {
|
|
343
|
-
session.messages = history.map((h) =>
|
|
428
|
+
session.messages = history.map((h) => {
|
|
429
|
+
// 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
|
|
430
|
+
if (Array.isArray(h.content)) {
|
|
431
|
+
const textPart = h.content.find((p) => p.type === 'text');
|
|
432
|
+
return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
|
|
433
|
+
}
|
|
434
|
+
return { role: h.role, text: h.content };
|
|
435
|
+
});
|
|
344
436
|
try {
|
|
345
437
|
await saveSession(session);
|
|
346
438
|
} catch {
|