viruagent 1.1.0 → 1.3.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/.github/workflows/publish.yml +26 -0
- package/README.md +50 -18
- package/config/agent-prompt.md +31 -0
- package/package.json +1 -1
- package/src/agent.js +56 -8
- package/src/lib/ai.js +281 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '20'
|
|
20
|
+
registry-url: 'https://registry.npmjs.org'
|
|
21
|
+
|
|
22
|
+
- run: npm ci
|
|
23
|
+
|
|
24
|
+
- run: npm publish --provenance --access public
|
|
25
|
+
env:
|
|
26
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
<img width="
|
|
2
|
-
|
|
1
|
+
<img width="520" height="109" alt="SCR-20260212-opqh" src="https://github.com/user-attachments/assets/b6c79a28-5619-49b9-b87c-4a3f725a193a" />
|
|
3
2
|
|
|
4
3
|
# ViruAgent
|
|
5
4
|
|
|
6
|
-
> 터미널에서 티스토리 블로그 글을
|
|
5
|
+
> 터미널에서 티스토리 블로그 글을 발행까지 한 번에!
|
|
6
|
+
> (CLI 기반 AI Agent 도구)
|
|
7
|
+
>
|
|
8
|
+
> 태균맨(tae\*virus)의 별명
|
|
9
|
+
> **Viru** + **Agent** = **ViruAgent** (바이루에이전트)
|
|
7
10
|
|
|
8
11
|
티스토리 API를 역분석해서 OpenAI와 엮었습니다.
|
|
9
12
|
글감 잡기 → 초안 생성 → 수정 → 발행까지 터미널 안에서 끝납니다.
|
|
@@ -14,12 +17,13 @@
|
|
|
14
17
|
|
|
15
18
|
## 뭘 할 수 있나
|
|
16
19
|
|
|
17
|
-
-
|
|
20
|
+
- **자연어로 글 발행** — "AI 트렌드로 글 써서 발행해줘"라고 말하면 AI가 알아서 글 생성부터 발행까지 처리합니다.
|
|
21
|
+
- **에이전트 패턴** — OpenAI Function Calling 기반. AI가 도구를 자율적으로 선택하고 실행하는 에이전트 루프로 동작합니다.
|
|
22
|
+
- **수동 명령어 호환** — `/write`, `/edit`, `/publish` 같은 슬래시 명령어도 그대로 사용할 수 있습니다.
|
|
18
23
|
- **Unsplash 이미지 자동 삽입** — 글 생성 시 주제에 맞는 이미지를 자동 검색하고 티스토리에 업로드합니다. 첫 번째 이미지가 썸네일로 설정됩니다.
|
|
19
24
|
- **브라우저 로그인으로 세션 관리** — OAuth 설정 없이 Playwright로 실제 로그인해서 쿠키를 가져옵니다.
|
|
20
25
|
- **유연한 글 구조** — 5가지 글 유형(튜토리얼, 비교/리뷰, 리스트, 정보 가이드, 인사이트)을 AI가 주제에 맞게 자율 선택합니다.
|
|
21
26
|
- **CLI UX** — 커맨드 힌트, 화살표 키 메뉴, 상태바 등을 넣어서 터미널에서도 불편하지 않게 만들었습니다.
|
|
22
|
-
- **HTML 유지하면서 수정** — 글 구조를 깨뜨리지 않고 특정 부분만 고치거나 말투를 바꿀 수 있습니다.
|
|
23
27
|
|
|
24
28
|
---
|
|
25
29
|
|
|
@@ -52,8 +56,6 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
|
|
|
52
56
|
|
|
53
57
|
---
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
59
|
| 명령어 | 설명 |
|
|
58
60
|
| --------------- | -------------------------------------------- |
|
|
59
61
|
| `/write <주제>` | AI가 블로그 글 초안 생성 |
|
|
@@ -63,7 +65,40 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
|
|
|
63
65
|
| `/set` | 카테고리, 공개 설정, 모델, 말투 변경 |
|
|
64
66
|
| `/list` | 최근 발행 글 목록 |
|
|
65
67
|
| `/login` | 티스토리 세션 갱신 |
|
|
66
|
-
| `/logout`
|
|
68
|
+
| `/logout` | 티스토리 세션 삭제 |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 에이전트 모드
|
|
73
|
+
|
|
74
|
+
슬래시 명령어 없이 자연어로 말하면 AI가 자율적으로 동작합니다.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
viruagent> AI 에이전트 주제로 글 써서 발행해줘
|
|
78
|
+
```
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### 자연어 요청 예시
|
|
84
|
+
|
|
85
|
+
| 입력 | AI가 하는 일 |
|
|
86
|
+
| ----------------------------- | -------------------------------- |
|
|
87
|
+
| "AI 트렌드로 글 써줘" | `generate_post` 호출 → 초안 생성 |
|
|
88
|
+
| "서론을 더 흥미롭게 수정해줘" | `edit_post` 호출 → 초안 수정 |
|
|
89
|
+
| "발행해줘" | `publish_post` 호출 → 즉시 발행 |
|
|
90
|
+
| "비공개로 발행해줘" | 공개설정 변경 후 발행 |
|
|
91
|
+
| "글 써서 바로 발행해줘" | 생성 → 발행 연속 실행 |
|
|
92
|
+
|
|
93
|
+
### 동작 원리
|
|
94
|
+
|
|
95
|
+
1. 사용자가 자연어로 요청
|
|
96
|
+
2. OpenAI Function Calling으로 AI가 적절한 도구 선택
|
|
97
|
+
3. 코드가 도구 실행 → 결과를 AI에게 전달
|
|
98
|
+
4. AI가 다음 행동 판단 (추가 도구 호출 또는 텍스트 응답)
|
|
99
|
+
5. 모든 작업 완료 후 결과 보고
|
|
100
|
+
|
|
101
|
+
기존 `/write`, `/edit`, `/publish` 명령어도 100% 호환됩니다.
|
|
67
102
|
|
|
68
103
|
---
|
|
69
104
|
|
|
@@ -77,11 +112,8 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
|
|
|
77
112
|
|
|
78
113
|
<img width="1054" height="896" alt="image" src="https://github.com/user-attachments/assets/9e30e0ed-32f9-41a4-b39f-e4e6863d5d2d" />
|
|
79
114
|
|
|
80
|
-
|
|
81
|
-
|
|
82
115
|
카테고리, 공개 여부, 발행 방식을 물어본 뒤 AI가 글을 생성해서 티스토리에 발행합니다.
|
|
83
116
|
|
|
84
|
-
|
|
85
117
|
### 직접 CLI로 실행
|
|
86
118
|
|
|
87
119
|
```bash
|
|
@@ -125,13 +157,13 @@ config/
|
|
|
125
157
|
|
|
126
158
|
### 글 유형 (AI가 주제에 맞게 자율 선택)
|
|
127
159
|
|
|
128
|
-
| 유형
|
|
129
|
-
|
|
130
|
-
| 튜토리얼
|
|
131
|
-
| 비교/리뷰
|
|
132
|
-
| 리스트
|
|
133
|
-
| 정보 가이드 | 문제 → 원인 → 해결 → FAQ
|
|
134
|
-
| 인사이트
|
|
160
|
+
| 유형 | 구조 |
|
|
161
|
+
| ----------- | -------------------------------------- |
|
|
162
|
+
| 튜토리얼 | 준비물 → 단계 → 결과 확인 → 트러블슈팅 |
|
|
163
|
+
| 비교/리뷰 | 비교 테이블 + 장단점 → 추천 |
|
|
164
|
+
| 리스트 | 번호 소제목 + 항목별 팁 |
|
|
165
|
+
| 정보 가이드 | 문제 → 원인 → 해결 → FAQ |
|
|
166
|
+
| 인사이트 | 화두 → 근거 → 시사점 → 액션 |
|
|
135
167
|
|
|
136
168
|
### prompt-config.json 설정
|
|
137
169
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
당신은 티스토리 블로그 글쓰기를 돕는 자율형 AI 에이전트입니다.
|
|
2
|
+
사용자의 자연어 요청을 이해하고, 적절한 도구를 순서대로 호출하여 블로그 글 작성부터 발행까지 자율적으로 수행합니다.
|
|
3
|
+
|
|
4
|
+
## 핵심 원칙: 요청받은 작업은 끝까지 실행하라
|
|
5
|
+
|
|
6
|
+
사용자가 여러 작업을 한 문장에 요청하면 (예: "글 써서 발행해줘"), **중간에 멈추지 말고** 모든 도구를 순서대로 호출하여 완료하세요.
|
|
7
|
+
|
|
8
|
+
- "글 써서 발행해줘" → `generate_post` 호출 → 바로 `publish_post` 호출
|
|
9
|
+
- "글 써서 수정하고 발행해줘" → `generate_post` → `edit_post` → `publish_post`
|
|
10
|
+
- 중간에 "다음 뭘 할까요?" 같은 질문을 하지 마세요.
|
|
11
|
+
- 모든 작업이 끝난 후 결과를 한 번에 보고하세요.
|
|
12
|
+
|
|
13
|
+
## 도구 사용 원칙
|
|
14
|
+
|
|
15
|
+
1. 사용자가 글 작성을 요청하면 `generate_post`를 호출하세요.
|
|
16
|
+
2. 수정 요청이면 `edit_post`를 호출하세요.
|
|
17
|
+
3. 미리보기 요청이면 `preview_post`를 호출하세요.
|
|
18
|
+
4. 발행 요청이면 바로 `publish_post`를 호출하세요. 별도 확인을 구하지 마세요.
|
|
19
|
+
5. 카테고리나 공개설정 변경 요청이면 `set_category` 또는 `set_visibility`를 호출하세요.
|
|
20
|
+
6. 현재 상태를 파악해야 할 때 `get_blog_status`를 호출하세요.
|
|
21
|
+
|
|
22
|
+
## 대화 스타일
|
|
23
|
+
|
|
24
|
+
- 한국어로 대화하세요.
|
|
25
|
+
- 도구 호출 결과를 사용자에게 간결하게 요약해서 전달하세요.
|
|
26
|
+
- 작업만 요청받았을 때는 선택지를 제시하지 마세요. 바로 실행하세요.
|
|
27
|
+
- 불필요한 도구 호출을 하지 마세요. 단순 대화는 도구 없이 응답하세요.
|
|
28
|
+
|
|
29
|
+
## 주의사항
|
|
30
|
+
|
|
31
|
+
- 초안이 없는 상태에서 수정/미리보기/발행을 요청하면 먼저 글을 작성하라고 안내하세요.
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const readline = require('readline');
|
|
2
2
|
const chalk = require('chalk');
|
|
3
|
-
const { generatePost, revisePost, chat, MODELS, loadConfig } = require('./lib/ai');
|
|
3
|
+
const { generatePost, revisePost, chat, runAgent, MODELS, loadConfig } = require('./lib/ai');
|
|
4
4
|
const { initBlog, getBlogName, publishPost, saveDraft, getPosts, getCategories, VISIBILITY } = require('./lib/tistory');
|
|
5
5
|
|
|
6
6
|
const TONES = loadConfig().tones.map((t) => t.name);
|
|
@@ -162,8 +162,10 @@ const showBootStep = async (msg, asyncFn, minMs = 800) => {
|
|
|
162
162
|
|
|
163
163
|
const withSpinner = async (message, asyncFn) => {
|
|
164
164
|
let i = 0;
|
|
165
|
+
const cols = process.stdout.columns || 80;
|
|
166
|
+
const truncated = message.length + 2 > cols ? message.slice(0, cols - 5) + '...' : message;
|
|
165
167
|
const timer = setInterval(() => {
|
|
166
|
-
process.stdout.write(`\r${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${
|
|
168
|
+
process.stdout.write(`\r\x1B[K${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${truncated}`);
|
|
167
169
|
}, 80);
|
|
168
170
|
try {
|
|
169
171
|
return await asyncFn();
|
|
@@ -599,7 +601,8 @@ ${chalk.cyan('/logout')} 로그아웃 (세션 삭제)
|
|
|
599
601
|
${chalk.cyan('/help')} 도움말
|
|
600
602
|
${chalk.cyan('/exit')} 종료
|
|
601
603
|
|
|
602
|
-
슬래시 없이 입력하면 AI
|
|
604
|
+
슬래시 없이 자연어로 입력하면 AI가 자율적으로 도구를 호출합니다.
|
|
605
|
+
${chalk.dim('예: "AI 트렌드로 글 써줘", "서론을 더 흥미롭게 수정해줘", "발행해줘"')}
|
|
603
606
|
`);
|
|
604
607
|
},
|
|
605
608
|
|
|
@@ -657,12 +660,57 @@ const handleInput = async (input) => {
|
|
|
657
660
|
log.warn(`알 수 없는 명령어: /${cmd}. /help로 확인하세요.`);
|
|
658
661
|
}
|
|
659
662
|
} else {
|
|
660
|
-
//
|
|
661
|
-
state.chatHistory.push({ role: 'user', content: trimmed });
|
|
663
|
+
// 에이전트 루프 (자연어 → 자율 도구 호출)
|
|
662
664
|
try {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
665
|
+
// Braille 도트 애니메이션 (텍스트 없이)
|
|
666
|
+
const DOT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
667
|
+
const DOT_COLORS = [chalk.cyan, chalk.blue, chalk.magenta, chalk.blue];
|
|
668
|
+
let spinnerTimer = null;
|
|
669
|
+
let spinnerIdx = 0;
|
|
670
|
+
|
|
671
|
+
const startSpinner = () => {
|
|
672
|
+
stopSpinner();
|
|
673
|
+
spinnerTimer = setInterval(() => {
|
|
674
|
+
const colorFn = DOT_COLORS[Math.floor(spinnerIdx / 3) % DOT_COLORS.length];
|
|
675
|
+
process.stdout.write(`\r\x1B[K ${colorFn(DOT_FRAMES[spinnerIdx++ % DOT_FRAMES.length])}`);
|
|
676
|
+
}, 80);
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const stopSpinner = () => {
|
|
680
|
+
if (spinnerTimer) {
|
|
681
|
+
clearInterval(spinnerTimer);
|
|
682
|
+
spinnerTimer = null;
|
|
683
|
+
process.stdout.write('\r\x1B[K');
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
startSpinner();
|
|
688
|
+
|
|
689
|
+
const reply = await runAgent(trimmed, {
|
|
690
|
+
state,
|
|
691
|
+
publishPost: publishPost,
|
|
692
|
+
onToolCall: () => {},
|
|
693
|
+
onToolResult: (name, result) => {
|
|
694
|
+
stopSpinner();
|
|
695
|
+
if (result?.error) {
|
|
696
|
+
log.warn(result.error);
|
|
697
|
+
} else if (name === 'generate_post' && result?.title) {
|
|
698
|
+
log.success(`글 생성 완료: "${result.title}"`);
|
|
699
|
+
} else if (name === 'edit_post' && result?.title) {
|
|
700
|
+
log.success(`수정 완료: "${result.title}"`);
|
|
701
|
+
} else if (name === 'publish_post' && result?.url) {
|
|
702
|
+
log.success(`발행 완료! ${result.url}`);
|
|
703
|
+
} else if (name === 'set_category' && result?.category) {
|
|
704
|
+
log.success(`카테고리 설정: ${result.category}`);
|
|
705
|
+
} else if (name === 'set_visibility' && result?.visibility) {
|
|
706
|
+
log.success(`공개설정: ${result.visibility}`);
|
|
707
|
+
}
|
|
708
|
+
startSpinner();
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
stopSpinner();
|
|
713
|
+
console.log(`\n${chalk.blue('AI')}\n${reply}\n`);
|
|
666
714
|
} catch (e) {
|
|
667
715
|
log.error(`대화 실패: ${e.message}`);
|
|
668
716
|
}
|
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 };
|