@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 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
- ### 실제 사용 예시: `App.tsx`
1
+ # @nago730/chatbot-library
2
2
 
3
- 코드는 당신이 만든 라이브러리를 `npm install` 했다고 가정하고 작성되었습니다.
3
+ > **JSON 하나로 만드는 프로덕션 레디 챗봇 엔진** React 환경에서 복잡한 대화형 인터페이스를 5분 안에 구축하세요.
4
4
 
5
- ```tsx
6
- import React from 'react';
7
- // 1. 당신이 만든 라이브러리에서 필요한 부품들을 가져옵니다.
8
- import { useChat, ChatNode } from '@your-id/chatbot-library';
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
- // 2. 청소업체 전용 대화 시나리오(Flow) 정의
11
- const CLEANING_FLOW: Record<string, ChatNode> = {
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: "serviceType",
14
- question: "안녕하세요! 어떤 청소 서비스가 필요하신가요?",
15
- options: ["이사청소", "거주청소", "사무실청소"],
16
- next: (val) => (val === "거주청소" ? "isVacant" : "spaceSize"),
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
- isVacant: {
19
- id: "isVacant",
20
- question: "현재 짐이 있는 상태인가요?",
21
- options: ["네, 비어있어요", "아니오, 짐이 있어요"],
22
- next: "spaceSize",
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: "complete",
31
- question: "모든 정보가 수집되었습니다. 상담원이 연락드릴게요!",
32
- isEnd: true,
33
- next: ""
34
- },
53
+ id: 'complete',
54
+ question: '감사합니다.연락드리겠습니다.',
55
+ next: '',
56
+ isEnd: true
57
+ }
35
58
  };
59
+ ```
36
60
 
37
- export default function App() {
38
- // 3. 라이브러리의 useChat 훅 사용
39
- // userId는 실제 서비스라면 로그인한 사용자의 ID를 넣으면 됩니다.
40
- const { node, submitAnswer, answers, isEnd } = useChat(CLEANING_FLOW, "customer_001");
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 style={{ maxWidth: '500px', margin: '40px auto', fontFamily: 'sans-serif' }}>
44
- <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '15px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}>
45
- <h2>🧹 청소 견적 도우미</h2>
46
- <hr />
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
- {/* 4. 실시간 데이터 요약 영역 (사용자에게 현재까지 입력한 정보를 보여줌) */}
86
- <div style={{ marginTop: '30px', padding: '15px', backgroundColor: '#f8f9fa', borderRadius: '10px' }}>
87
- <h4 style={{ marginTop: 0 }}>📋 현재까지 수집된 정보</h4>
88
- <ul style={{ fontSize: '14px', color: '#666' }}>
89
- <li>서비스 종류: {answers.serviceType || '-'}</li>
90
- {answers.isVacant && <li>공실 여부: {answers.isVacant}</li>}
91
- <li>평수: {answers.spaceSize ? `${answers.spaceSize}평` : '-'}</li>
92
- </ul>
93
- </div>
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
- {isEnd && (
96
- <div style={{ textAlign: 'center', color: 'green', fontWeight: 'bold', marginTop: '20px' }}>
97
- 신청이 완료되었습니다!
98
- </div>
99
- )}
100
- </div>
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
- ### 💡 `App.tsx` 코드가 특별한 이유
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
- 1. **실시간 요약:** 하단에 `answers` 객체를 이용해 사용자가 입력한 내용을 바로 보여줍니다. 이는 고객에게 신뢰감을 줍니다.
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
- save: (userId: string, data: any) => Promise<void>;
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
- save: (userId: string, data: any) => Promise<void>;
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 c=Object.defineProperty;var x=Object.getOwnPropertyDescriptor;var N=Object.getOwnPropertyNames;var w=Object.prototype.hasOwnProperty;var C=(e,t)=>{for(var r in t)c(e,r,{get:t[r],enumerable:!0})},E=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of N(t))!w.call(e,o)&&o!==r&&c(e,o,{get:()=>t[o],enumerable:!(n=x(t,o))||n.enumerable});return e};var y=e=>E(c({},"__esModule",{value:!0}),e);var A={};C(A,{ChatEngine:()=>i,useChat:()=>S});module.exports=y(A);var i=class{constructor(t){this.flow=t}getCurrentNode(t){let r=this.flow[t];if(!r)throw new Error(`ChatEngineError: Node with id "${t}" not found in flow.`);return r}getNextStep(t,r){let n=this.flow[t];if(!n)throw new Error(`ChatEngineError: Cannot calculate next step from missing node "${t}".`);return typeof n.next=="function"?n.next(r):n.next}};var s=require("react");function S(e,t,r){let n=(0,s.useMemo)(()=>new i(e),[e]),[o,m]=(0,s.useState)("start"),[a,p]=(0,s.useState)({}),u=(0,s.useCallback)(async g=>{try{let d=n.getCurrentNode(o),f=n.getNextStep(o,g),h={...a,[d.id]:g};p(h),m(f),e[f]?.isEnd&&r&&await r.save(t,h)}catch(d){throw d}},[o,n,a,e,t,r]);return{node:n.getCurrentNode(o),submitAnswer:u,answers:a,isEnd:!!e[o]?.isEnd}}0&&(module.exports={ChatEngine,useChat});
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 s=class{constructor(t){this.flow=t}getCurrentNode(t){let r=this.flow[t];if(!r)throw new Error(`ChatEngineError: Node with id "${t}" not found in flow.`);return r}getNextStep(t,r){let e=this.flow[t];if(!e)throw new Error(`ChatEngineError: Cannot calculate next step from missing node "${t}".`);return typeof e.next=="function"?e.next(r):e.next}};import{useState as f,useCallback as u,useMemo as x}from"react";function y(n,t,r){let e=x(()=>new s(n),[n]),[o,h]=f("start"),[i,m]=f({}),p=u(async d=>{try{let a=e.getCurrentNode(o),c=e.getNextStep(o,d),g={...i,[a.id]:d};m(g),h(c),n[c]?.isEnd&&r&&await r.save(t,g)}catch(a){throw a}},[o,e,i,n,t,r]);return{node:e.getCurrentNode(o),submitAnswer:p,answers:i,isEnd:!!n[o]?.isEnd}}export{s as ChatEngine,y as useChat};
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};