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/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
- require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') });
5
-
6
- if (!process.env.OPENAI_API_KEY) {
7
- console.error('\x1b[31m✗ OPENAI_API_KEY 설정되지 않았습니다.\x1b[0m');
8
- console.error('\x1b[33m 1. 프로젝트 루트에 .env 파일을 생성하세요');
9
- console.error(' 2. OPENAI_API_KEY=sk-... 형식으로 키를 입력하세요');
10
- console.error(' 3. https://platform.openai.com/api-keys 에서 키를 발급받을 있습니다\x1b[0m');
11
- process.exit(1);
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 키가 유효하지 않습니다. .env 파일의 OPENAI_API_KEY를 확인하세요.');
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 client.chat.completions.create({
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 client.chat.completions.create({
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 client.chat.completions.create({
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
- module.exports = { generatePost, revisePost, chat, MODELS, loadConfig };
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(/&nbsp;/g, ' ')
410
+ .replace(/&lt;/g, '<')
411
+ .replace(/&gt;/g, '>')
412
+ .replace(/&amp;/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 };
@@ -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 UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
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 (!UNSPLASH_ACCESS_KEY) return null;
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 ${UNSPLASH_ACCESS_KEY}` },
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 (!UNSPLASH_ACCESS_KEY) {
79
- log.warn('UNSPLASH_ACCESS_KEY 미설정 — 이미지 처리 건너뜀');
76
+ if (!getUnsplashKey()) {
77
+ log.warn('getUnsplashKey() 미설정 — 이미지 처리 건너뜀');
80
78
  return { html, thumbnailUrl, thumbnailKage };
81
79
  }
82
80
 
package/.env.example DELETED
@@ -1,2 +0,0 @@
1
- OPENAI_API_KEY=sk-your-api-key-here
2
- UNSPLASH_ACCESS_KEY=