byuckchon-frontend-cli 1.9.1 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/index.js +1 -1
- package/package.json +1 -1
- package/src/ai/tools.js +117 -6
- package/src/commands/chat.js +15 -0
- package/src/openapi/cache.js +7 -2
- package/src/ui/ChatApp.js +12 -0
package/README.md
CHANGED
|
@@ -193,6 +193,10 @@ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙
|
|
|
193
193
|
|
|
194
194
|
- **OpenAPI**: chat 시작 시 자동 fetch + 1시간 디스크 캐시 → 엔드포인트 요약을 시스템 프롬프트에 박음.
|
|
195
195
|
- 헤더에 `openapi` 줄로 표시. 캐시 hit 면 `(cached)`, fresh fetch 면 `(live)`.
|
|
196
|
+
- **세션 중 서버가 스펙을 바꿔도 자동 대응 (v1.10+)**: `search_openapi` / `get_openapi_endpoint` 가
|
|
197
|
+
캐시에서 엔드포인트를 못 찾으면 **딱 한 번 최신본을 다시 받아 재검색**합니다 (`🔄 OpenAPI 스펙 새로고침`).
|
|
198
|
+
남용 방지를 위해 세션당 횟수·간격이 제한됩니다. "방금 스웨거 업데이트했어, 다시 읽어줘" 라고 하면
|
|
199
|
+
즉시 강제 새로고침(`refresh_openapi`)합니다.
|
|
196
200
|
- **코드 인덱스**: chat 시작 시 인덱스 파일이 없으면 **백그라운드에서 자동 빌드**.
|
|
197
201
|
- 빌드 중에는 화면에 `📚 인덱싱 중 ...` 진행 표시. 끝나면 `✓` 메시지 한 줄.
|
|
198
202
|
- OpenAI 키가 없으면 빌드를 건너뛰고 도움 메시지를 띄움 (Anthropic 은 임베딩 API 미제공).
|
package/bin/index.js
CHANGED
package/package.json
CHANGED
package/src/ai/tools.js
CHANGED
|
@@ -44,6 +44,39 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
44
44
|
return _openapiDocPromise;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ── live refetch (서버가 세션 도중 스펙을 바꾼 경우 대비) ──
|
|
48
|
+
// 남용 방지: 세션당 최대 횟수 + 최소 간격 throttle.
|
|
49
|
+
const REFRESH_MAX = 6;
|
|
50
|
+
const REFRESH_MIN_INTERVAL_MS = 10_000;
|
|
51
|
+
let _refreshCount = 0;
|
|
52
|
+
let _lastRefreshAt = 0;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 캐시를 무시하고 OpenAPI 스펙을 다시 fetch 한다.
|
|
56
|
+
* @returns {Promise<{ doc: object|null, refreshed: boolean, reason?: string }>}
|
|
57
|
+
*/
|
|
58
|
+
async function refreshOpenApiDoc() {
|
|
59
|
+
if (!openapiSource) return { doc: null, refreshed: false, reason: 'no-source' };
|
|
60
|
+
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (_refreshCount >= REFRESH_MAX) {
|
|
63
|
+
return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'limit' };
|
|
64
|
+
}
|
|
65
|
+
if (now - _lastRefreshAt < REFRESH_MIN_INTERVAL_MS) {
|
|
66
|
+
return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'throttled' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_refreshCount += 1;
|
|
70
|
+
_lastRefreshAt = now;
|
|
71
|
+
_openapiDocPromise = getCachedOpenApi(openapiSource, { force: true })
|
|
72
|
+
.then((res) => res.doc ?? null)
|
|
73
|
+
.catch(() => null);
|
|
74
|
+
|
|
75
|
+
const doc = await _openapiDocPromise;
|
|
76
|
+
onEvent({ kind: 'openapi_refreshed', ok: !!doc });
|
|
77
|
+
return { doc, refreshed: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
function safePath(p) {
|
|
48
81
|
if (!p || typeof p !== 'string') {
|
|
49
82
|
throw new Error('path 가 비어있습니다');
|
|
@@ -188,7 +221,11 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
188
221
|
// ─────────── OpenAPI 툴 ───────────
|
|
189
222
|
|
|
190
223
|
async function searchOpenApi({ query, limit = 40 }) {
|
|
191
|
-
|
|
224
|
+
let doc = await loadOpenApiDoc();
|
|
225
|
+
if (!doc) {
|
|
226
|
+
// 첫 로드 실패면 한 번 live refetch 시도.
|
|
227
|
+
({ doc } = await refreshOpenApiDoc());
|
|
228
|
+
}
|
|
192
229
|
if (!doc) {
|
|
193
230
|
return {
|
|
194
231
|
ok: false,
|
|
@@ -197,11 +234,25 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
197
234
|
'(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
|
|
198
235
|
};
|
|
199
236
|
}
|
|
200
|
-
|
|
237
|
+
|
|
238
|
+
let hits = searchEndpoints(doc, query, { limit });
|
|
239
|
+
let refreshed = false;
|
|
240
|
+
|
|
241
|
+
// 캐시된 스펙에서 못 찾으면 → 서버에서 방금 추가됐을 수 있으니 딱 한 번 live refetch 후 재검색.
|
|
242
|
+
if (hits.length === 0) {
|
|
243
|
+
const r = await refreshOpenApiDoc();
|
|
244
|
+
if (r.refreshed && r.doc) {
|
|
245
|
+
refreshed = true;
|
|
246
|
+
doc = r.doc;
|
|
247
|
+
hits = searchEndpoints(doc, query, { limit });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
201
251
|
return {
|
|
202
252
|
ok: true,
|
|
203
253
|
query,
|
|
204
254
|
count: hits.length,
|
|
255
|
+
refreshed,
|
|
205
256
|
endpoints: hits.map((e) => ({
|
|
206
257
|
method: e.method,
|
|
207
258
|
path: e.path,
|
|
@@ -210,17 +261,63 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
210
261
|
})),
|
|
211
262
|
hint:
|
|
212
263
|
hits.length === 0
|
|
213
|
-
?
|
|
214
|
-
|
|
264
|
+
? (refreshed
|
|
265
|
+
? '최신 스펙을 다시 받아왔는데도 매치가 없습니다. 다른 키워드로 재시도하거나 query 를 비워 전체 목록을 확인하세요.'
|
|
266
|
+
: '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.')
|
|
267
|
+
: (refreshed
|
|
268
|
+
? '캐시엔 없던 항목을 최신 스펙에서 찾았습니다. 상세는 get_openapi_endpoint(path, method).'
|
|
269
|
+
: '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.'),
|
|
215
270
|
};
|
|
216
271
|
}
|
|
217
272
|
|
|
218
273
|
async function getOpenApiEndpoint({ path: epPath, method }) {
|
|
219
|
-
|
|
274
|
+
let doc = await loadOpenApiDoc();
|
|
275
|
+
if (!doc) {
|
|
276
|
+
({ doc } = await refreshOpenApiDoc());
|
|
277
|
+
}
|
|
220
278
|
if (!doc) {
|
|
221
279
|
return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
|
|
222
280
|
}
|
|
223
|
-
|
|
281
|
+
|
|
282
|
+
let result = getEndpoint(doc, epPath, method);
|
|
283
|
+
|
|
284
|
+
// 못 찾으면 → 최신 스펙으로 한 번 더.
|
|
285
|
+
if (!result.ok) {
|
|
286
|
+
const r = await refreshOpenApiDoc();
|
|
287
|
+
if (r.refreshed && r.doc) {
|
|
288
|
+
const retry = getEndpoint(r.doc, epPath, method);
|
|
289
|
+
if (retry.ok) result = { ...retry, refreshed: true };
|
|
290
|
+
else result = { ...retry, refreshed: true };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function refreshOpenApi() {
|
|
297
|
+
if (!openapiSource) {
|
|
298
|
+
return { ok: false, error: 'bc.config.json 에 api.openapi 가 설정되어 있지 않습니다.' };
|
|
299
|
+
}
|
|
300
|
+
const r = await refreshOpenApiDoc();
|
|
301
|
+
if (!r.doc) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
refreshed: r.refreshed,
|
|
305
|
+
error:
|
|
306
|
+
r.reason === 'throttled'
|
|
307
|
+
? '방금 새로고침했습니다. 잠시 후 다시 시도하세요.'
|
|
308
|
+
: '스펙을 다시 받아오지 못했습니다 (네트워크/URL 확인).',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const count = (r.doc.paths ? Object.keys(r.doc.paths).length : 0);
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
refreshed: r.refreshed,
|
|
315
|
+
reason: r.refreshed ? undefined : r.reason,
|
|
316
|
+
paths: count,
|
|
317
|
+
message: r.refreshed
|
|
318
|
+
? `최신 OpenAPI 스펙을 다시 받아왔습니다 (path ${count}개).`
|
|
319
|
+
: '최근에 이미 새로고침되어 캐시를 재사용했습니다.',
|
|
320
|
+
};
|
|
224
321
|
}
|
|
225
322
|
|
|
226
323
|
// ─────────── Figma 툴 ───────────
|
|
@@ -381,6 +478,20 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
381
478
|
}),
|
|
382
479
|
execute: getOpenApiEndpoint,
|
|
383
480
|
}),
|
|
481
|
+
refresh_openapi: tool({
|
|
482
|
+
description:
|
|
483
|
+
'연결된 OpenAPI(Swagger) 스펙을 캐시 무시하고 서버에서 다시 받아온다. ' +
|
|
484
|
+
'사용자가 "방금 스웨거(백엔드 API) 를 업데이트했다 / 다시 읽어라" 라고 하거나, ' +
|
|
485
|
+
'search_openapi 가 분명히 있어야 할 엔드포인트를 못 찾을 때 호출. ' +
|
|
486
|
+
'(search_openapi / get_openapi_endpoint 는 못 찾으면 자동으로 한 번 새로고침하므로, ' +
|
|
487
|
+
'명시적 요청이 있을 때만 직접 부르면 된다.)',
|
|
488
|
+
inputSchema: jsonSchema({
|
|
489
|
+
type: 'object',
|
|
490
|
+
properties: {},
|
|
491
|
+
additionalProperties: false,
|
|
492
|
+
}),
|
|
493
|
+
execute: refreshOpenApi,
|
|
494
|
+
}),
|
|
384
495
|
write_file: tool({
|
|
385
496
|
description:
|
|
386
497
|
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
package/src/commands/chat.js
CHANGED
|
@@ -121,6 +121,9 @@ export async function chatCommand(opts = {}) {
|
|
|
121
121
|
'가져와서 zod/타입/요청 함수를 만든다. ' +
|
|
122
122
|
'예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
|
|
123
123
|
'`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
|
|
124
|
+
'스펙은 캐시(최대 1시간)라 서버가 방금 바꿨으면 오래됐을 수 있다 — ' +
|
|
125
|
+
'search_openapi/get_openapi_endpoint 는 못 찾으면 자동으로 한 번 최신본을 다시 받아온다. ' +
|
|
126
|
+
'사용자가 "방금 스웨거 업데이트했어/다시 읽어" 라고 하면 `refresh_openapi()` 를 먼저 호출한다. ' +
|
|
124
127
|
'이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
|
|
125
128
|
}
|
|
126
129
|
} catch {
|
|
@@ -264,6 +267,12 @@ async function runOnce({ cfg, resolved, system, prompt }) {
|
|
|
264
267
|
effective: cfg.effective,
|
|
265
268
|
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
266
269
|
onEvent: (ev) => {
|
|
270
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
271
|
+
console.log(
|
|
272
|
+
chalk.dim(ev.ok ? ' 🔄 OpenAPI 스펙 새로고침' : ' ⚠️ OpenAPI 새로고침 실패'),
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
267
276
|
const label =
|
|
268
277
|
ev.kind === 'write_created'
|
|
269
278
|
? '🆕'
|
|
@@ -489,6 +498,12 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
489
498
|
effective: cfg.effective,
|
|
490
499
|
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
491
500
|
onEvent: (ev) => {
|
|
501
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
502
|
+
console.log(
|
|
503
|
+
chalk.dim(ev.ok ? '\n 🔄 OpenAPI 스펙 새로고침' : '\n ⚠️ OpenAPI 새로고침 실패'),
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
492
507
|
const label =
|
|
493
508
|
ev.kind === 'write_created'
|
|
494
509
|
? '🆕 생성'
|
package/src/openapi/cache.js
CHANGED
|
@@ -23,8 +23,13 @@ function keyFor(input) {
|
|
|
23
23
|
*
|
|
24
24
|
* - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
|
|
25
25
|
* - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} input OpenAPI URL 또는 파일 경로
|
|
28
|
+
* @param {object} [opts]
|
|
29
|
+
* @param {boolean} [opts.force] true 면 TTL 무시하고 무조건 live refetch (캐시 갱신).
|
|
30
|
+
* 서버가 세션 도중 스펙을 바꿨을 때 사용.
|
|
26
31
|
*/
|
|
27
|
-
export async function getCachedOpenApi(input) {
|
|
32
|
+
export async function getCachedOpenApi(input, { force = false } = {}) {
|
|
28
33
|
const dir = await getCacheDir();
|
|
29
34
|
const file = path.join(dir, `openapi-${keyFor(input)}.json`);
|
|
30
35
|
|
|
@@ -39,7 +44,7 @@ export async function getCachedOpenApi(input) {
|
|
|
39
44
|
/* miss */
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
if (cachedFresh && cached) {
|
|
47
|
+
if (!force && cachedFresh && cached) {
|
|
43
48
|
return { doc: cached, cached: true, source: input };
|
|
44
49
|
}
|
|
45
50
|
|
package/src/ui/ChatApp.js
CHANGED
|
@@ -871,6 +871,18 @@ export function ChatApp({
|
|
|
871
871
|
|
|
872
872
|
// 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
|
|
873
873
|
const onToolEvent = (ev) => {
|
|
874
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
875
|
+
setMessages((m) => [
|
|
876
|
+
...m,
|
|
877
|
+
{
|
|
878
|
+
role: 'system-info',
|
|
879
|
+
text: ev.ok
|
|
880
|
+
? '🔄 OpenAPI 스펙 새로고침 (서버에서 최신본 다시 받음)'
|
|
881
|
+
: '⚠️ OpenAPI 새로고침 실패 (네트워크/URL 확인)',
|
|
882
|
+
},
|
|
883
|
+
]);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
874
886
|
const labels = {
|
|
875
887
|
write_created: '🆕 생성',
|
|
876
888
|
write_overwritten: '✏️ 덮어씀',
|