byuckchon-frontend-cli 1.6.0 → 1.7.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 +16 -3
- 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 +104 -18
- package/src/commands/chat.js +105 -7
- package/src/openapi/lookup.js +135 -0
- package/src/openapi/summary.js +26 -30
- package/src/ui/ChatApp.js +1 -0
package/README.md
CHANGED
|
@@ -74,8 +74,11 @@ bc init
|
|
|
74
74
|
| `read_file` | 프로젝트 내 파일/디렉터리 내용 읽기 |
|
|
75
75
|
| `list_files` | 글롭 패턴으로 파일 나열 |
|
|
76
76
|
| `search_code` | RAG 인덱스 의미 기반 검색 (인덱스 있어야 함) |
|
|
77
|
+
| `search_openapi` | OpenAPI 스펙에서 엔드포인트 검색 (path/summary/tag) — 큰 스펙도 OK |
|
|
78
|
+
| `get_openapi_endpoint` | 특정 엔드포인트 상세 (params/requestBody/responses, `$ref` 인라인) |
|
|
77
79
|
| `write_file` | 새 파일 생성 또는 통째 덮어쓰기 |
|
|
78
80
|
| `edit_file` | 유일한 `old_string → new_string` 으로 부분 수정 (안전) |
|
|
81
|
+
| `fetch_figma` / `fetch_figma_image` / `fetch_figma_styles` | Figma 디자인/이미지/토큰 |
|
|
79
82
|
|
|
80
83
|
모델은 한 턴 안에서 **최대 12 step** 까지 툴을 자유롭게 호출합니다. 일반적인 흐름:
|
|
81
84
|
1. `list_files` 로 `src/api/` 구조 파악
|
|
@@ -264,12 +267,20 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
|
|
|
264
267
|
이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
|
|
265
268
|
Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
|
|
266
269
|
|
|
267
|
-
**이미지 첨부 3가지 방법:**
|
|
270
|
+
**이미지 첨부 3가지 방법:** (ink·plain 모드 모두 지원 — v1.6.1+)
|
|
268
271
|
1. `/image ./shot.png` — 경로 직접
|
|
269
272
|
2. **드래그 & 드롭** — `/image ` 까지 입력 후, Finder 에서 파일을 터미널 위로 끌어다 놓으면 절대경로가 자동 입력됩니다. Enter.
|
|
270
|
-
3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
|
|
273
|
+
3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷 또는 Finder 에서 `Cmd+C` 한 이미지)를 바로 첨부.
|
|
271
274
|
- 사전에 `brew install pngpaste` 한 번 필요.
|
|
272
275
|
|
|
276
|
+
> **터미널에서 `Cmd+V` 로 직접 붙이기는 왜 안 되나?** 터미널 앱은 클립보드의 "이미지 바이트" 를
|
|
277
|
+
> 앱에 전달하지 않고 텍스트만 줍니다 (OS/터미널 공통 제약). 그래서 클립보드 이미지를 붙이려면
|
|
278
|
+
> `/paste` 가 `pngpaste` 로 클립보드를 직접 읽어 첨부합니다 — `Cmd+C` → 입력창에 `/paste` → Enter.
|
|
279
|
+
|
|
280
|
+
> **썸네일 미리보기:** iTerm2 · kitty · WezTerm 에서는 plain 모드에서 첨부 직후 작은 썸네일이
|
|
281
|
+
> 인라인으로 표시됩니다. 그 외 터미널은 파일명 + 용량만 표시됩니다 (터미널이 이미지 렌더링을
|
|
282
|
+
> 지원하지 않기 때문).
|
|
283
|
+
|
|
273
284
|
### 한글 입력이 자꾸 씹힐 때 (v1.6+)
|
|
274
285
|
|
|
275
286
|
`ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
|
|
@@ -342,7 +353,9 @@ bc config set-ui ink # 풀 TUI 복귀
|
|
|
342
353
|
- [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
|
|
343
354
|
- [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
|
|
344
355
|
- [x] v1.6.0 — Figma 툴 (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
|
|
345
|
-
- [
|
|
356
|
+
- [x] v1.6.1 — 툴 스키마 `jsonSchema()` 래핑 (`schema is not a function` 수정), plain 모드 이미지 첨부(`/image`·`/paste`) + iTerm2/kitty 인라인 썸네일
|
|
357
|
+
- [x] v1.7.0 — OpenAPI 검색 툴 (`search_openapi` / `get_openapi_endpoint`) — 큰 스펙(수백 엔드포인트)에서도 정확한 경로/스키마 조회. 요약도 path 당 1줄로 압축 + 한도 상향
|
|
358
|
+
- [ ] v1.8.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
|
|
346
359
|
- [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
|
|
347
360
|
- [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
|
|
348
361
|
|
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 {
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from '../figma/api.js';
|
|
13
13
|
import { simplifyFetchNodes } from '../figma/simplify.js';
|
|
14
14
|
import { parseFigmaUrl } from '../figma/url.js';
|
|
15
|
+
import { getCachedOpenApi } from '../openapi/cache.js';
|
|
16
|
+
import { searchEndpoints, getEndpoint } from '../openapi/lookup.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Agentic chat 용 툴 정의.
|
|
@@ -27,9 +29,21 @@ import { parseFigmaUrl } from '../figma/url.js';
|
|
|
27
29
|
* const tools = buildTools({ projectRoot, effective, onEvent });
|
|
28
30
|
* streamText({ tools, stopWhen: stepCountIs(12), ... });
|
|
29
31
|
*/
|
|
30
|
-
export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
32
|
+
export function buildTools({ projectRoot, effective, onEvent = () => {}, openapiSource = null }) {
|
|
31
33
|
const root = path.resolve(projectRoot);
|
|
32
34
|
|
|
35
|
+
// OpenAPI 스펙은 한 번만 로드해서 캐시 (큰 파일이라 반복 파싱 비쌈).
|
|
36
|
+
let _openapiDocPromise = null;
|
|
37
|
+
function loadOpenApiDoc() {
|
|
38
|
+
if (!openapiSource) return Promise.resolve(null);
|
|
39
|
+
if (!_openapiDocPromise) {
|
|
40
|
+
_openapiDocPromise = getCachedOpenApi(openapiSource)
|
|
41
|
+
.then((res) => res.doc ?? null)
|
|
42
|
+
.catch(() => null);
|
|
43
|
+
}
|
|
44
|
+
return _openapiDocPromise;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
function safePath(p) {
|
|
34
48
|
if (!p || typeof p !== 'string') {
|
|
35
49
|
throw new Error('path 가 비어있습니다');
|
|
@@ -171,6 +185,44 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
171
185
|
return { ok: true, path: rel, action: 'edited' };
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
// ─────────── OpenAPI 툴 ───────────
|
|
189
|
+
|
|
190
|
+
async function searchOpenApi({ query, limit = 40 }) {
|
|
191
|
+
const doc = await loadOpenApiDoc();
|
|
192
|
+
if (!doc) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error:
|
|
196
|
+
'OpenAPI 스펙을 불러올 수 없습니다. bc.config.json 의 api.openapi 가 올바른 JSON 스펙 URL 인지 확인하세요 ' +
|
|
197
|
+
'(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const hits = searchEndpoints(doc, query, { limit });
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
query,
|
|
204
|
+
count: hits.length,
|
|
205
|
+
endpoints: hits.map((e) => ({
|
|
206
|
+
method: e.method,
|
|
207
|
+
path: e.path,
|
|
208
|
+
summary: e.summary,
|
|
209
|
+
tags: e.tags,
|
|
210
|
+
})),
|
|
211
|
+
hint:
|
|
212
|
+
hits.length === 0
|
|
213
|
+
? '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.'
|
|
214
|
+
: '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function getOpenApiEndpoint({ path: epPath, method }) {
|
|
219
|
+
const doc = await loadOpenApiDoc();
|
|
220
|
+
if (!doc) {
|
|
221
|
+
return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
|
|
222
|
+
}
|
|
223
|
+
return getEndpoint(doc, epPath, method);
|
|
224
|
+
}
|
|
225
|
+
|
|
174
226
|
// ─────────── Figma 툴 ───────────
|
|
175
227
|
|
|
176
228
|
async function fetchFigma({ url, depth = 4 }) {
|
|
@@ -260,31 +312,31 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
260
312
|
read_file: tool({
|
|
261
313
|
description:
|
|
262
314
|
'프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
|
|
263
|
-
inputSchema: {
|
|
315
|
+
inputSchema: jsonSchema({
|
|
264
316
|
type: 'object',
|
|
265
317
|
properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
|
|
266
318
|
required: ['path'],
|
|
267
319
|
additionalProperties: false,
|
|
268
|
-
},
|
|
320
|
+
}),
|
|
269
321
|
execute: readFile,
|
|
270
322
|
}),
|
|
271
323
|
list_files: tool({
|
|
272
324
|
description:
|
|
273
325
|
'글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
|
|
274
|
-
inputSchema: {
|
|
326
|
+
inputSchema: jsonSchema({
|
|
275
327
|
type: 'object',
|
|
276
328
|
properties: {
|
|
277
329
|
pattern: { type: 'string', default: '**/*' },
|
|
278
330
|
limit: { type: 'number', default: 100 },
|
|
279
331
|
},
|
|
280
332
|
additionalProperties: false,
|
|
281
|
-
},
|
|
333
|
+
}),
|
|
282
334
|
execute: listFiles,
|
|
283
335
|
}),
|
|
284
336
|
search_code: tool({
|
|
285
337
|
description:
|
|
286
338
|
'코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
|
|
287
|
-
inputSchema: {
|
|
339
|
+
inputSchema: jsonSchema({
|
|
288
340
|
type: 'object',
|
|
289
341
|
properties: {
|
|
290
342
|
query: { type: 'string' },
|
|
@@ -292,13 +344,47 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
292
344
|
},
|
|
293
345
|
required: ['query'],
|
|
294
346
|
additionalProperties: false,
|
|
295
|
-
},
|
|
347
|
+
}),
|
|
296
348
|
execute: searchCode,
|
|
297
349
|
}),
|
|
350
|
+
search_openapi: tool({
|
|
351
|
+
description:
|
|
352
|
+
'연결된 OpenAPI(Swagger) 스펙에서 엔드포인트를 검색한다. path 일부("admin/inquiries"), ' +
|
|
353
|
+
'한글 summary("문의"), tag, operationId 어느 걸로도 검색 가능. ' +
|
|
354
|
+
'API 코드를 짜기 전에 반드시 이 툴로 정확한 경로/메서드를 먼저 확인할 것. ' +
|
|
355
|
+
'시스템 프롬프트의 요약은 잘려 있을 수 있으므로 "엔드포인트가 안 보인다" 싶으면 이 툴로 찾는다.',
|
|
356
|
+
inputSchema: jsonSchema({
|
|
357
|
+
type: 'object',
|
|
358
|
+
properties: {
|
|
359
|
+
query: { type: 'string', description: '검색어 (path 일부/summary/tag). 비우면 전체 목록' },
|
|
360
|
+
limit: { type: 'number', default: 40 },
|
|
361
|
+
},
|
|
362
|
+
additionalProperties: false,
|
|
363
|
+
}),
|
|
364
|
+
execute: searchOpenApi,
|
|
365
|
+
}),
|
|
366
|
+
get_openapi_endpoint: tool({
|
|
367
|
+
description:
|
|
368
|
+
'특정 엔드포인트의 상세(parameters / requestBody / responses 스키마, $ref 인라인됨) 를 가져온다. ' +
|
|
369
|
+
'search_openapi 로 찾은 정확한 path 와 method 를 넘긴다. 이 결과로 zod 스키마/타입/요청 함수를 정확히 생성.',
|
|
370
|
+
inputSchema: jsonSchema({
|
|
371
|
+
type: 'object',
|
|
372
|
+
properties: {
|
|
373
|
+
path: { type: 'string', description: '정확한 경로. 예: /api/admin/inquiries' },
|
|
374
|
+
method: {
|
|
375
|
+
type: 'string',
|
|
376
|
+
description: 'GET/POST/... 생략 시 해당 path 의 모든 메서드',
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
required: ['path'],
|
|
380
|
+
additionalProperties: false,
|
|
381
|
+
}),
|
|
382
|
+
execute: getOpenApiEndpoint,
|
|
383
|
+
}),
|
|
298
384
|
write_file: tool({
|
|
299
385
|
description:
|
|
300
386
|
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
|
301
|
-
inputSchema: {
|
|
387
|
+
inputSchema: jsonSchema({
|
|
302
388
|
type: 'object',
|
|
303
389
|
properties: {
|
|
304
390
|
path: { type: 'string' },
|
|
@@ -306,13 +392,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
306
392
|
},
|
|
307
393
|
required: ['path', 'content'],
|
|
308
394
|
additionalProperties: false,
|
|
309
|
-
},
|
|
395
|
+
}),
|
|
310
396
|
execute: writeFile,
|
|
311
397
|
}),
|
|
312
398
|
edit_file: tool({
|
|
313
399
|
description:
|
|
314
400
|
'기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
|
|
315
|
-
inputSchema: {
|
|
401
|
+
inputSchema: jsonSchema({
|
|
316
402
|
type: 'object',
|
|
317
403
|
properties: {
|
|
318
404
|
path: { type: 'string' },
|
|
@@ -321,7 +407,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
321
407
|
},
|
|
322
408
|
required: ['path', 'old_string', 'new_string'],
|
|
323
409
|
additionalProperties: false,
|
|
324
|
-
},
|
|
410
|
+
}),
|
|
325
411
|
execute: editFile,
|
|
326
412
|
}),
|
|
327
413
|
fetch_figma: tool({
|
|
@@ -330,7 +416,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
330
416
|
'간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
|
|
331
417
|
'없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
|
|
332
418
|
'디자인 의도를 학습한 뒤 코드를 짠다.',
|
|
333
|
-
inputSchema: {
|
|
419
|
+
inputSchema: jsonSchema({
|
|
334
420
|
type: 'object',
|
|
335
421
|
properties: {
|
|
336
422
|
url: { type: 'string', description: 'Figma share/copy link' },
|
|
@@ -338,14 +424,14 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
338
424
|
},
|
|
339
425
|
required: ['url'],
|
|
340
426
|
additionalProperties: false,
|
|
341
|
-
},
|
|
427
|
+
}),
|
|
342
428
|
execute: fetchFigma,
|
|
343
429
|
}),
|
|
344
430
|
fetch_figma_image: tool({
|
|
345
431
|
description:
|
|
346
432
|
'Figma 프레임을 PNG/JPG/SVG 이미지로 export. savePath 를 주면 프로젝트 폴더 안에 파일로 저장 ' +
|
|
347
433
|
'(스토리북 배경, public asset 등). 안 주면 임시 URL 만 반환.',
|
|
348
|
-
inputSchema: {
|
|
434
|
+
inputSchema: jsonSchema({
|
|
349
435
|
type: 'object',
|
|
350
436
|
properties: {
|
|
351
437
|
url: { type: 'string' },
|
|
@@ -355,19 +441,19 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
355
441
|
},
|
|
356
442
|
required: ['url'],
|
|
357
443
|
additionalProperties: false,
|
|
358
|
-
},
|
|
444
|
+
}),
|
|
359
445
|
execute: fetchFigmaImage,
|
|
360
446
|
}),
|
|
361
447
|
fetch_figma_styles: tool({
|
|
362
448
|
description:
|
|
363
449
|
'Figma 파일의 로컬 스타일(컬러/타이포/이펙트 토큰) 목록을 가져온다. 디자인 토큰 추출 / ' +
|
|
364
450
|
'Tailwind 테마 설정 / theme.ts 생성 시 사용.',
|
|
365
|
-
inputSchema: {
|
|
451
|
+
inputSchema: jsonSchema({
|
|
366
452
|
type: 'object',
|
|
367
453
|
properties: { url: { type: 'string' } },
|
|
368
454
|
required: ['url'],
|
|
369
455
|
additionalProperties: false,
|
|
370
|
-
},
|
|
456
|
+
}),
|
|
371
457
|
execute: fetchFigmaStylesTool,
|
|
372
458
|
}),
|
|
373
459
|
};
|
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,
|
|
@@ -83,11 +86,15 @@ export async function chatCommand(opts = {}) {
|
|
|
83
86
|
};
|
|
84
87
|
system =
|
|
85
88
|
baseSystem +
|
|
86
|
-
'\n\n---\nOpenAPI 스펙 (
|
|
89
|
+
'\n\n---\nOpenAPI 스펙 개요 (아래 목록은 길면 잘려 있을 수 있음):\n' +
|
|
87
90
|
summary +
|
|
88
|
-
'\n\n
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
+
'\n\n**중요**: 위 목록은 일부만 보일 수 있다. 사용자가 특정 엔드포인트를 말하거나 ' +
|
|
92
|
+
'위 목록에서 안 보이면, 추측하지 말고 반드시 `search_openapi(query)` 로 정확한 ' +
|
|
93
|
+
'path/method 를 검색한 뒤 `get_openapi_endpoint(path, method)` 로 상세 스키마를 ' +
|
|
94
|
+
'가져와서 zod/타입/요청 함수를 만든다. ' +
|
|
95
|
+
'예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
|
|
96
|
+
'`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
|
|
97
|
+
'이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
|
|
91
98
|
}
|
|
92
99
|
} catch {
|
|
93
100
|
/* 비정상 URL/네트워크 실패 — 무시하고 계속. */
|
|
@@ -201,6 +208,7 @@ async function runOnce({ cfg, resolved, system, prompt }) {
|
|
|
201
208
|
const tools = buildTools({
|
|
202
209
|
projectRoot,
|
|
203
210
|
effective: cfg.effective,
|
|
211
|
+
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
204
212
|
onEvent: (ev) => {
|
|
205
213
|
const label =
|
|
206
214
|
ev.kind === 'write_created'
|
|
@@ -254,7 +262,46 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
254
262
|
if (session?.messages?.length) {
|
|
255
263
|
console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
|
|
256
264
|
}
|
|
257
|
-
console.log(
|
|
265
|
+
console.log(
|
|
266
|
+
chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// 다음 메시지에 함께 보낼 이미지 첨부 목록.
|
|
270
|
+
let pendingAttachments = [];
|
|
271
|
+
|
|
272
|
+
const printAttachments = () => {
|
|
273
|
+
if (!pendingAttachments.length) {
|
|
274
|
+
console.log(chalk.dim(' 첨부 없음.'));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
console.log(chalk.dim(' 현재 첨부:'));
|
|
278
|
+
for (const a of pendingAttachments) {
|
|
279
|
+
console.log(chalk.dim(` - ${path.basename(a.path)} (${a.sizeKb}KB)`));
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const addImage = async (rawPath) => {
|
|
284
|
+
const p = cleanDroppedPath(rawPath);
|
|
285
|
+
if (!p) {
|
|
286
|
+
console.log(chalk.red(' 사용법: /image <파일 경로>'));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!isImagePath(p)) {
|
|
290
|
+
console.log(chalk.red(' 지원 확장자: png, jpg, jpeg, gif, webp'));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const { default: fsp } = await import('node:fs/promises');
|
|
295
|
+
const stat = await fsp.stat(path.resolve(p));
|
|
296
|
+
const sizeKb = Math.max(1, Math.round(stat.size / 1024));
|
|
297
|
+
pendingAttachments.push({ kind: 'image', path: path.resolve(p), sizeKb });
|
|
298
|
+
console.log(chalk.green(` 📎 첨부: ${path.basename(p)} (${sizeKb}KB)`));
|
|
299
|
+
// iTerm2 / kitty / WezTerm 이면 작은 썸네일을 바로 보여준다.
|
|
300
|
+
await printInlineThumbnail(path.resolve(p), { heightCells: 6 });
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.log(chalk.red(' 이미지 읽기 실패: ' + (e?.message ?? e)));
|
|
303
|
+
}
|
|
304
|
+
};
|
|
258
305
|
|
|
259
306
|
const rl = readline.createInterface({
|
|
260
307
|
input: process.stdin,
|
|
@@ -284,7 +331,50 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
284
331
|
rl.close();
|
|
285
332
|
return;
|
|
286
333
|
}
|
|
287
|
-
|
|
334
|
+
// 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
|
|
335
|
+
if (line.startsWith('/image')) {
|
|
336
|
+
await addImage(line.slice('/image'.length).trim());
|
|
337
|
+
ask();
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (line === '/paste') {
|
|
341
|
+
try {
|
|
342
|
+
const file = await pasteClipboardImage();
|
|
343
|
+
await addImage(file);
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.log(chalk.red(' ' + (e?.message ?? e)));
|
|
346
|
+
}
|
|
347
|
+
ask();
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (line === '/attachments') {
|
|
351
|
+
printAttachments();
|
|
352
|
+
ask();
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (line === '/clear-attach') {
|
|
356
|
+
pendingAttachments = [];
|
|
357
|
+
console.log(chalk.dim(' 첨부를 비웠습니다.'));
|
|
358
|
+
ask();
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
|
|
363
|
+
if (pendingAttachments.length) {
|
|
364
|
+
const parts = [{ type: 'text', text: line }];
|
|
365
|
+
try {
|
|
366
|
+
for (const att of pendingAttachments) {
|
|
367
|
+
parts.push(await imagePartFromFile(att.path));
|
|
368
|
+
}
|
|
369
|
+
history.push({ role: 'user', content: parts });
|
|
370
|
+
} catch (e) {
|
|
371
|
+
console.log(chalk.red(' 첨부 처리 실패: ' + (e?.message ?? e)));
|
|
372
|
+
history.push({ role: 'user', content: line });
|
|
373
|
+
}
|
|
374
|
+
pendingAttachments = [];
|
|
375
|
+
} else {
|
|
376
|
+
history.push({ role: 'user', content: line });
|
|
377
|
+
}
|
|
288
378
|
rl.pause();
|
|
289
379
|
|
|
290
380
|
const projectRoot = cfg.paths.projectFile
|
|
@@ -293,6 +383,7 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
293
383
|
const tools = buildTools({
|
|
294
384
|
projectRoot,
|
|
295
385
|
effective: cfg.effective,
|
|
386
|
+
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
296
387
|
onEvent: (ev) => {
|
|
297
388
|
const label =
|
|
298
389
|
ev.kind === 'write_created'
|
|
@@ -340,7 +431,14 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
340
431
|
|
|
341
432
|
// 세션 자동 저장 — readline 모드에서도 끊김 대비.
|
|
342
433
|
if (session) {
|
|
343
|
-
session.messages = history.map((h) =>
|
|
434
|
+
session.messages = history.map((h) => {
|
|
435
|
+
// 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
|
|
436
|
+
if (Array.isArray(h.content)) {
|
|
437
|
+
const textPart = h.content.find((p) => p.type === 'text');
|
|
438
|
+
return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
|
|
439
|
+
}
|
|
440
|
+
return { role: h.role, text: h.content };
|
|
441
|
+
});
|
|
344
442
|
try {
|
|
345
443
|
await saveSession(session);
|
|
346
444
|
} catch {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 스펙을 "쿼리" 하는 헬퍼.
|
|
3
|
+
*
|
|
4
|
+
* 큰 스펙(수백 개 엔드포인트, 수백 KB) 은 시스템 프롬프트 요약에 다 담을 수도 없고,
|
|
5
|
+
* 캐시 파일을 통째로 모델에 읽힐 수도 없다(토큰 폭발). 그래서 모델이 필요한 부분만
|
|
6
|
+
* 골라 가져갈 수 있게 검색/상세조회 함수를 제공한다.
|
|
7
|
+
*
|
|
8
|
+
* - searchEndpoints: path/summary/tag/operationId 부분일치로 후보 나열
|
|
9
|
+
* - getEndpoint: 특정 path(+method) 의 상세를 $ref 해석해서 반환
|
|
10
|
+
*/
|
|
11
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
12
|
+
|
|
13
|
+
/** "#/components/schemas/Foo" → doc.components.schemas.Foo */
|
|
14
|
+
function resolveRef(doc, ref, seen = new Set()) {
|
|
15
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
|
|
16
|
+
const parts = ref.slice(2).split('/');
|
|
17
|
+
let cur = doc;
|
|
18
|
+
for (const p of parts) {
|
|
19
|
+
if (cur == null) return null;
|
|
20
|
+
cur = cur[p];
|
|
21
|
+
}
|
|
22
|
+
return cur ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 객체 안의 $ref 를 재귀적으로 해석해 인라인한다.
|
|
27
|
+
* 깊이/순환 방지: maxDepth, seen(ref 경로) 로 컷.
|
|
28
|
+
*/
|
|
29
|
+
function deref(doc, node, { maxDepth = 6, depth = 0, seen = new Set() } = {}) {
|
|
30
|
+
if (node == null || typeof node !== 'object') return node;
|
|
31
|
+
if (depth > maxDepth) return node;
|
|
32
|
+
|
|
33
|
+
if (node.$ref) {
|
|
34
|
+
if (seen.has(node.$ref)) return { $ref: node.$ref, note: '(순환 참조 생략)' };
|
|
35
|
+
const target = resolveRef(doc, node.$ref);
|
|
36
|
+
if (!target) return node;
|
|
37
|
+
const nextSeen = new Set(seen);
|
|
38
|
+
nextSeen.add(node.$ref);
|
|
39
|
+
return deref(doc, target, { maxDepth, depth: depth + 1, seen: nextSeen });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(node)) {
|
|
43
|
+
return node.map((n) => deref(doc, n, { maxDepth, depth: depth + 1, seen }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const [k, v] of Object.entries(node)) {
|
|
48
|
+
out[k] = deref(doc, v, { maxDepth, depth: depth + 1, seen });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 모든 엔드포인트의 가벼운 인덱스 (path, method, summary, tags, operationId). */
|
|
54
|
+
export function listEndpoints(doc) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const [pathStr, methods] of Object.entries(doc?.paths ?? {})) {
|
|
57
|
+
if (!methods || typeof methods !== 'object') continue;
|
|
58
|
+
for (const m of METHODS) {
|
|
59
|
+
const op = methods[m];
|
|
60
|
+
if (!op || typeof op !== 'object') continue;
|
|
61
|
+
out.push({
|
|
62
|
+
method: m.toUpperCase(),
|
|
63
|
+
path: pathStr,
|
|
64
|
+
summary: op.summary ?? '',
|
|
65
|
+
operationId: op.operationId ?? '',
|
|
66
|
+
tags: op.tags ?? [],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 부분일치 검색. query 의 각 토큰이 path/summary/operationId/tag 어딘가에
|
|
75
|
+
* (대소문자 무시) 들어가면 매치. 점수 = 매치한 필드 수 + path 정확 포함 가산점.
|
|
76
|
+
*/
|
|
77
|
+
export function searchEndpoints(doc, query, { limit = 40 } = {}) {
|
|
78
|
+
const all = listEndpoints(doc);
|
|
79
|
+
if (!query || !query.trim()) return all.slice(0, limit);
|
|
80
|
+
|
|
81
|
+
const tokens = query.toLowerCase().split(/[\s/]+/).filter(Boolean);
|
|
82
|
+
const scored = [];
|
|
83
|
+
for (const e of all) {
|
|
84
|
+
const hay = [
|
|
85
|
+
e.path.toLowerCase(),
|
|
86
|
+
e.summary.toLowerCase(),
|
|
87
|
+
e.operationId.toLowerCase(),
|
|
88
|
+
e.tags.join(' ').toLowerCase(),
|
|
89
|
+
];
|
|
90
|
+
let score = 0;
|
|
91
|
+
for (const tok of tokens) {
|
|
92
|
+
if (hay[0].includes(tok)) score += 3; // path 매치 가중
|
|
93
|
+
else if (hay.some((h) => h.includes(tok))) score += 1;
|
|
94
|
+
}
|
|
95
|
+
if (e.path.toLowerCase().includes(query.toLowerCase())) score += 2;
|
|
96
|
+
if (score > 0) scored.push({ ...e, score });
|
|
97
|
+
}
|
|
98
|
+
scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
99
|
+
return scored.slice(0, limit);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 특정 path(+method) 의 상세. $ref 를 인라인해서 request/response 스키마까지 본다.
|
|
104
|
+
* method 생략 시 그 path 의 모든 메서드 반환.
|
|
105
|
+
*/
|
|
106
|
+
export function getEndpoint(doc, targetPath, method) {
|
|
107
|
+
const methods = doc?.paths?.[targetPath];
|
|
108
|
+
if (!methods) {
|
|
109
|
+
// 끝 슬래시/대소문자 보정 한 번 시도
|
|
110
|
+
const found = Object.keys(doc?.paths ?? {}).find(
|
|
111
|
+
(p) => p.toLowerCase() === String(targetPath).toLowerCase(),
|
|
112
|
+
);
|
|
113
|
+
if (!found) return { ok: false, error: `해당 path 없음: ${targetPath}` };
|
|
114
|
+
targetPath = found;
|
|
115
|
+
}
|
|
116
|
+
const pathItem = doc.paths[targetPath];
|
|
117
|
+
const wanted = method ? [method.toLowerCase()] : METHODS;
|
|
118
|
+
const operations = {};
|
|
119
|
+
for (const m of wanted) {
|
|
120
|
+
const op = pathItem[m];
|
|
121
|
+
if (!op) continue;
|
|
122
|
+
operations[m.toUpperCase()] = {
|
|
123
|
+
summary: op.summary,
|
|
124
|
+
operationId: op.operationId,
|
|
125
|
+
tags: op.tags,
|
|
126
|
+
parameters: deref(doc, op.parameters),
|
|
127
|
+
requestBody: deref(doc, op.requestBody),
|
|
128
|
+
responses: deref(doc, op.responses),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(operations).length === 0) {
|
|
132
|
+
return { ok: false, error: `${targetPath} 에 ${method ?? ''} 메서드 없음` };
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, path: targetPath, operations };
|
|
135
|
+
}
|
package/src/openapi/summary.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* ...
|
|
15
15
|
*/
|
|
16
16
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
17
|
-
const
|
|
18
|
-
const MAX_BYTES =
|
|
17
|
+
const MAX_PATHS = 400;
|
|
18
|
+
const MAX_BYTES = 16 * 1024;
|
|
19
19
|
|
|
20
20
|
export function summarizeOpenApi(doc) {
|
|
21
21
|
if (!doc || typeof doc !== 'object') return null;
|
|
@@ -30,46 +30,42 @@ export function summarizeOpenApi(doc) {
|
|
|
30
30
|
lines.push(`base: ${servers.slice(0, 3).join(', ')}`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// path 하나당 한 줄로 메서드를 합친다 (줄 수 절반 + 모델이 path 단위로 보기 쉬움).
|
|
34
|
+
const byPath = [];
|
|
34
35
|
for (const [pathStr, methods] of Object.entries(doc.paths ?? {})) {
|
|
35
36
|
if (!methods || typeof methods !== 'object') continue;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
method: m.toUpperCase(),
|
|
42
|
-
path: pathStr,
|
|
43
|
-
desc: desc.split('\n')[0].slice(0, 90),
|
|
44
|
-
});
|
|
45
|
-
}
|
|
37
|
+
const verbs = METHODS.filter((m) => methods[m] && typeof methods[m] === 'object').map((m) =>
|
|
38
|
+
m.toUpperCase(),
|
|
39
|
+
);
|
|
40
|
+
if (verbs.length === 0) continue;
|
|
41
|
+
byPath.push({ path: pathStr, verbs });
|
|
46
42
|
}
|
|
43
|
+
byPath.sort((a, b) => a.path.localeCompare(b.path));
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const shown = endpoints.slice(0, MAX_ENDPOINTS);
|
|
45
|
+
const total = byPath.length;
|
|
46
|
+
const shown = byPath.slice(0, MAX_PATHS);
|
|
51
47
|
|
|
52
|
-
lines.push(`
|
|
48
|
+
lines.push(`paths: (${shown.length}/${total})`);
|
|
53
49
|
for (const e of shown) {
|
|
54
|
-
lines.push(` ${e.
|
|
50
|
+
lines.push(` ${e.verbs.join(',').padEnd(20)} ${e.path}`);
|
|
55
51
|
}
|
|
56
52
|
if (shown.length < total) {
|
|
57
|
-
lines.push(` ... and ${total - shown.length} more`);
|
|
53
|
+
lines.push(` ... and ${total - shown.length} more (search_openapi 로 검색하세요)`);
|
|
58
54
|
}
|
|
59
55
|
|
|
60
56
|
let result = lines.join('\n');
|
|
57
|
+
// 바이트 초과 시 뒤에서부터 잘라낸다 (search_openapi 가 있으니 전부 못 담아도 안전).
|
|
61
58
|
if (Buffer.byteLength(result, 'utf8') > MAX_BYTES) {
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
result = compact;
|
|
59
|
+
const header = [lines[0], servers.length ? lines[1] : null].filter(Boolean);
|
|
60
|
+
const out = [...header, `paths: (truncated/${total}) — 전체는 search_openapi 로 검색`];
|
|
61
|
+
let bytes = Buffer.byteLength(out.join('\n'), 'utf8');
|
|
62
|
+
for (const e of shown) {
|
|
63
|
+
const line = ` ${e.verbs.join(',').padEnd(20)} ${e.path}`;
|
|
64
|
+
bytes += Buffer.byteLength(line + '\n', 'utf8');
|
|
65
|
+
if (bytes > MAX_BYTES) break;
|
|
66
|
+
out.push(line);
|
|
67
|
+
}
|
|
68
|
+
result = out.join('\n');
|
|
73
69
|
}
|
|
74
70
|
return result;
|
|
75
71
|
}
|
package/src/ui/ChatApp.js
CHANGED