@nago730/chatbot-library 1.0.0 → 1.1.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/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/src/useChat.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
2
|
+
import { ChatEngine } from './engine';
|
|
3
|
+
import { ChatNode, ChatMessage, ChatState, ChatOptions, StorageAdapter } from './types';
|
|
4
|
+
|
|
5
|
+
// 재귀적으로 객체 키를 정렬하여 결정론적 직렬화
|
|
6
|
+
const sortObjectKeys = (obj: any): any => {
|
|
7
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
8
|
+
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
|
|
9
|
+
|
|
10
|
+
const sorted: any = {};
|
|
11
|
+
Object.keys(obj).sort().forEach(key => {
|
|
12
|
+
sorted[key] = sortObjectKeys(obj[key]);
|
|
13
|
+
});
|
|
14
|
+
return sorted;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 콘텐츠 해시 생성 (키 순서에 무관)
|
|
18
|
+
const getFlowHash = (flow: any): string => {
|
|
19
|
+
const sortedFlow = sortObjectKeys(flow);
|
|
20
|
+
const str = JSON.stringify(sortedFlow);
|
|
21
|
+
let hash = 0;
|
|
22
|
+
for (let i = 0; i < str.length; i++) {
|
|
23
|
+
const char = str.charCodeAt(i);
|
|
24
|
+
hash = (hash << 5) - hash + char;
|
|
25
|
+
hash |= 0;
|
|
26
|
+
}
|
|
27
|
+
return hash.toString(36);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// UUID 생성 (crypto API 폴백 포함)
|
|
31
|
+
const generateUUID = (): string => {
|
|
32
|
+
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
|
33
|
+
return window.crypto.randomUUID();
|
|
34
|
+
}
|
|
35
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
36
|
+
const r = (Math.random() * 16) | 0;
|
|
37
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
38
|
+
return v.toString(16);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Guest 사용자 체크
|
|
43
|
+
const isGuest = (userId: string): boolean => {
|
|
44
|
+
return userId.startsWith('guest_') ||
|
|
45
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(userId);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function useChat(
|
|
49
|
+
flow: Record<string, ChatNode>,
|
|
50
|
+
userId: string,
|
|
51
|
+
initialNodeId: string = 'start',
|
|
52
|
+
adapter?: StorageAdapter,
|
|
53
|
+
options?: ChatOptions
|
|
54
|
+
) {
|
|
55
|
+
const isBrowser = typeof window !== 'undefined';
|
|
56
|
+
const scenarioId = options?.scenarioId || 'default';
|
|
57
|
+
const flowHash = useMemo(() => getFlowHash(flow), [flow]);
|
|
58
|
+
|
|
59
|
+
// Guest ID 처리 (SSR 안전)
|
|
60
|
+
const effectiveUserId = useMemo(() => {
|
|
61
|
+
if (userId) return userId;
|
|
62
|
+
if (!isBrowser) return 'ssr_placeholder';
|
|
63
|
+
|
|
64
|
+
let guestId = localStorage.getItem('_nago_chatbot_guest_id');
|
|
65
|
+
if (!guestId) {
|
|
66
|
+
guestId = `guest_${generateUUID()}`;
|
|
67
|
+
localStorage.setItem('_nago_chatbot_guest_id', guestId);
|
|
68
|
+
}
|
|
69
|
+
return guestId;
|
|
70
|
+
}, [userId, isBrowser]);
|
|
71
|
+
|
|
72
|
+
// ⭐ 세션 ID 초기화 로직 (Smart Loading)
|
|
73
|
+
const initializeSessionId = useCallback((): string => {
|
|
74
|
+
if (!isBrowser) return 'ssr_placeholder';
|
|
75
|
+
|
|
76
|
+
const requestedSessionId = options?.sessionId;
|
|
77
|
+
const lastSessionKey = `_nago_chat_last_session_${scenarioId}_${effectiveUserId}`;
|
|
78
|
+
|
|
79
|
+
// 1. 옵션에 'new'가 지정되면 항상 새 세션 생성
|
|
80
|
+
if (requestedSessionId === 'new') {
|
|
81
|
+
const newId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
82
|
+
localStorage.setItem(lastSessionKey, newId);
|
|
83
|
+
return newId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. 특정 세션 ID가 지정되면 해당 세션 사용
|
|
87
|
+
if (requestedSessionId && requestedSessionId !== 'auto') {
|
|
88
|
+
localStorage.setItem(lastSessionKey, requestedSessionId);
|
|
89
|
+
return requestedSessionId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. 'auto' 또는 미지정: 마지막 세션 복구 또는 새로 생성
|
|
93
|
+
const lastSessionId = localStorage.getItem(lastSessionKey);
|
|
94
|
+
if (lastSessionId) {
|
|
95
|
+
return lastSessionId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 4. 마지막 세션도 없으면 새로 생성
|
|
99
|
+
const newId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
100
|
+
localStorage.setItem(lastSessionKey, newId);
|
|
101
|
+
return newId;
|
|
102
|
+
}, [isBrowser, options?.sessionId, scenarioId, effectiveUserId]);
|
|
103
|
+
|
|
104
|
+
// ⭐ 세션 ID 상태 관리
|
|
105
|
+
const [currentSessionId, setCurrentSessionId] = useState<string>(() => initializeSessionId());
|
|
106
|
+
|
|
107
|
+
// ⭐ 세션 기반 스토리지 키 생성
|
|
108
|
+
const getStorageKey = useCallback((sessionId: string) => {
|
|
109
|
+
return `_nago_chat_${scenarioId}_${effectiveUserId}_${sessionId}`;
|
|
110
|
+
}, [scenarioId, effectiveUserId]);
|
|
111
|
+
|
|
112
|
+
const engine = useMemo(() => new ChatEngine(flow), [flow]);
|
|
113
|
+
|
|
114
|
+
// 🔴 CRITICAL: Hydration 안전 - 초기 상태는 항상 동일
|
|
115
|
+
const [stepId, setStepId] = useState(initialNodeId);
|
|
116
|
+
const [answers, setAnswers] = useState<Record<string, any>>({});
|
|
117
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
118
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
119
|
+
|
|
120
|
+
// flow나 initialNodeId 변경 시 상태 초기화
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
setStepId(initialNodeId);
|
|
123
|
+
setAnswers({});
|
|
124
|
+
setMessages([]);
|
|
125
|
+
setIsLoaded(false);
|
|
126
|
+
}, [flow, initialNodeId]);
|
|
127
|
+
|
|
128
|
+
// 🔴 CRITICAL: 상태 복구는 100% useEffect에서만 (클라이언트 전용)
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!isBrowser || isLoaded) return;
|
|
131
|
+
|
|
132
|
+
const loadSavedState = async () => {
|
|
133
|
+
const storageKey = getStorageKey(currentSessionId); // ⭐ 세션 기반 키 사용
|
|
134
|
+
const guestMode = isGuest(effectiveUserId);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// 1. 서버 데이터 로드 (Guest가 아닐 때만)
|
|
138
|
+
let serverState: ChatState | null = null;
|
|
139
|
+
if (!guestMode && adapter?.loadState) {
|
|
140
|
+
// ⭐ 어댑터에 세션 ID도 전달 (향후 확장 가능)
|
|
141
|
+
serverState = await adapter.loadState(effectiveUserId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. 로컬 데이터 로드 (세션별)
|
|
145
|
+
const localData = localStorage.getItem(storageKey);
|
|
146
|
+
const localState: ChatState | null = localData ? JSON.parse(localData) : null;
|
|
147
|
+
|
|
148
|
+
// 3. 시나리오 해시 검증 (서버/로컬 모두 체크)
|
|
149
|
+
const activeState = serverState || localState;
|
|
150
|
+
if (activeState && activeState.flowHash !== flowHash) {
|
|
151
|
+
console.log('[useChat] Scenario updated. Clearing old state.');
|
|
152
|
+
localStorage.removeItem(storageKey);
|
|
153
|
+
setIsLoaded(true);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 4. 서버 vs 로컬 우선순위 결정 (최신 데이터 선택)
|
|
158
|
+
let targetState: ChatState | null = null;
|
|
159
|
+
if (serverState && localState) {
|
|
160
|
+
targetState = serverState.updatedAt >= localState.updatedAt ? serverState : localState;
|
|
161
|
+
} else {
|
|
162
|
+
targetState = serverState || localState;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 5. 상태 복구
|
|
166
|
+
if (targetState) {
|
|
167
|
+
setStepId(targetState.currentStep);
|
|
168
|
+
setAnswers(targetState.answers);
|
|
169
|
+
setMessages(targetState.messages);
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error('[useChat] Failed to load saved state:', error);
|
|
173
|
+
} finally {
|
|
174
|
+
setIsLoaded(true);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
loadSavedState();
|
|
179
|
+
}, [isBrowser, effectiveUserId, flowHash, scenarioId, adapter, isLoaded, currentSessionId, getStorageKey]);
|
|
180
|
+
|
|
181
|
+
// 저장 로직 헬퍼
|
|
182
|
+
const saveIfNeeded = useCallback(async (
|
|
183
|
+
nextStepId: string,
|
|
184
|
+
newAnswers: Record<string, any>,
|
|
185
|
+
newMessages: ChatMessage[]
|
|
186
|
+
) => {
|
|
187
|
+
if (!isBrowser) return;
|
|
188
|
+
|
|
189
|
+
const saveStrategy = options?.saveStrategy || 'always';
|
|
190
|
+
const nextNode = flow[nextStepId];
|
|
191
|
+
const guestMode = isGuest(effectiveUserId);
|
|
192
|
+
|
|
193
|
+
// saveStrategy에 따라 저장 여부 결정
|
|
194
|
+
const shouldSave = saveStrategy === 'always' || (saveStrategy === 'onEnd' && nextNode?.isEnd);
|
|
195
|
+
if (!shouldSave) return;
|
|
196
|
+
|
|
197
|
+
const state: ChatState = {
|
|
198
|
+
answers: newAnswers,
|
|
199
|
+
currentStep: nextStepId,
|
|
200
|
+
messages: newMessages,
|
|
201
|
+
flowHash,
|
|
202
|
+
updatedAt: Date.now()
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// 로컬 저장 (세션별 키 사용)
|
|
206
|
+
const storageKey = getStorageKey(currentSessionId); // ⭐ 세션 기반 키
|
|
207
|
+
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
208
|
+
|
|
209
|
+
// 서버 저장 조건:
|
|
210
|
+
// - Guest가 아니면 항상 저장
|
|
211
|
+
// - Guest이면 대화 종료 시점(isEnd)에만 저장
|
|
212
|
+
const shouldSaveToServer = !guestMode || nextNode?.isEnd;
|
|
213
|
+
|
|
214
|
+
if (shouldSaveToServer && adapter?.saveState) {
|
|
215
|
+
try {
|
|
216
|
+
await adapter.saveState(effectiveUserId, state);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error('[useChat] Failed to save to server:', error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, [isBrowser, adapter, options, flow, flowHash, effectiveUserId, currentSessionId, getStorageKey]);
|
|
222
|
+
|
|
223
|
+
const submitAnswer = useCallback(async (value: any) => {
|
|
224
|
+
try {
|
|
225
|
+
const currentNode = engine.getCurrentNode(stepId);
|
|
226
|
+
const nextStepId = engine.getNextStep(stepId, value);
|
|
227
|
+
|
|
228
|
+
const newAnswers = { ...answers, [currentNode.id]: value };
|
|
229
|
+
|
|
230
|
+
// 메시지 히스토리에 기록
|
|
231
|
+
const newMessage: ChatMessage = {
|
|
232
|
+
nodeId: currentNode.id,
|
|
233
|
+
question: currentNode.question,
|
|
234
|
+
answer: value,
|
|
235
|
+
timestamp: Date.now()
|
|
236
|
+
};
|
|
237
|
+
const newMessages = [...messages, newMessage];
|
|
238
|
+
|
|
239
|
+
setAnswers(newAnswers);
|
|
240
|
+
setStepId(nextStepId);
|
|
241
|
+
setMessages(newMessages);
|
|
242
|
+
|
|
243
|
+
// 저장 로직 (전략에 따라 실행)
|
|
244
|
+
await saveIfNeeded(nextStepId, newAnswers, newMessages);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// 라이브러리 사용자가 에러를 처리할 수 있도록 다시 던지거나,
|
|
247
|
+
// 필요에 따라 상태에 에러를 저장할 수 있습니다.
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}, [stepId, engine, answers, messages, saveIfNeeded]);
|
|
251
|
+
|
|
252
|
+
const submitInput = useCallback(async (inputValue: string) => {
|
|
253
|
+
if (!inputValue.trim()) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const currentNode = engine.getCurrentNode(stepId);
|
|
259
|
+
const nextStepId = engine.getNextStep(stepId, inputValue);
|
|
260
|
+
|
|
261
|
+
const newAnswers = { ...answers, [currentNode.id]: inputValue };
|
|
262
|
+
|
|
263
|
+
// 메시지 히스토리에 기록
|
|
264
|
+
const newMessage: ChatMessage = {
|
|
265
|
+
nodeId: currentNode.id,
|
|
266
|
+
question: currentNode.question,
|
|
267
|
+
answer: inputValue,
|
|
268
|
+
timestamp: Date.now()
|
|
269
|
+
};
|
|
270
|
+
const newMessages = [...messages, newMessage];
|
|
271
|
+
|
|
272
|
+
setAnswers(newAnswers);
|
|
273
|
+
setStepId(nextStepId);
|
|
274
|
+
setMessages(newMessages);
|
|
275
|
+
|
|
276
|
+
// 저장 로직 (전략에 따라 실행)
|
|
277
|
+
await saveIfNeeded(nextStepId, newAnswers, newMessages);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}, [stepId, engine, answers, messages, saveIfNeeded]);
|
|
282
|
+
|
|
283
|
+
// ⭐ 세션 리셋 함수 (새 상담 시작 또는 특정 세션 불러오기)
|
|
284
|
+
const reset = useCallback((newSessionId?: string) => {
|
|
285
|
+
if (!isBrowser) return;
|
|
286
|
+
|
|
287
|
+
// 1. 새 세션 ID 결정
|
|
288
|
+
const targetSessionId = newSessionId || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
289
|
+
|
|
290
|
+
// 2. 마지막 세션 업데이트
|
|
291
|
+
const lastSessionKey = `_nago_chat_last_session_${scenarioId}_${effectiveUserId}`;
|
|
292
|
+
localStorage.setItem(lastSessionKey, targetSessionId);
|
|
293
|
+
|
|
294
|
+
// 3. 세션 ID 변경
|
|
295
|
+
setCurrentSessionId(targetSessionId);
|
|
296
|
+
|
|
297
|
+
// 4. 특정 세션을 불러오는 경우
|
|
298
|
+
if (newSessionId) {
|
|
299
|
+
const storageKey = getStorageKey(targetSessionId);
|
|
300
|
+
const sessionData = localStorage.getItem(storageKey);
|
|
301
|
+
|
|
302
|
+
if (sessionData) {
|
|
303
|
+
try {
|
|
304
|
+
const savedState: ChatState = JSON.parse(sessionData);
|
|
305
|
+
|
|
306
|
+
// 시나리오 해시 검증
|
|
307
|
+
if (savedState.flowHash === flowHash) {
|
|
308
|
+
setStepId(savedState.currentStep);
|
|
309
|
+
setAnswers(savedState.answers);
|
|
310
|
+
setMessages(savedState.messages);
|
|
311
|
+
console.log('[useChat] Session restored:', targetSessionId);
|
|
312
|
+
return;
|
|
313
|
+
} else {
|
|
314
|
+
console.log('[useChat] Session flowHash mismatch. Starting fresh.');
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
console.error('[useChat] Failed to restore session:', error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 5. 새 세션 또는 복구 실패 시 초기화
|
|
323
|
+
setStepId(initialNodeId);
|
|
324
|
+
setAnswers({});
|
|
325
|
+
setMessages([]);
|
|
326
|
+
console.log('[useChat] New session started:', targetSessionId);
|
|
327
|
+
}, [isBrowser, scenarioId, effectiveUserId, flowHash, initialNodeId, getStorageKey]);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
node: engine.getCurrentNode(stepId),
|
|
331
|
+
submitAnswer,
|
|
332
|
+
submitInput,
|
|
333
|
+
answers,
|
|
334
|
+
messages,
|
|
335
|
+
isEnd: !!flow[stepId]?.isEnd,
|
|
336
|
+
sessionId: currentSessionId, // ⭐ 현재 세션 ID
|
|
337
|
+
reset // ⭐ 세션 리셋 함수
|
|
338
|
+
};
|
|
339
|
+
}
|