viruagent 1.1.0 → 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 +41 -2
- 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 -1
- package/src/agent.js +56 -8
- package/src/lib/ai.js +281 -1
package/src/lib/ai.js
CHANGED
|
@@ -188,4 +188,284 @@ const chat = async (messages, model) => {
|
|
|
188
188
|
return res.choices[0].message.content;
|
|
189
189
|
};
|
|
190
190
|
|
|
191
|
-
|
|
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 };
|