@donghanh/react 0.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/package.json +22 -0
- package/src/hooks.ts +25 -0
- package/src/index.ts +12 -0
- package/src/provider.tsx +267 -0
- package/tsconfig.json +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@donghanh/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless React hooks for donghanh — useChat, useInitOperation, DongHanhProvider",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/rezzahub/donghanh",
|
|
13
|
+
"directory": "packages/react"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@donghanh/core": "workspace:*"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
20
|
+
"typescript": "^5.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import type { InitOperation } from "./provider";
|
|
5
|
+
import { useChatContext } from "./provider";
|
|
6
|
+
|
|
7
|
+
export function useChat() {
|
|
8
|
+
const { messages, isLoading, sendMessage, stop } = useChatContext();
|
|
9
|
+
return { messages, isLoading, sendMessage, stop };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useInitOperation(
|
|
13
|
+
id: string,
|
|
14
|
+
variables?: Record<string, unknown>,
|
|
15
|
+
) {
|
|
16
|
+
const { setInitOperation } = useChatContext();
|
|
17
|
+
const varsRef = useRef(variables);
|
|
18
|
+
varsRef.current = variables;
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const op: InitOperation = { id, variables: varsRef.current };
|
|
22
|
+
setInitOperation(op);
|
|
23
|
+
return () => setInitOperation(null);
|
|
24
|
+
}, [id, setInitOperation]);
|
|
25
|
+
}
|
package/src/index.ts
ADDED
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
// --- Config ---
|
|
13
|
+
|
|
14
|
+
export interface DongHanhConfig {
|
|
15
|
+
endpoint: string;
|
|
16
|
+
getAuthToken: () => string | Promise<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ConfigContext = createContext<DongHanhConfig | null>(null);
|
|
20
|
+
|
|
21
|
+
export function useDongHanhConfig(): DongHanhConfig {
|
|
22
|
+
const ctx = useContext(ConfigContext);
|
|
23
|
+
if (!ctx) {
|
|
24
|
+
throw new Error("useDongHanhConfig must be used within DongHanhProvider");
|
|
25
|
+
}
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Chat types ---
|
|
30
|
+
|
|
31
|
+
export interface ChatAction {
|
|
32
|
+
operation: string;
|
|
33
|
+
label: string;
|
|
34
|
+
variables?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ChatMessageItem {
|
|
38
|
+
id: string;
|
|
39
|
+
role: "user" | "assistant";
|
|
40
|
+
content: string;
|
|
41
|
+
actions?: ChatAction[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface InitOperation {
|
|
45
|
+
id: string;
|
|
46
|
+
variables?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Chat context ---
|
|
50
|
+
|
|
51
|
+
interface ChatContextValue {
|
|
52
|
+
messages: ChatMessageItem[];
|
|
53
|
+
isLoading: boolean;
|
|
54
|
+
initOperation: InitOperation | null;
|
|
55
|
+
sendMessage: (content: string, action?: ChatAction) => Promise<void>;
|
|
56
|
+
setInitOperation: (op: InitOperation | null) => void;
|
|
57
|
+
stop: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
61
|
+
|
|
62
|
+
let messageCounter = 0;
|
|
63
|
+
|
|
64
|
+
export function useChatContext(): ChatContextValue {
|
|
65
|
+
const ctx = useContext(ChatContext);
|
|
66
|
+
if (!ctx) {
|
|
67
|
+
throw new Error("useChatContext must be used within DongHanhProvider");
|
|
68
|
+
}
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- SSE parser ---
|
|
73
|
+
|
|
74
|
+
function parseSSE(
|
|
75
|
+
text: string,
|
|
76
|
+
onEvent: (event: string, data: string) => void,
|
|
77
|
+
): string {
|
|
78
|
+
const lines = text.split("\n");
|
|
79
|
+
let remaining = "";
|
|
80
|
+
let currentEvent = "";
|
|
81
|
+
let currentData = "";
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
|
|
86
|
+
// If this is the last line and doesn't end with \n, it's incomplete
|
|
87
|
+
if (i === lines.length - 1 && !text.endsWith("\n")) {
|
|
88
|
+
remaining = line;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (line.startsWith("event: ")) {
|
|
93
|
+
currentEvent = line.slice(7).trim();
|
|
94
|
+
} else if (line.startsWith("data: ")) {
|
|
95
|
+
currentData = line.slice(6);
|
|
96
|
+
} else if (line === "") {
|
|
97
|
+
// Empty line = end of event
|
|
98
|
+
if (currentEvent) {
|
|
99
|
+
onEvent(currentEvent, currentData);
|
|
100
|
+
}
|
|
101
|
+
currentEvent = "";
|
|
102
|
+
currentData = "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return remaining;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Provider ---
|
|
110
|
+
|
|
111
|
+
export function DongHanhProvider(props: {
|
|
112
|
+
config: DongHanhConfig;
|
|
113
|
+
children: ReactNode;
|
|
114
|
+
}) {
|
|
115
|
+
const { config } = props;
|
|
116
|
+
const [messages, setMessages] = useState<ChatMessageItem[]>([]);
|
|
117
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
118
|
+
const [initOperation, setInitOperation] = useState<InitOperation | null>(
|
|
119
|
+
null,
|
|
120
|
+
);
|
|
121
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
122
|
+
const initOpRef = useRef<InitOperation | null>(null);
|
|
123
|
+
initOpRef.current = initOperation;
|
|
124
|
+
|
|
125
|
+
const sendMessage = useCallback(
|
|
126
|
+
async (content: string, action?: ChatAction) => {
|
|
127
|
+
const userMsg: ChatMessageItem = {
|
|
128
|
+
id: `msg-${++messageCounter}`,
|
|
129
|
+
role: "user",
|
|
130
|
+
content,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const updatedMessages = [...messages, userMsg];
|
|
134
|
+
setMessages(updatedMessages);
|
|
135
|
+
setIsLoading(true);
|
|
136
|
+
|
|
137
|
+
const assistantId = `msg-${++messageCounter}`;
|
|
138
|
+
|
|
139
|
+
// Add empty assistant message that we'll stream into
|
|
140
|
+
setMessages((prev) => [
|
|
141
|
+
...prev,
|
|
142
|
+
{ id: assistantId, role: "assistant", content: "" },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const token = await config.getAuthToken();
|
|
147
|
+
abortRef.current = new AbortController();
|
|
148
|
+
|
|
149
|
+
const body: Record<string, unknown> = {
|
|
150
|
+
messages: updatedMessages.map((m) => ({
|
|
151
|
+
role: m.role,
|
|
152
|
+
content: m.content,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (updatedMessages.length === 1 && initOpRef.current) {
|
|
157
|
+
body.initOperation = initOpRef.current;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Send action details for direct execution
|
|
161
|
+
if (action) {
|
|
162
|
+
body.action = action;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const resp = await fetch(config.endpoint, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
Authorization: `Bearer ${token}`,
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify(body),
|
|
172
|
+
signal: abortRef.current.signal,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!resp.ok) {
|
|
176
|
+
setMessages((prev) =>
|
|
177
|
+
prev.map((m) =>
|
|
178
|
+
m.id === assistantId
|
|
179
|
+
? { ...m, content: "Something went wrong. Please try again." }
|
|
180
|
+
: m,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const reader = resp.body!.getReader();
|
|
187
|
+
const decoder = new TextDecoder();
|
|
188
|
+
let buffer = "";
|
|
189
|
+
let fullText = "";
|
|
190
|
+
let actions: ChatAction[] | undefined;
|
|
191
|
+
|
|
192
|
+
while (true) {
|
|
193
|
+
const { done, value } = await reader.read();
|
|
194
|
+
if (done) break;
|
|
195
|
+
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
buffer = parseSSE(buffer, (event, data) => {
|
|
198
|
+
if (event === "text") {
|
|
199
|
+
fullText += data;
|
|
200
|
+
setMessages((prev) =>
|
|
201
|
+
prev.map((m) =>
|
|
202
|
+
m.id === assistantId ? { ...m, content: fullText } : m,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
} else if (event === "actions") {
|
|
206
|
+
try {
|
|
207
|
+
actions = JSON.parse(data);
|
|
208
|
+
} catch {
|
|
209
|
+
// ignore
|
|
210
|
+
}
|
|
211
|
+
} else if (event === "done") {
|
|
212
|
+
// Final update with actions
|
|
213
|
+
if (actions) {
|
|
214
|
+
setMessages((prev) =>
|
|
215
|
+
prev.map((m) =>
|
|
216
|
+
m.id === assistantId ? { ...m, actions } : m,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// If we never got any text, remove the empty assistant message
|
|
225
|
+
if (!fullText) {
|
|
226
|
+
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
|
|
227
|
+
}
|
|
228
|
+
} catch (err: unknown) {
|
|
229
|
+
const error = err as { name?: string };
|
|
230
|
+
if (error.name !== "AbortError") {
|
|
231
|
+
setMessages((prev) =>
|
|
232
|
+
prev.map((m) =>
|
|
233
|
+
m.id === assistantId
|
|
234
|
+
? { ...m, content: "Connection error. Please try again." }
|
|
235
|
+
: m,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
setIsLoading(false);
|
|
241
|
+
abortRef.current = null;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
[messages, config],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const stop = useCallback(() => {
|
|
248
|
+
abortRef.current?.abort();
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
const chatValue: ChatContextValue = {
|
|
252
|
+
messages,
|
|
253
|
+
isLoading,
|
|
254
|
+
initOperation,
|
|
255
|
+
sendMessage,
|
|
256
|
+
setInitOperation,
|
|
257
|
+
stop,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<ConfigContext.Provider value={config}>
|
|
262
|
+
<ChatContext.Provider value={chatValue}>
|
|
263
|
+
{props.children}
|
|
264
|
+
</ChatContext.Provider>
|
|
265
|
+
</ConfigContext.Provider>
|
|
266
|
+
);
|
|
267
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"jsx": "react-jsx",
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|