@nago730/chatbot-library 1.0.0 → 1.1.1
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/LICENSE +21 -0
- package/README.md +369 -90
- package/dist/index.d.mts +29 -3
- package/dist/index.d.ts +29 -3
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +35 -30
- package/src/engine.ts +25 -0
- package/src/examples/firebaseAdapter.example.ts +421 -0
- package/src/index.ts +3 -0
- package/src/types.ts +40 -0
- package/src/useChat.ts +339 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nago
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,113 +1,392 @@
|
|
|
1
|
-
|
|
1
|
+
# @nago730/chatbot-library
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **JSON 하나로 만드는 프로덕션 레디 챗봇 엔진** — React 환경에서 복잡한 대화형 인터페이스를 5분 안에 구축하세요.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
<p align="left">
|
|
6
|
+
<img src="https://img.shields.io/npm/v/@nago730/chatbot-library" alt="npm version" />
|
|
7
|
+
<img src="https://img.shields.io/github/license/Nago730/chatbot-library" alt="license" />
|
|
8
|
+
<img src="https://img.shields.io/npm/dm/@nago730/chatbot-library" alt="downloads" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🎯 핵심 기능 3가지
|
|
14
|
+
|
|
15
|
+
| 기능 | 설명 | 효과 |
|
|
16
|
+
|------|------|------|
|
|
17
|
+
| 🗂️ **JSON 기반 시나리오** | 코드 없이 대화 흐름 설계 | 개발 시간 **90% 단축** |
|
|
18
|
+
| 🔄 **멀티 세션 관리** | 한 사용자가 여러 상담 진행 | 사용자 경험 **향상** |
|
|
19
|
+
| 🔥 **프로덕션 레디** | Firebase 연동 + 비용 최적화 | 운영 비용 **98% 절감** |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## ⚡ 5분 빠른 시작
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
25
|
+
### 1. 설치
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @nago730/chatbot-library
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Flow 정의 (JSON)
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
const SUPPORT_FLOW = {
|
|
12
35
|
start: {
|
|
13
|
-
id:
|
|
14
|
-
question:
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
id: 'start',
|
|
37
|
+
question: '무엇을 도와드릴까요?',
|
|
38
|
+
type: 'button',
|
|
39
|
+
options: ['주문 문의', '배송 조회', '취소/환불'],
|
|
40
|
+
next: (answer) => {
|
|
41
|
+
if (answer === '주문 문의') return 'order';
|
|
42
|
+
if (answer === '배송 조회') return 'delivery';
|
|
43
|
+
return 'refund';
|
|
44
|
+
}
|
|
17
45
|
},
|
|
18
|
-
|
|
19
|
-
id:
|
|
20
|
-
question:
|
|
21
|
-
|
|
22
|
-
next:
|
|
23
|
-
},
|
|
24
|
-
spaceSize: {
|
|
25
|
-
id: "spaceSize",
|
|
26
|
-
question: "공간의 평수는 어떻게 되나요? (숫자만 입력)",
|
|
27
|
-
next: "complete",
|
|
46
|
+
order: {
|
|
47
|
+
id: 'order',
|
|
48
|
+
question: '주문번호를 입력해주세요',
|
|
49
|
+
type: 'input',
|
|
50
|
+
next: 'complete'
|
|
28
51
|
},
|
|
29
52
|
complete: {
|
|
30
|
-
id:
|
|
31
|
-
question:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
53
|
+
id: 'complete',
|
|
54
|
+
question: '감사합니다. 곧 연락드리겠습니다.',
|
|
55
|
+
next: '',
|
|
56
|
+
isEnd: true
|
|
57
|
+
}
|
|
35
58
|
};
|
|
59
|
+
```
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
### 3. 컴포넌트에서 사용
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { useChat } from '@nago730/chatbot-library';
|
|
65
|
+
|
|
66
|
+
function ChatBot() {
|
|
67
|
+
const { node, submitAnswer, submitInput, messages, isEnd } = useChat(
|
|
68
|
+
SUPPORT_FLOW,
|
|
69
|
+
'user_123'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (isEnd) {
|
|
73
|
+
return <div>✅ {node.question}</div>;
|
|
74
|
+
}
|
|
41
75
|
|
|
42
76
|
return (
|
|
43
|
-
<div
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<div style={{ margin: '20px 0', minHeight: '100px' }}>
|
|
50
|
-
<p style={{ fontSize: '18px', fontWeight: 'bold', color: '#333' }}>
|
|
51
|
-
{node.question}
|
|
52
|
-
</p>
|
|
53
|
-
|
|
54
|
-
{/* 선택지 버튼 (이사청소, 거주청소 등) */}
|
|
55
|
-
{!isEnd && node.options && (
|
|
56
|
-
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
|
57
|
-
{node.options.map((opt) => (
|
|
58
|
-
<button
|
|
59
|
-
key={opt}
|
|
60
|
-
onClick={() => submitAnswer(opt)}
|
|
61
|
-
style={{ padding: '10px 20px', cursor: 'pointer', borderRadius: '8px', border: '1px solid #007bff', background: 'white', color: '#007bff' }}
|
|
62
|
-
>
|
|
63
|
-
{opt}
|
|
64
|
-
</button>
|
|
65
|
-
))}
|
|
66
|
-
</div>
|
|
67
|
-
)}
|
|
68
|
-
|
|
69
|
-
{/* 주관식 입력창 (평수 입력 등) */}
|
|
70
|
-
{!isEnd && !node.options && (
|
|
71
|
-
<input
|
|
72
|
-
type="text"
|
|
73
|
-
placeholder="답변을 입력하고 Enter를 누르세요"
|
|
74
|
-
onKeyDown={(e) => {
|
|
75
|
-
if (e.key === 'Enter') {
|
|
76
|
-
submitAnswer((e.target as HTMLInputElement).value);
|
|
77
|
-
(e.target as HTMLInputElement).value = ""; // 입력창 비우기
|
|
78
|
-
}
|
|
79
|
-
}}
|
|
80
|
-
style={{ width: '100%', padding: '10px', boxSizing: 'border-box', borderRadius: '8px', border: '1px solid #ccc' }}
|
|
81
|
-
/>
|
|
82
|
-
)}
|
|
77
|
+
<div>
|
|
78
|
+
{/* 대화 히스토리 */}
|
|
79
|
+
{messages.map((msg, i) => (
|
|
80
|
+
<div key={i}>
|
|
81
|
+
<p>🤖 {msg.question}</p>
|
|
82
|
+
<p>👤 {msg.answer}</p>
|
|
83
83
|
</div>
|
|
84
|
+
))}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
{/* 현재 질문 */}
|
|
87
|
+
<p>{node.question}</p>
|
|
88
|
+
|
|
89
|
+
{/* 버튼형 */}
|
|
90
|
+
{node.type === 'button' && node.options?.map(opt => (
|
|
91
|
+
<button key={opt} onClick={() => submitAnswer(opt)}>
|
|
92
|
+
{opt}
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
{/* 입력형 */}
|
|
97
|
+
{node.type === 'input' && (
|
|
98
|
+
<input onKeyDown={(e) => {
|
|
99
|
+
if (e.key === 'Enter') submitInput(e.currentTarget.value);
|
|
100
|
+
}} />
|
|
101
|
+
)}
|
|
101
102
|
</div>
|
|
102
103
|
);
|
|
103
104
|
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**🎉 완료!** 이제 작동하는 챗봇이 생겼습니다.
|
|
104
108
|
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 📚 핵심 개념
|
|
112
|
+
|
|
113
|
+
### Flow 구조
|
|
114
|
+
|
|
115
|
+
Flow는 **노드(Node)의 집합**입니다. 각 노드는 질문과 다음 단계를 정의합니다.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
interface ChatNode {
|
|
119
|
+
id: string; // 고유 ID
|
|
120
|
+
question: string; // 사용자에게 보여줄 질문
|
|
121
|
+
type?: 'button' | 'input'; // 답변 받는 방식 (기본: button)
|
|
122
|
+
options?: string[]; // 선택지 (type='button'일 때)
|
|
123
|
+
next: string | ((answer) => string); // 다음 노드 ID (동적 가능)
|
|
124
|
+
isEnd?: boolean; // 대화 종료 표시
|
|
125
|
+
}
|
|
105
126
|
```
|
|
106
127
|
|
|
128
|
+
### 세션 관리
|
|
129
|
+
|
|
130
|
+
한 사용자가 **여러 번 상담**을 시작할 수 있습니다.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const { sessionId, reset } = useChat(FLOW, userId, 'start', adapter, {
|
|
134
|
+
sessionId: 'auto' // 'auto' | 'new' | 'specific_id'
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 새 상담 시작
|
|
138
|
+
<button onClick={() => reset()}>새 상담</button>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 저장 전략
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const chat = useChat(FLOW, userId, 'start', adapter, {
|
|
145
|
+
saveStrategy: 'onEnd' // 'always' | 'onEnd'
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| 전략 | 저장 시점 | 추천 대상 |
|
|
150
|
+
|------|-----------|-----------|
|
|
151
|
+
| `'always'` | 매 답변마다 | 데이터 무결성이 중요한 경우 |
|
|
152
|
+
| `'onEnd'` | 대화 종료 시 | **비용 절감** (권장) |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 🔥 Firebase 연동 (프로덕션)
|
|
157
|
+
|
|
158
|
+
### Quick Start
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { createHybridFirebaseAdapter } from '@nago730/chatbot-library/examples';
|
|
162
|
+
import { getFirestore } from 'firebase/firestore';
|
|
163
|
+
|
|
164
|
+
const db = getFirestore(app);
|
|
165
|
+
const adapter = createHybridFirebaseAdapter(db, {
|
|
166
|
+
timeout: 5000,
|
|
167
|
+
fallbackToLocal: true,
|
|
168
|
+
debug: false
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const chat = useChat(FLOW, userId, 'start', adapter, {
|
|
172
|
+
saveStrategy: 'onEnd' // 비용 98% 절감!
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 비용 최적화
|
|
177
|
+
|
|
178
|
+
**10만 사용자, 일 10회 대화 기준 (Firestore)**
|
|
179
|
+
|
|
180
|
+
| 구성 | 월 비용 | 절감율 |
|
|
181
|
+
|------|---------|--------|
|
|
182
|
+
| 기본 설정 (always + 전체 데이터) | $2,700 | - |
|
|
183
|
+
| **하이브리드 + onEnd** ⭐ | **$5.4** | **99.8%** |
|
|
184
|
+
|
|
185
|
+
### 핵심 개선사항
|
|
186
|
+
|
|
187
|
+
- ✅ **기기 전환 복구**: PC → 모바일 대화 이어가기 100%
|
|
188
|
+
- ✅ **네트워크 안정성**: 타임아웃 + 자동 폴백
|
|
189
|
+
- ✅ **타입 안전**: Firebase Timestamp 자동 정규화
|
|
190
|
+
- ✅ **비용 최적화**: 스마트 저장 전략
|
|
191
|
+
|
|
192
|
+
📖 [Firebase 상세 가이드](./docs/firebase-adapter-guide.md)
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 🔄 멀티 세션
|
|
197
|
+
|
|
198
|
+
한 사용자가 **여러 상담을 진행**하고 **이전 대화를 불러올** 수 있습니다.
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const { sessionId, reset, isEnd } = useChat(FLOW, userId, 'start', adapter, {
|
|
202
|
+
sessionId: 'auto'
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// UI 예제
|
|
206
|
+
<div>
|
|
207
|
+
<p>현재 세션: {sessionId}</p>
|
|
208
|
+
|
|
209
|
+
{isEnd && (
|
|
210
|
+
<button onClick={() => reset()}>
|
|
211
|
+
새 상담 시작
|
|
212
|
+
</button>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
<button onClick={() => reset('session_1706000000_abc')}>
|
|
216
|
+
이전 상담 보기
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
📖 [멀티 세션 완벽 가이드](./docs/multi-session-guide.md)
|
|
222
|
+
|
|
107
223
|
---
|
|
108
224
|
|
|
109
|
-
|
|
225
|
+
## 📖 API Reference
|
|
226
|
+
|
|
227
|
+
### useChat
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
useChat(
|
|
231
|
+
flow: Record<string, ChatNode>,
|
|
232
|
+
userId: string,
|
|
233
|
+
initialNodeId?: string,
|
|
234
|
+
adapter?: StorageAdapter,
|
|
235
|
+
options?: ChatOptions
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### Parameters
|
|
240
|
+
|
|
241
|
+
| 파라미터 | 타입 | 설명 |
|
|
242
|
+
|----------|------|------|
|
|
243
|
+
| `flow` | `Record<string, ChatNode>` | 시나리오 Flow 객체 |
|
|
244
|
+
| `userId` | `string` | 사용자 ID (세션 키로 사용) |
|
|
245
|
+
| `initialNodeId` | `string` | 시작 노드 ID (기본: `'start'`) |
|
|
246
|
+
| `adapter` | `StorageAdapter` | 저장소 어댑터 (선택) |
|
|
247
|
+
| `options` | `ChatOptions` | 추가 옵션 (선택) |
|
|
248
|
+
|
|
249
|
+
#### ChatOptions
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
interface ChatOptions {
|
|
253
|
+
saveStrategy?: 'always' | 'onEnd'; // 저장 시점
|
|
254
|
+
scenarioId?: string; // 시나리오 ID
|
|
255
|
+
sessionId?: 'auto' | 'new' | string; // 세션 전략
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
#### Return Values
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
{
|
|
263
|
+
node: ChatNode; // 현재 노드
|
|
264
|
+
submitAnswer: (value: any) => Promise<void>; // 버튼 답변 제출
|
|
265
|
+
submitInput: (value: string) => Promise<void>; // 텍스트 답변 제출
|
|
266
|
+
answers: Record<string, any>; // 수집된 답변
|
|
267
|
+
messages: ChatMessage[]; // 대화 히스토리
|
|
268
|
+
isEnd: boolean; // 종료 여부
|
|
269
|
+
sessionId: string; // 현재 세션 ID
|
|
270
|
+
reset: (sessionId?: string) => void; // 세션 리셋
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### StorageAdapter
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
interface StorageAdapter {
|
|
278
|
+
saveState: (userId: string, state: ChatState) => Promise<void>;
|
|
279
|
+
loadState: (userId: string) => Promise<ChatState | null>;
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 📚 전체 문서
|
|
286
|
+
|
|
287
|
+
### 가이드
|
|
288
|
+
- 📘 [**Complete Guide**](./docs/complete-guide.md) - 모든 기능 + 실전 패턴
|
|
289
|
+
- 🔥 [Firebase Adapter Guide](./docs/firebase-adapter-guide.md)
|
|
290
|
+
- 🔄 [Multi-Session Guide](./docs/multi-session-guide.md)
|
|
291
|
+
- ⚡ [Quick Reference](./docs/firebase-quick-reference.md)
|
|
292
|
+
|
|
293
|
+
### 학습 자료
|
|
294
|
+
- ✅ [Best Practices](./docs/best-practices.md) - DO's & DON'Ts
|
|
295
|
+
- 💡 [Examples](./docs/examples.md) - 실전 코드 모음
|
|
296
|
+
- 🔧 [예제 코드](./src/examples/)
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## ⚠️ Common Pitfalls
|
|
301
|
+
|
|
302
|
+
개발 시 자주 발생하는 실수들:
|
|
303
|
+
|
|
304
|
+
1. ❌ **sessionId 없이 멀티 상담 구현** → `reset()` 사용하세요
|
|
305
|
+
2. ❌ **saveStrategy: 'always' + 실시간 타이핑** → `'onEnd'` 사용 권장
|
|
306
|
+
3. ❌ **Firebase Timestamp 정규화 누락** → 어댑터 예제 코드 사용
|
|
307
|
+
4. ❌ **에러 핸들링 없음** → `fallbackToLocal: true` 설정 필수
|
|
308
|
+
|
|
309
|
+
📖 [전체 Best Practices 보기](./docs/best-practices.md)
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## 🚀 실전 예제
|
|
314
|
+
|
|
315
|
+
### 고객 지원 챗봇
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
const SUPPORT_FLOW = {
|
|
319
|
+
start: { /* ... */ },
|
|
320
|
+
order_inquiry: { /* ... */ },
|
|
321
|
+
delivery_status: { /* ... */ },
|
|
322
|
+
refund: { /* ... */ }
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function CustomerSupport() {
|
|
326
|
+
const { node, submitAnswer, reset, sessionId } = useChat(
|
|
327
|
+
SUPPORT_FLOW,
|
|
328
|
+
customerId,
|
|
329
|
+
'start',
|
|
330
|
+
firebaseAdapter,
|
|
331
|
+
{ sessionId: 'auto', saveStrategy: 'onEnd' }
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return <ChatUI node={node} onAnswer={submitAnswer} onReset={reset} />;
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
더 많은 예제: [Examples](./docs/examples.md)
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## 🛠️ 타입 정의
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// ChatNode
|
|
346
|
+
interface ChatNode {
|
|
347
|
+
id: string;
|
|
348
|
+
question: string;
|
|
349
|
+
type?: 'button' | 'input';
|
|
350
|
+
options?: string[];
|
|
351
|
+
next: string | ((answer: any) => string);
|
|
352
|
+
isEnd?: boolean;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ChatMessage
|
|
356
|
+
interface ChatMessage {
|
|
357
|
+
nodeId: string;
|
|
358
|
+
question: string;
|
|
359
|
+
answer: any;
|
|
360
|
+
timestamp: number;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ChatState
|
|
364
|
+
interface ChatState {
|
|
365
|
+
answers: Record<string, any>;
|
|
366
|
+
currentStep: string;
|
|
367
|
+
messages: ChatMessage[];
|
|
368
|
+
flowHash: string;
|
|
369
|
+
updatedAt: number;
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## 🤝 기여하기
|
|
376
|
+
|
|
377
|
+
이 라이브러리는 프리랜서 외주 작업을 하며 반복되는 챗봇 구현에 지쳐 만들어졌습니다.
|
|
378
|
+
AI 기반 개발에 최적화된 문서를 목표로 하고 있습니다.
|
|
379
|
+
|
|
380
|
+
- ⭐ **Star** 하나가 개발 동기부여가 됩니다!
|
|
381
|
+
- 🐛 버그 제보: [Issues](https://github.com/Nago730/chatbot-library/issues)
|
|
382
|
+
- 💡 기능 제안: [Issues](https://github.com/Nago730/chatbot-library/issues) (기능 제안도 환영합니다!)
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## 📄 라이선스
|
|
387
|
+
|
|
388
|
+
MIT License
|
|
389
|
+
|
|
390
|
+
---
|
|
110
391
|
|
|
111
|
-
|
|
112
|
-
2. **동적 질문 처리:** `CLEANING_FLOW`를 보시면 `serviceType`이 무엇이냐에 따라 `isVacant` 질문을 건너뛰거나 포함하는 로직이 적용되어 있습니다.
|
|
113
|
-
3. **UI와 로직의 분리:** 질문 내용이나 순서를 바꾸고 싶을 때, UI 코드를 건드릴 필요 없이 `CLEANING_FLOW` 객체의 내용만 수정하면 됩니다.
|
|
392
|
+
**Made with ❤️ for Vibe Coders** — AI 시대의 더 나은 개발 경험을 위해
|
package/dist/index.d.mts
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
interface ChatNode {
|
|
2
2
|
id: string;
|
|
3
3
|
question: string;
|
|
4
|
+
type?: 'button' | 'input';
|
|
4
5
|
options?: string[];
|
|
5
6
|
next: string | ((answer: any) => string);
|
|
6
7
|
isEnd?: boolean;
|
|
7
8
|
}
|
|
9
|
+
interface ChatMessage {
|
|
10
|
+
nodeId: string;
|
|
11
|
+
question: string;
|
|
12
|
+
answer: any;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
}
|
|
15
|
+
interface ChatOptions {
|
|
16
|
+
saveStrategy?: 'always' | 'onEnd';
|
|
17
|
+
scenarioId?: string;
|
|
18
|
+
/**
|
|
19
|
+
* 세션 ID 설정
|
|
20
|
+
* - 'auto': 마지막 세션 복구 또는 새 세션 생성 (기본값)
|
|
21
|
+
* - 'new': 항상 새로운 세션 생성
|
|
22
|
+
* - string: 특정 세션 ID로 복구 또는 생성
|
|
23
|
+
*/
|
|
24
|
+
sessionId?: 'auto' | 'new' | string;
|
|
25
|
+
}
|
|
8
26
|
interface ChatState {
|
|
9
27
|
answers: Record<string, any>;
|
|
10
28
|
currentStep: string;
|
|
29
|
+
messages: ChatMessage[];
|
|
30
|
+
flowHash: string;
|
|
31
|
+
updatedAt: number;
|
|
11
32
|
}
|
|
12
33
|
interface StorageAdapter {
|
|
13
|
-
|
|
34
|
+
saveState: (userId: string, state: ChatState) => Promise<void>;
|
|
35
|
+
loadState: (userId: string) => Promise<ChatState | null>;
|
|
14
36
|
}
|
|
15
37
|
|
|
16
38
|
declare class ChatEngine {
|
|
@@ -20,11 +42,15 @@ declare class ChatEngine {
|
|
|
20
42
|
getNextStep(currentStepId: string, answer: any): string;
|
|
21
43
|
}
|
|
22
44
|
|
|
23
|
-
declare function useChat(flow: Record<string, ChatNode>, userId: string, adapter?: StorageAdapter): {
|
|
45
|
+
declare function useChat(flow: Record<string, ChatNode>, userId: string, initialNodeId?: string, adapter?: StorageAdapter, options?: ChatOptions): {
|
|
24
46
|
node: ChatNode;
|
|
25
47
|
submitAnswer: (value: any) => Promise<void>;
|
|
48
|
+
submitInput: (inputValue: string) => Promise<void>;
|
|
26
49
|
answers: Record<string, any>;
|
|
50
|
+
messages: ChatMessage[];
|
|
27
51
|
isEnd: boolean;
|
|
52
|
+
sessionId: string;
|
|
53
|
+
reset: (newSessionId?: string) => void;
|
|
28
54
|
};
|
|
29
55
|
|
|
30
|
-
export { ChatEngine, type ChatNode, type ChatState, type StorageAdapter, useChat };
|
|
56
|
+
export { ChatEngine, type ChatMessage, type ChatNode, type ChatOptions, type ChatState, type StorageAdapter, useChat };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
interface ChatNode {
|
|
2
2
|
id: string;
|
|
3
3
|
question: string;
|
|
4
|
+
type?: 'button' | 'input';
|
|
4
5
|
options?: string[];
|
|
5
6
|
next: string | ((answer: any) => string);
|
|
6
7
|
isEnd?: boolean;
|
|
7
8
|
}
|
|
9
|
+
interface ChatMessage {
|
|
10
|
+
nodeId: string;
|
|
11
|
+
question: string;
|
|
12
|
+
answer: any;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
}
|
|
15
|
+
interface ChatOptions {
|
|
16
|
+
saveStrategy?: 'always' | 'onEnd';
|
|
17
|
+
scenarioId?: string;
|
|
18
|
+
/**
|
|
19
|
+
* 세션 ID 설정
|
|
20
|
+
* - 'auto': 마지막 세션 복구 또는 새 세션 생성 (기본값)
|
|
21
|
+
* - 'new': 항상 새로운 세션 생성
|
|
22
|
+
* - string: 특정 세션 ID로 복구 또는 생성
|
|
23
|
+
*/
|
|
24
|
+
sessionId?: 'auto' | 'new' | string;
|
|
25
|
+
}
|
|
8
26
|
interface ChatState {
|
|
9
27
|
answers: Record<string, any>;
|
|
10
28
|
currentStep: string;
|
|
29
|
+
messages: ChatMessage[];
|
|
30
|
+
flowHash: string;
|
|
31
|
+
updatedAt: number;
|
|
11
32
|
}
|
|
12
33
|
interface StorageAdapter {
|
|
13
|
-
|
|
34
|
+
saveState: (userId: string, state: ChatState) => Promise<void>;
|
|
35
|
+
loadState: (userId: string) => Promise<ChatState | null>;
|
|
14
36
|
}
|
|
15
37
|
|
|
16
38
|
declare class ChatEngine {
|
|
@@ -20,11 +42,15 @@ declare class ChatEngine {
|
|
|
20
42
|
getNextStep(currentStepId: string, answer: any): string;
|
|
21
43
|
}
|
|
22
44
|
|
|
23
|
-
declare function useChat(flow: Record<string, ChatNode>, userId: string, adapter?: StorageAdapter): {
|
|
45
|
+
declare function useChat(flow: Record<string, ChatNode>, userId: string, initialNodeId?: string, adapter?: StorageAdapter, options?: ChatOptions): {
|
|
24
46
|
node: ChatNode;
|
|
25
47
|
submitAnswer: (value: any) => Promise<void>;
|
|
48
|
+
submitInput: (inputValue: string) => Promise<void>;
|
|
26
49
|
answers: Record<string, any>;
|
|
50
|
+
messages: ChatMessage[];
|
|
27
51
|
isEnd: boolean;
|
|
52
|
+
sessionId: string;
|
|
53
|
+
reset: (newSessionId?: string) => void;
|
|
28
54
|
};
|
|
29
55
|
|
|
30
|
-
export { ChatEngine, type ChatNode, type ChatState, type StorageAdapter, useChat };
|
|
56
|
+
export { ChatEngine, type ChatMessage, type ChatNode, type ChatOptions, type ChatState, type StorageAdapter, useChat };
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var
|
|
1
|
+
"use strict";var D=Object.defineProperty;var T=Object.getOwnPropertyDescriptor;var k=Object.getOwnPropertyNames;var z=Object.prototype.hasOwnProperty;var B=(t,e)=>{for(var r in e)D(t,r,{get:e[r],enumerable:!0})},G=(t,e,r,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let g of k(e))!z.call(t,g)&&g!==r&&D(t,g,{get:()=>e[g],enumerable:!(a=T(e,g))||a.enumerable});return t};var W=t=>G(D({},"__esModule",{value:!0}),t);var Y={};B(Y,{ChatEngine:()=>M,useChat:()=>X});module.exports=W(Y);var M=class{constructor(e){this.flow=e}getCurrentNode(e){let r=this.flow[e];if(!r)throw new Error(`ChatEngineError: Node with id "${e}" not found in flow.`);return r}getNextStep(e,r){let a=this.flow[e];if(!a)throw new Error(`ChatEngineError: Cannot calculate next step from missing node "${e}".`);return typeof a.next=="function"?a.next(r):a.next}};var i=require("react");var O=t=>{if(t===null||typeof t!="object")return t;if(Array.isArray(t))return t.map(O);let e={};return Object.keys(t).sort().forEach(r=>{e[r]=O(t[r])}),e},P=t=>{let e=O(t),r=JSON.stringify(e),a=0;for(let g=0;g<r.length;g++){let u=r.charCodeAt(g);a=(a<<5)-a+u,a|=0}return a.toString(36)},Q=()=>typeof window<"u"&&window.crypto?.randomUUID?window.crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,t=>{let e=Math.random()*16|0;return(t==="x"?e:e&3|8).toString(16)}),K=t=>t.startsWith("guest_")||/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(t);function X(t,e,r="start",a,g){let u=typeof window<"u",w=g?.scenarioId||"default",m=(0,i.useMemo)(()=>P(t),[t]),h=(0,i.useMemo)(()=>{if(e)return e;if(!u)return"ssr_placeholder";let s=localStorage.getItem("_nago_chatbot_guest_id");return s||(s=`guest_${Q()}`,localStorage.setItem("_nago_chatbot_guest_id",s)),s},[e,u]),U=(0,i.useCallback)(()=>{if(!u)return"ssr_placeholder";let s=g?.sessionId,o=`_nago_chat_last_session_${w}_${h}`;if(s==="new"){let d=`session_${Date.now()}_${Math.random().toString(36).substr(2,9)}`;return localStorage.setItem(o,d),d}if(s&&s!=="auto")return localStorage.setItem(o,s),s;let l=localStorage.getItem(o);if(l)return l;let n=`session_${Date.now()}_${Math.random().toString(36).substr(2,9)}`;return localStorage.setItem(o,n),n},[u,g?.sessionId,w,h]),[I,q]=(0,i.useState)(()=>U()),y=(0,i.useCallback)(s=>`_nago_chat_${w}_${h}_${s}`,[w,h]),x=(0,i.useMemo)(()=>new M(t),[t]),[f,C]=(0,i.useState)(r),[v,_]=(0,i.useState)({}),[N,p]=(0,i.useState)([]),[b,E]=(0,i.useState)(!1);(0,i.useEffect)(()=>{C(r),_({}),p([]),E(!1)},[t,r]),(0,i.useEffect)(()=>{if(!u||b)return;(async()=>{let o=y(I),l=K(h);try{let n=null;!l&&a?.loadState&&(n=await a.loadState(h));let d=localStorage.getItem(o),c=d?JSON.parse(d):null,A=n||c;if(A&&A.flowHash!==m){console.log("[useChat] Scenario updated. Clearing old state."),localStorage.removeItem(o),E(!0);return}let S=null;n&&c?S=n.updatedAt>=c.updatedAt?n:c:S=n||c,S&&(C(S.currentStep),_(S.answers),p(S.messages))}catch(n){console.error("[useChat] Failed to load saved state:",n)}finally{E(!0)}})()},[u,h,m,w,a,b,I,y]);let $=(0,i.useCallback)(async(s,o,l)=>{if(!u)return;let n=g?.saveStrategy||"always",d=t[s],c=K(h);if(!(n==="always"||n==="onEnd"&&d?.isEnd))return;let S={answers:o,currentStep:s,messages:l,flowHash:m,updatedAt:Date.now()},R=y(I);if(localStorage.setItem(R,JSON.stringify(S)),(!c||d?.isEnd)&&a?.saveState)try{await a.saveState(h,S)}catch(L){console.error("[useChat] Failed to save to server:",L)}},[u,a,g,t,m,h,I,y]),F=(0,i.useCallback)(async s=>{try{let o=x.getCurrentNode(f),l=x.getNextStep(f,s),n={...v,[o.id]:s},d={nodeId:o.id,question:o.question,answer:s,timestamp:Date.now()},c=[...N,d];_(n),C(l),p(c),await $(l,n,c)}catch(o){throw o}},[f,x,v,N,$]),H=(0,i.useCallback)(async s=>{if(s.trim())try{let o=x.getCurrentNode(f),l=x.getNextStep(f,s),n={...v,[o.id]:s},d={nodeId:o.id,question:o.question,answer:s,timestamp:Date.now()},c=[...N,d];_(n),C(l),p(c),await $(l,n,c)}catch(o){throw o}},[f,x,v,N,$]),J=(0,i.useCallback)(s=>{if(!u)return;let o=s||`session_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,l=`_nago_chat_last_session_${w}_${h}`;if(localStorage.setItem(l,o),q(o),s){let n=y(o),d=localStorage.getItem(n);if(d)try{let c=JSON.parse(d);if(c.flowHash===m){C(c.currentStep),_(c.answers),p(c.messages),console.log("[useChat] Session restored:",o);return}else console.log("[useChat] Session flowHash mismatch. Starting fresh.")}catch(c){console.error("[useChat] Failed to restore session:",c)}}C(r),_({}),p([]),console.log("[useChat] New session started:",o)},[u,w,h,m,r,y]);return{node:x.getCurrentNode(f),submitAnswer:F,submitInput:H,answers:v,messages:N,isEnd:!!t[f]?.isEnd,sessionId:I,reset:J}}0&&(module.exports={ChatEngine,useChat});
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var E=class{constructor(n){this.flow=n}getCurrentNode(n){let a=this.flow[n];if(!a)throw new Error(`ChatEngineError: Node with id "${n}" not found in flow.`);return a}getNextStep(n,a){let c=this.flow[n];if(!c)throw new Error(`ChatEngineError: Cannot calculate next step from missing node "${n}".`);return typeof c.next=="function"?c.next(a):c.next}};import{useState as M,useCallback as p,useMemo as O,useEffect as U}from"react";var b=s=>{if(s===null||typeof s!="object")return s;if(Array.isArray(s))return s.map(b);let n={};return Object.keys(s).sort().forEach(a=>{n[a]=b(s[a])}),n},z=s=>{let n=b(s),a=JSON.stringify(n),c=0;for(let u=0;u<a.length;u++){let d=a.charCodeAt(u);c=(c<<5)-c+d,c|=0}return c.toString(36)},B=()=>typeof window<"u"&&window.crypto?.randomUUID?window.crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,s=>{let n=Math.random()*16|0;return(s==="x"?n:n&3|8).toString(16)}),q=s=>s.startsWith("guest_")||/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s);function X(s,n,a="start",c,u){let d=typeof window<"u",f=u?.scenarioId||"default",x=O(()=>z(s),[s]),l=O(()=>{if(n)return n;if(!d)return"ssr_placeholder";let t=localStorage.getItem("_nago_chatbot_guest_id");return t||(t=`guest_${B()}`,localStorage.setItem("_nago_chatbot_guest_id",t)),t},[n,d]),F=p(()=>{if(!d)return"ssr_placeholder";let t=u?.sessionId,e=`_nago_chat_last_session_${f}_${l}`;if(t==="new"){let i=`session_${Date.now()}_${Math.random().toString(36).substr(2,9)}`;return localStorage.setItem(e,i),i}if(t&&t!=="auto")return localStorage.setItem(e,t),t;let g=localStorage.getItem(e);if(g)return g;let o=`session_${Date.now()}_${Math.random().toString(36).substr(2,9)}`;return localStorage.setItem(e,o),o},[d,u?.sessionId,f,l]),[I,H]=M(()=>F()),m=p(t=>`_nago_chat_${f}_${l}_${t}`,[f,l]),w=O(()=>new E(s),[s]),[S,y]=M(a),[v,C]=M({}),[N,_]=M([]),[K,A]=M(!1);U(()=>{y(a),C({}),_([]),A(!1)},[s,a]),U(()=>{if(!d||K)return;(async()=>{let e=m(I),g=q(l);try{let o=null;!g&&c?.loadState&&(o=await c.loadState(l));let i=localStorage.getItem(e),r=i?JSON.parse(i):null,D=o||r;if(D&&D.flowHash!==x){console.log("[useChat] Scenario updated. Clearing old state."),localStorage.removeItem(e),A(!0);return}let h=null;o&&r?h=o.updatedAt>=r.updatedAt?o:r:h=o||r,h&&(y(h.currentStep),C(h.answers),_(h.messages))}catch(o){console.error("[useChat] Failed to load saved state:",o)}finally{A(!0)}})()},[d,l,x,f,c,K,I,m]);let $=p(async(t,e,g)=>{if(!d)return;let o=u?.saveStrategy||"always",i=s[t],r=q(l);if(!(o==="always"||o==="onEnd"&&i?.isEnd))return;let h={answers:e,currentStep:t,messages:g,flowHash:x,updatedAt:Date.now()},T=m(I);if(localStorage.setItem(T,JSON.stringify(h)),(!r||i?.isEnd)&&c?.saveState)try{await c.saveState(l,h)}catch(k){console.error("[useChat] Failed to save to server:",k)}},[d,c,u,s,x,l,I,m]),J=p(async t=>{try{let e=w.getCurrentNode(S),g=w.getNextStep(S,t),o={...v,[e.id]:t},i={nodeId:e.id,question:e.question,answer:t,timestamp:Date.now()},r=[...N,i];C(o),y(g),_(r),await $(g,o,r)}catch(e){throw e}},[S,w,v,N,$]),R=p(async t=>{if(t.trim())try{let e=w.getCurrentNode(S),g=w.getNextStep(S,t),o={...v,[e.id]:t},i={nodeId:e.id,question:e.question,answer:t,timestamp:Date.now()},r=[...N,i];C(o),y(g),_(r),await $(g,o,r)}catch(e){throw e}},[S,w,v,N,$]),L=p(t=>{if(!d)return;let e=t||`session_${Date.now()}_${Math.random().toString(36).substr(2,9)}`,g=`_nago_chat_last_session_${f}_${l}`;if(localStorage.setItem(g,e),H(e),t){let o=m(e),i=localStorage.getItem(o);if(i)try{let r=JSON.parse(i);if(r.flowHash===x){y(r.currentStep),C(r.answers),_(r.messages),console.log("[useChat] Session restored:",e);return}else console.log("[useChat] Session flowHash mismatch. Starting fresh.")}catch(r){console.error("[useChat] Failed to restore session:",r)}}y(a),C({}),_([]),console.log("[useChat] New session started:",e)},[d,f,l,x,a,m]);return{node:w.getCurrentNode(S),submitAnswer:J,submitInput:R,answers:v,messages:N,isEnd:!!s[S]?.isEnd,sessionId:I,reset:L}}export{E as ChatEngine,X as useChat};
|