viruagent 1.0.1 → 1.2.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 +53 -15
- package/config/agent-prompt.md +31 -0
- package/docs/agent-pattern-guide.md +574 -0
- package/docs/hybrid-db-agent-guide.md +484 -0
- package/package.json +1 -2
- package/src/agent.js +208 -42
- package/src/cli-post.js +11 -1
- package/src/lib/ai.js +295 -16
- package/src/lib/unsplash.js +5 -7
- package/.env.example +0 -2
package/src/lib/ai.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
const OpenAI = require('openai');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if (!
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
4
|
+
let client;
|
|
5
|
+
const getClient = () => {
|
|
6
|
+
if (!client) {
|
|
7
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
8
|
+
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다. /set api 로 키를 설정하세요.');
|
|
9
|
+
}
|
|
10
|
+
client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
11
|
+
}
|
|
12
|
+
return client;
|
|
13
|
+
};
|
|
15
14
|
|
|
16
15
|
const { replaceImagePlaceholders } = require('./unsplash');
|
|
17
16
|
const { createLogger } = require('./logger');
|
|
@@ -19,7 +18,7 @@ const aiLog = createLogger('ai');
|
|
|
19
18
|
|
|
20
19
|
const handleApiError = (e) => {
|
|
21
20
|
if (e?.status === 401 || e?.code === 'invalid_api_key') {
|
|
22
|
-
throw new Error('OpenAI API 키가 유효하지 않습니다.
|
|
21
|
+
throw new Error('OpenAI API 키가 유효하지 않습니다. /set api 로 키를 확인하세요.');
|
|
23
22
|
}
|
|
24
23
|
if (e?.status === 429) {
|
|
25
24
|
throw new Error('API 요청 한도를 초과했습니다. 잠시 후 다시 시도하거나 요금제를 확인하세요.');
|
|
@@ -77,7 +76,7 @@ const generatePost = async (topic, options = {}) => {
|
|
|
77
76
|
|
|
78
77
|
let res;
|
|
79
78
|
try {
|
|
80
|
-
res = await
|
|
79
|
+
res = await getClient().chat.completions.create({
|
|
81
80
|
model,
|
|
82
81
|
messages: [
|
|
83
82
|
{ role: 'system', content: config.systemPrompt },
|
|
@@ -136,7 +135,7 @@ const revisePost = async (content, instruction, model) => {
|
|
|
136
135
|
|
|
137
136
|
let res;
|
|
138
137
|
try {
|
|
139
|
-
res = await
|
|
138
|
+
res = await getClient().chat.completions.create({
|
|
140
139
|
model,
|
|
141
140
|
messages: [
|
|
142
141
|
{ role: 'system', content: config.systemPrompt },
|
|
@@ -174,7 +173,7 @@ const chat = async (messages, model) => {
|
|
|
174
173
|
|
|
175
174
|
let res;
|
|
176
175
|
try {
|
|
177
|
-
res = await
|
|
176
|
+
res = await getClient().chat.completions.create({
|
|
178
177
|
model,
|
|
179
178
|
messages: [
|
|
180
179
|
{ role: 'system', content: '당신은 블로그 글쓰기를 돕는 AI 어시스턴트입니다. 주제 논의, 아이디어 브레인스토밍, 글 구조 제안 등을 도와줍니다. 한국어로 대화하세요.' },
|
|
@@ -189,4 +188,284 @@ const chat = async (messages, model) => {
|
|
|
189
188
|
return res.choices[0].message.content;
|
|
190
189
|
};
|
|
191
190
|
|
|
192
|
-
|
|
191
|
+
// ─── Agent Pattern: Tool Definitions ───
|
|
192
|
+
|
|
193
|
+
const AGENT_PROMPT_PATH = path.join(__dirname, '..', '..', 'config', 'agent-prompt.md');
|
|
194
|
+
|
|
195
|
+
const loadAgentPrompt = () => {
|
|
196
|
+
try {
|
|
197
|
+
return fs.readFileSync(AGENT_PROMPT_PATH, 'utf-8');
|
|
198
|
+
} catch {
|
|
199
|
+
return '당신은 블로그 글쓰기를 돕는 AI 에이전트입니다. 한국어로 대화하세요.';
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const agentTools = [
|
|
204
|
+
{
|
|
205
|
+
type: 'function',
|
|
206
|
+
function: {
|
|
207
|
+
name: 'generate_post',
|
|
208
|
+
description: '블로그 글 초안을 생성합니다.',
|
|
209
|
+
parameters: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
topic: { type: 'string', description: '글 주제' },
|
|
213
|
+
tone: { type: 'string', description: '글 톤/말투 (선택)' },
|
|
214
|
+
},
|
|
215
|
+
required: ['topic'],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
type: 'function',
|
|
221
|
+
function: {
|
|
222
|
+
name: 'edit_post',
|
|
223
|
+
description: '현재 초안을 수정합니다.',
|
|
224
|
+
parameters: {
|
|
225
|
+
type: 'object',
|
|
226
|
+
properties: {
|
|
227
|
+
instruction: { type: 'string', description: '수정 지시사항' },
|
|
228
|
+
},
|
|
229
|
+
required: ['instruction'],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
type: 'function',
|
|
235
|
+
function: {
|
|
236
|
+
name: 'preview_post',
|
|
237
|
+
description: '현재 초안을 미리봅니다.',
|
|
238
|
+
parameters: { type: 'object', properties: {} },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: 'function',
|
|
243
|
+
function: {
|
|
244
|
+
name: 'publish_post',
|
|
245
|
+
description: '현재 초안을 블로그에 발행합니다.',
|
|
246
|
+
parameters: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: {
|
|
249
|
+
visibility: { type: 'number', description: '공개설정 (20=공개, 15=보호, 0=비공개)' },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
type: 'function',
|
|
256
|
+
function: {
|
|
257
|
+
name: 'set_category',
|
|
258
|
+
description: '블로그 카테고리를 변경합니다.',
|
|
259
|
+
parameters: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
category_name: { type: 'string', description: '카테고리 이름' },
|
|
263
|
+
},
|
|
264
|
+
required: ['category_name'],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
type: 'function',
|
|
270
|
+
function: {
|
|
271
|
+
name: 'set_visibility',
|
|
272
|
+
description: '공개설정을 변경합니다.',
|
|
273
|
+
parameters: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
visibility: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
enum: ['공개', '보호', '비공개'],
|
|
279
|
+
description: '공개설정',
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
required: ['visibility'],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
type: 'function',
|
|
288
|
+
function: {
|
|
289
|
+
name: 'get_blog_status',
|
|
290
|
+
description: '현재 블로그 상태를 조회합니다 (초안 유무, 카테고리, 모델 등).',
|
|
291
|
+
parameters: { type: 'object', properties: {} },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 에이전트 루프 실행
|
|
298
|
+
* @param {string} userMessage - 사용자 입력
|
|
299
|
+
* @param {Object} context - 외부 의존성
|
|
300
|
+
* @param {Object} context.state - 앱 상태 (draft, categories, model, tone 등)
|
|
301
|
+
* @param {Function} context.publishPost - 발행 함수
|
|
302
|
+
* @param {Function} context.saveDraft - 임시저장 함수
|
|
303
|
+
* @param {Function} context.getCategories - 카테고리 조회
|
|
304
|
+
* @param {Function} context.onToolCall - 도구 호출 시 콜백 (name, args) => void
|
|
305
|
+
* @param {Function} context.onToolResult - 도구 결과 콜백 (name, result) => void
|
|
306
|
+
* @returns {Promise<string>} AI 최종 텍스트 응답
|
|
307
|
+
*/
|
|
308
|
+
const runAgent = async (userMessage, context) => {
|
|
309
|
+
const { state, publishPost: publishFn, onToolCall, onToolResult } = context;
|
|
310
|
+
const config = loadConfig();
|
|
311
|
+
const model = state.model || config.defaultModel;
|
|
312
|
+
|
|
313
|
+
// 대화 히스토리에 사용자 메시지 추가
|
|
314
|
+
state.chatHistory.push({ role: 'user', content: userMessage });
|
|
315
|
+
|
|
316
|
+
const messages = [
|
|
317
|
+
{ role: 'system', content: loadAgentPrompt() },
|
|
318
|
+
...state.chatHistory,
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
const MAX_LOOPS = 10;
|
|
322
|
+
|
|
323
|
+
for (let loop = 0; loop < MAX_LOOPS; loop++) {
|
|
324
|
+
let res;
|
|
325
|
+
try {
|
|
326
|
+
res = await getClient().chat.completions.create({
|
|
327
|
+
model,
|
|
328
|
+
messages,
|
|
329
|
+
tools: agentTools,
|
|
330
|
+
...(!isReasoningModel(model) && { temperature: 0.7 }),
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
handleApiError(e);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const choice = res.choices[0];
|
|
337
|
+
const msg = choice.message;
|
|
338
|
+
|
|
339
|
+
// 메시지를 히스토리에 추가
|
|
340
|
+
messages.push(msg);
|
|
341
|
+
|
|
342
|
+
// tool_calls가 없으면 텍스트 응답 반환
|
|
343
|
+
if (!msg.tool_calls || msg.tool_calls.length === 0) {
|
|
344
|
+
const text = msg.content || '';
|
|
345
|
+
state.chatHistory.push({ role: 'assistant', content: text });
|
|
346
|
+
return text;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// tool_calls 처리
|
|
350
|
+
for (const toolCall of msg.tool_calls) {
|
|
351
|
+
const fnName = toolCall.function.name;
|
|
352
|
+
const args = JSON.parse(toolCall.function.arguments || '{}');
|
|
353
|
+
|
|
354
|
+
if (onToolCall) onToolCall(fnName, args);
|
|
355
|
+
|
|
356
|
+
let result;
|
|
357
|
+
try {
|
|
358
|
+
result = await executeAgentTool(fnName, args, { state, publishFn, config });
|
|
359
|
+
} catch (e) {
|
|
360
|
+
result = { error: e.message };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
|
364
|
+
|
|
365
|
+
if (onToolResult) onToolResult(fnName, result);
|
|
366
|
+
|
|
367
|
+
messages.push({
|
|
368
|
+
role: 'tool',
|
|
369
|
+
tool_call_id: toolCall.id,
|
|
370
|
+
content: resultStr,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 루프 초과 시
|
|
376
|
+
const fallback = '작업이 너무 많은 단계를 거쳤습니다. 현재까지의 진행 상황을 확인해주세요.';
|
|
377
|
+
state.chatHistory.push({ role: 'assistant', content: fallback });
|
|
378
|
+
return fallback;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 에이전트 도구 실행
|
|
383
|
+
*/
|
|
384
|
+
const executeAgentTool = async (name, args, { state, publishFn, config }) => {
|
|
385
|
+
switch (name) {
|
|
386
|
+
case 'generate_post': {
|
|
387
|
+
const tone = args.tone || state.tone || config.defaultTone;
|
|
388
|
+
const result = await generatePost(args.topic, {
|
|
389
|
+
model: state.model,
|
|
390
|
+
tone,
|
|
391
|
+
});
|
|
392
|
+
state.draft = result;
|
|
393
|
+
return { success: true, title: result.title, tags: result.tags };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'edit_post': {
|
|
397
|
+
if (!state.draft) return { error: '초안이 없습니다. 먼저 글을 생성해주세요.' };
|
|
398
|
+
const result = await revisePost(state.draft.content, args.instruction, state.model);
|
|
399
|
+
state.draft.title = result.title;
|
|
400
|
+
state.draft.content = result.content;
|
|
401
|
+
state.draft.tags = result.tags;
|
|
402
|
+
return { success: true, title: result.title, tags: result.tags };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
case 'preview_post': {
|
|
406
|
+
if (!state.draft) return { error: '초안이 없습니다.' };
|
|
407
|
+
const plain = state.draft.content
|
|
408
|
+
.replace(/<[^>]+>/g, '')
|
|
409
|
+
.replace(/ /g, ' ')
|
|
410
|
+
.replace(/</g, '<')
|
|
411
|
+
.replace(/>/g, '>')
|
|
412
|
+
.replace(/&/g, '&');
|
|
413
|
+
return {
|
|
414
|
+
title: state.draft.title,
|
|
415
|
+
preview: plain.slice(0, 500) + (plain.length > 500 ? '...' : ''),
|
|
416
|
+
tags: state.draft.tags,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
case 'publish_post': {
|
|
421
|
+
if (!state.draft) return { error: '초안이 없습니다.' };
|
|
422
|
+
const vis = args.visibility != null ? args.visibility : state.visibility;
|
|
423
|
+
const result = await publishFn({
|
|
424
|
+
title: state.draft.title,
|
|
425
|
+
content: state.draft.content,
|
|
426
|
+
visibility: vis,
|
|
427
|
+
category: state.category,
|
|
428
|
+
tag: state.draft.tags,
|
|
429
|
+
thumbnail: state.draft.thumbnailKage || null,
|
|
430
|
+
});
|
|
431
|
+
const url = result.entryUrl || '';
|
|
432
|
+
state.draft = null;
|
|
433
|
+
return { success: true, url };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case 'set_category': {
|
|
437
|
+
const id = state.categories[args.category_name];
|
|
438
|
+
if (id === undefined) {
|
|
439
|
+
return { error: `"${args.category_name}" 카테고리를 찾을 수 없습니다.`, available: Object.keys(state.categories) };
|
|
440
|
+
}
|
|
441
|
+
state.category = id;
|
|
442
|
+
return { success: true, category: args.category_name, id };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
case 'set_visibility': {
|
|
446
|
+
const visMap = { '공개': 20, '보호': 15, '비공개': 0 };
|
|
447
|
+
if (visMap[args.visibility] === undefined) {
|
|
448
|
+
return { error: '유효하지 않은 공개설정입니다. 공개/보호/비공개 중 선택하세요.' };
|
|
449
|
+
}
|
|
450
|
+
state.visibility = visMap[args.visibility];
|
|
451
|
+
return { success: true, visibility: args.visibility };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
case 'get_blog_status': {
|
|
455
|
+
return {
|
|
456
|
+
hasDraft: !!state.draft,
|
|
457
|
+
draftTitle: state.draft?.title || null,
|
|
458
|
+
category: Object.entries(state.categories).find(([, id]) => id === state.category)?.[0] || '없음',
|
|
459
|
+
visibility: state.visibility === 20 ? '공개' : state.visibility === 15 ? '보호' : '비공개',
|
|
460
|
+
model: state.model,
|
|
461
|
+
tone: state.tone,
|
|
462
|
+
availableCategories: Object.keys(state.categories),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
default:
|
|
467
|
+
return { error: `알 수 없는 도구: ${name}` };
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
module.exports = { generatePost, revisePost, chat, runAgent, MODELS, loadConfig };
|
package/src/lib/unsplash.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
|
-
require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') });
|
|
3
1
|
const { createLogger } = require('./logger');
|
|
4
2
|
const log = createLogger('unsplash');
|
|
5
3
|
|
|
6
|
-
const
|
|
4
|
+
const getUnsplashKey = () => process.env.UNSPLASH_ACCESS_KEY;
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
7
|
* Unsplash에서 키워드로 이미지 검색
|
|
@@ -11,12 +9,12 @@ const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
|
|
|
11
9
|
* @returns {Promise<Object|null>} { url, alt, credit, link } 또는 null
|
|
12
10
|
*/
|
|
13
11
|
const searchImage = async (keyword) => {
|
|
14
|
-
if (!
|
|
12
|
+
if (!getUnsplashKey()) return null;
|
|
15
13
|
|
|
16
14
|
try {
|
|
17
15
|
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(keyword)}&per_page=1&orientation=landscape`;
|
|
18
16
|
const res = await fetch(url, {
|
|
19
|
-
headers: { Authorization: `Client-ID ${
|
|
17
|
+
headers: { Authorization: `Client-ID ${getUnsplashKey()}` },
|
|
20
18
|
});
|
|
21
19
|
|
|
22
20
|
if (!res.ok) {
|
|
@@ -75,8 +73,8 @@ const replaceImagePlaceholders = async (html, options = {}) => {
|
|
|
75
73
|
let thumbnailUrl = null;
|
|
76
74
|
let thumbnailKage = null;
|
|
77
75
|
|
|
78
|
-
if (!
|
|
79
|
-
log.warn('
|
|
76
|
+
if (!getUnsplashKey()) {
|
|
77
|
+
log.warn('getUnsplashKey() 미설정 — 이미지 처리 건너뜀');
|
|
80
78
|
return { html, thumbnailUrl, thumbnailKage };
|
|
81
79
|
}
|
|
82
80
|
|
package/.env.example
DELETED