@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 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
@@ -0,0 +1,12 @@
1
+ export { useChat, useInitOperation } from "./hooks";
2
+ export type {
3
+ ChatAction,
4
+ ChatMessageItem,
5
+ DongHanhConfig,
6
+ InitOperation,
7
+ } from "./provider";
8
+ export {
9
+ DongHanhProvider,
10
+ useChatContext,
11
+ useDongHanhConfig,
12
+ } from "./provider";
@@ -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
+ }