@nimbleflux/fluxbase-sdk-react 2026.5.5-rc.2 → 2026.6.2

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/src/use-ai.ts ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * AI hooks for Fluxbase React SDK
3
+ * Provides hooks for chatbot chat, conversation history, and knowledge base search
4
+ */
5
+
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8
+ import { useFluxbaseClient } from "./context";
9
+ import type {
10
+ ListConversationsOptions,
11
+ AIChatEvent,
12
+ } from "@nimbleflux/fluxbase-sdk";
13
+
14
+ /**
15
+ * Hook to list all public/enabled chatbots
16
+ */
17
+ export function useChatbots(namespace?: string) {
18
+ const client = useFluxbaseClient();
19
+
20
+ return useQuery({
21
+ queryKey: ["fluxbase", "ai", "chatbots", namespace],
22
+ queryFn: async () => {
23
+ const { data, error } = await client.ai.listChatbots(namespace);
24
+ if (error) throw error;
25
+ return data || [];
26
+ },
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Hook to list the current user's conversations
32
+ */
33
+ export function useConversations(options?: ListConversationsOptions) {
34
+ const client = useFluxbaseClient();
35
+
36
+ return useQuery({
37
+ queryKey: ["fluxbase", "ai", "conversations", options],
38
+ queryFn: async () => {
39
+ const { data, error } = await client.ai.listConversations(options);
40
+ if (error) throw error;
41
+ return data;
42
+ },
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Hook to get a conversation with full message history
48
+ */
49
+ export function useConversation(conversationId: string | null) {
50
+ const client = useFluxbaseClient();
51
+
52
+ return useQuery({
53
+ queryKey: ["fluxbase", "ai", "conversation", conversationId],
54
+ queryFn: async () => {
55
+ if (!conversationId) return null;
56
+ const { data, error } = await client.ai.getConversation(conversationId);
57
+ if (error) throw error;
58
+ return data;
59
+ },
60
+ enabled: !!conversationId,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Hook to delete a conversation
66
+ */
67
+ export function useDeleteConversation() {
68
+ const client = useFluxbaseClient();
69
+ const queryClient = useQueryClient();
70
+
71
+ return useMutation({
72
+ mutationFn: async (conversationId: string) => {
73
+ const { error } = await client.ai.deleteConversation(conversationId);
74
+ if (error) throw error;
75
+ },
76
+ onSuccess: () => {
77
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "ai", "conversations"] });
78
+ },
79
+ });
80
+ }
81
+
82
+ interface AIChatState {
83
+ messages: ChatMessage[];
84
+ isStreaming: boolean;
85
+ isConnected: boolean;
86
+ error: Error | null;
87
+ }
88
+
89
+ interface ChatMessage {
90
+ role: "user" | "assistant";
91
+ content: string;
92
+ queryResults?: Array<{
93
+ query: string;
94
+ summary: string;
95
+ rowCount: number;
96
+ data?: Record<string, unknown>[];
97
+ }>;
98
+ progress?: string[];
99
+ }
100
+
101
+ export type { ChatMessage };
102
+
103
+ /**
104
+ * Hook for AI chatbot streaming chat
105
+ *
106
+ * @example
107
+ * ```tsx
108
+ * const { messages, sendMessage, isStreaming, error } = useAIChat({
109
+ * chatbot: 'my-chatbot',
110
+ * onQueryResult: (result) => console.log('SQL:', result.query),
111
+ * })
112
+ * ```
113
+ */
114
+ export function useAIChat(options: {
115
+ chatbot: string;
116
+ namespace?: string;
117
+ onError?: (error: Error) => void;
118
+ onQueryResult?: (result: {
119
+ query: string;
120
+ summary: string;
121
+ rowCount: number;
122
+ data?: Record<string, unknown>[];
123
+ }) => void;
124
+ }) {
125
+ const client = useFluxbaseClient();
126
+ const chatRef = useRef<ReturnType<typeof client.ai.createChat> | null>(null);
127
+ const conversationIdRef = useRef<string | null>(null);
128
+ const [state, setState] = useState<AIChatState>({
129
+ messages: [],
130
+ isStreaming: false,
131
+ isConnected: false,
132
+ error: null,
133
+ });
134
+ const assistantBufferRef = useRef<string>("");
135
+ const assistantProgressRef = useRef<string[]>([]);
136
+
137
+ // Connect on mount
138
+ useEffect(() => {
139
+ const chat = client.ai.createChat({
140
+ onEvent: (event: AIChatEvent) => {
141
+ handleEvent(event);
142
+ },
143
+ onContent: (delta: string) => {
144
+ assistantBufferRef.current += delta;
145
+ },
146
+ onProgress: (_step: string, message: string) => {
147
+ assistantProgressRef.current = [
148
+ ...assistantProgressRef.current,
149
+ message,
150
+ ];
151
+ },
152
+ onQueryResult: (query, summary, rowCount, data) => {
153
+ options.onQueryResult?.({ query, summary, rowCount, data });
154
+ },
155
+ onDone: () => {
156
+ setState((prev) => ({
157
+ ...prev,
158
+ messages: [
159
+ ...prev.messages,
160
+ {
161
+ role: "assistant" as const,
162
+ content: assistantBufferRef.current,
163
+ progress: assistantProgressRef.current.length > 0 ? [...assistantProgressRef.current] : undefined,
164
+ },
165
+ ],
166
+ isStreaming: false,
167
+ }));
168
+ assistantBufferRef.current = "";
169
+ assistantProgressRef.current = [];
170
+ },
171
+ onError: (error: string) => {
172
+ const err = new Error(error);
173
+ setState((prev) => ({ ...prev, isStreaming: false, error: err }));
174
+ options.onError?.(err);
175
+ },
176
+ });
177
+
178
+ chatRef.current = chat;
179
+
180
+ chat.connect().then(() => {
181
+ setState((prev) => ({ ...prev, isConnected: true }));
182
+ }).catch((err: Error) => {
183
+ setState((prev) => ({ ...prev, error: err }));
184
+ });
185
+
186
+ return () => {
187
+ chat.disconnect();
188
+ chatRef.current = null;
189
+ };
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, [options.chatbot, options.namespace]);
192
+
193
+ // Internal event handler (not used directly since we set callbacks)
194
+ const handleEvent = (_event: AIChatEvent) => {
195
+ // Events are handled via individual callbacks above
196
+ };
197
+
198
+ const sendMessage = useCallback(async (content: string) => {
199
+ const chat = chatRef.current;
200
+ if (!chat || !chat.isConnected()) {
201
+ setState((prev) => ({ ...prev, error: new Error("Not connected") }));
202
+ return;
203
+ }
204
+
205
+ // Add user message immediately
206
+ setState((prev) => ({
207
+ ...prev,
208
+ messages: [...prev.messages, { role: "user", content }],
209
+ isStreaming: true,
210
+ error: null,
211
+ }));
212
+
213
+ // Start chat if needed, then send
214
+ if (!conversationIdRef.current) {
215
+ conversationIdRef.current = await chat.startChat(
216
+ options.chatbot,
217
+ options.namespace,
218
+ );
219
+ }
220
+
221
+ await chat.sendMessage(conversationIdRef.current, content);
222
+ }, [options.chatbot, options.namespace]);
223
+
224
+ const cancel = useCallback(() => {
225
+ const chat = chatRef.current;
226
+ if (chat && conversationIdRef.current) {
227
+ chat.cancel(conversationIdRef.current);
228
+ }
229
+ setState((prev) => ({ ...prev, isStreaming: false }));
230
+ }, []);
231
+
232
+ const reset = useCallback(() => {
233
+ conversationIdRef.current = null;
234
+ assistantBufferRef.current = "";
235
+ assistantProgressRef.current = [];
236
+ setState({
237
+ messages: [],
238
+ isStreaming: false,
239
+ isConnected: state.isConnected,
240
+ error: null,
241
+ });
242
+ }, [state.isConnected]);
243
+
244
+ return {
245
+ messages: state.messages,
246
+ isStreaming: state.isStreaming,
247
+ isConnected: state.isConnected,
248
+ error: state.error,
249
+ sendMessage,
250
+ cancel,
251
+ reset,
252
+ };
253
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Tests for Branches hooks
3
+ */
4
+
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { renderHook, waitFor, act } from "@testing-library/react";
7
+ import {
8
+ useBranches,
9
+ useCreateBranch,
10
+ useDeleteBranch,
11
+ useResetBranch,
12
+ } from "./use-branches";
13
+ import { createMockClient, createWrapper } from "./test-utils";
14
+
15
+ describe("useBranches", () => {
16
+ it("should list branches", async () => {
17
+ const mockData = {
18
+ branches: [
19
+ { id: "b1", slug: "main", status: "ready", type: "main" },
20
+ { id: "b2", slug: "feature", status: "ready", type: "preview" },
21
+ ],
22
+ total: 2,
23
+ limit: 50,
24
+ offset: 0,
25
+ };
26
+ const client = createMockClient({
27
+ branching: {
28
+ list: vi.fn().mockResolvedValue({ data: mockData, error: null }),
29
+ } as any,
30
+ });
31
+
32
+ const { result } = renderHook(() => useBranches(), {
33
+ wrapper: createWrapper(client),
34
+ });
35
+
36
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
37
+ expect(result.current.data).toEqual(mockData);
38
+ });
39
+
40
+ it("should pass options to list", async () => {
41
+ const list = vi.fn().mockResolvedValue({
42
+ data: { branches: [], total: 0, limit: 50, offset: 0 },
43
+ error: null,
44
+ });
45
+ const client = createMockClient({
46
+ branching: { list } as any,
47
+ });
48
+
49
+ renderHook(() => useBranches({ status: "ready", limit: 10 }), {
50
+ wrapper: createWrapper(client),
51
+ });
52
+
53
+ await waitFor(() => {
54
+ expect(list).toHaveBeenCalledWith({ status: "ready", limit: 10 });
55
+ });
56
+ });
57
+
58
+ it("should handle errors", async () => {
59
+ const client = createMockClient({
60
+ branching: {
61
+ list: vi.fn().mockResolvedValue({ data: null, error: new Error("Failed") }),
62
+ } as any,
63
+ });
64
+
65
+ const { result } = renderHook(() => useBranches(), {
66
+ wrapper: createWrapper(client),
67
+ });
68
+
69
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
70
+ expect(result.current.error).toBeInstanceOf(Error);
71
+ });
72
+ });
73
+
74
+ describe("useCreateBranch", () => {
75
+ it("should create a branch and invalidate queries", async () => {
76
+ const create = vi.fn().mockResolvedValue({
77
+ data: { id: "b2", slug: "feature-x", status: "creating" },
78
+ error: null,
79
+ });
80
+ const client = createMockClient({
81
+ branching: { create } as any,
82
+ });
83
+
84
+ const { result } = renderHook(() => useCreateBranch(), {
85
+ wrapper: createWrapper(client),
86
+ });
87
+
88
+ await act(async () => {
89
+ await result.current.mutateAsync({ name: "feature/x" });
90
+ });
91
+
92
+ expect(create).toHaveBeenCalledWith("feature/x", {});
93
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
94
+ });
95
+
96
+ it("should pass options to create", async () => {
97
+ const create = vi.fn().mockResolvedValue({
98
+ data: { id: "b2", slug: "feature-x", status: "creating" },
99
+ error: null,
100
+ });
101
+ const client = createMockClient({
102
+ branching: { create } as any,
103
+ });
104
+
105
+ const { result } = renderHook(() => useCreateBranch(), {
106
+ wrapper: createWrapper(client),
107
+ });
108
+
109
+ await act(async () => {
110
+ await result.current.mutateAsync({
111
+ name: "feature/x",
112
+ dataCloneMode: "schema_only",
113
+ expiresIn: "7d",
114
+ });
115
+ });
116
+
117
+ expect(create).toHaveBeenCalledWith("feature/x", {
118
+ dataCloneMode: "schema_only",
119
+ expiresIn: "7d",
120
+ });
121
+ });
122
+
123
+ it("should handle errors", async () => {
124
+ const client = createMockClient({
125
+ branching: {
126
+ create: vi.fn().mockResolvedValue({ data: null, error: new Error("Failed") }),
127
+ } as any,
128
+ });
129
+
130
+ const { result } = renderHook(() => useCreateBranch(), {
131
+ wrapper: createWrapper(client),
132
+ });
133
+
134
+ await act(async () => {
135
+ try {
136
+ await result.current.mutateAsync({ name: "bad" });
137
+ } catch {
138
+ // expected
139
+ }
140
+ });
141
+
142
+ await waitFor(() => expect(result.current.isError).toBe(true));
143
+ });
144
+ });
145
+
146
+ describe("useDeleteBranch", () => {
147
+ it("should delete a branch and invalidate queries", async () => {
148
+ const del = vi.fn().mockResolvedValue({ error: null });
149
+ const client = createMockClient({
150
+ branching: { delete: del } as any,
151
+ });
152
+
153
+ const { result } = renderHook(() => useDeleteBranch(), {
154
+ wrapper: createWrapper(client),
155
+ });
156
+
157
+ await act(async () => {
158
+ await result.current.mutateAsync("feature/x");
159
+ });
160
+
161
+ expect(del).toHaveBeenCalledWith("feature/x");
162
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
163
+ });
164
+
165
+ it("should handle errors", async () => {
166
+ const client = createMockClient({
167
+ branching: {
168
+ delete: vi.fn().mockResolvedValue({ error: new Error("Not found") }),
169
+ } as any,
170
+ });
171
+
172
+ const { result } = renderHook(() => useDeleteBranch(), {
173
+ wrapper: createWrapper(client),
174
+ });
175
+
176
+ await act(async () => {
177
+ try {
178
+ await result.current.mutateAsync("feature/x");
179
+ } catch {
180
+ // expected
181
+ }
182
+ });
183
+
184
+ await waitFor(() => expect(result.current.isError).toBe(true));
185
+ });
186
+ });
187
+
188
+ describe("useResetBranch", () => {
189
+ it("should reset a branch and invalidate queries", async () => {
190
+ const reset = vi.fn().mockResolvedValue({
191
+ data: { id: "b2", slug: "feature-x", status: "creating" },
192
+ error: null,
193
+ });
194
+ const client = createMockClient({
195
+ branching: { reset } as any,
196
+ });
197
+
198
+ const { result } = renderHook(() => useResetBranch(), {
199
+ wrapper: createWrapper(client),
200
+ });
201
+
202
+ await act(async () => {
203
+ await result.current.mutateAsync("feature/x");
204
+ });
205
+
206
+ expect(reset).toHaveBeenCalledWith("feature/x");
207
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
208
+ });
209
+
210
+ it("should handle errors", async () => {
211
+ const client = createMockClient({
212
+ branching: {
213
+ reset: vi.fn().mockResolvedValue({ data: null, error: new Error("Failed") }),
214
+ } as any,
215
+ });
216
+
217
+ const { result } = renderHook(() => useResetBranch(), {
218
+ wrapper: createWrapper(client),
219
+ });
220
+
221
+ await act(async () => {
222
+ try {
223
+ await result.current.mutateAsync("feature/x");
224
+ } catch {
225
+ // expected
226
+ }
227
+ });
228
+
229
+ await waitFor(() => expect(result.current.isError).toBe(true));
230
+ });
231
+ });
@@ -1,6 +1,17 @@
1
- import { useQuery } from "@tanstack/react-query"
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
2
2
  import { useFluxbaseClient } from "./context"
3
3
 
4
+ export interface UseCreateBranchParams {
5
+ name: string;
6
+ parentBranchId?: string;
7
+ dataCloneMode?: "schema_only" | "full_clone" | "seed_data";
8
+ type?: "main" | "preview" | "persistent";
9
+ githubPRNumber?: number;
10
+ githubPRUrl?: string;
11
+ githubRepo?: string;
12
+ expiresIn?: string;
13
+ }
14
+
4
15
  export interface UseBranchesOptions {
5
16
  status?: "creating" | "ready" | "migrating" | "error" | "deleting" | "deleted";
6
17
  type?: "main" | "preview" | "persistent";
@@ -19,3 +30,48 @@ export function useBranches(options?: UseBranchesOptions) {
19
30
  },
20
31
  })
21
32
  }
33
+
34
+ export function useCreateBranch() {
35
+ const client = useFluxbaseClient()
36
+ const queryClient = useQueryClient()
37
+ return useMutation({
38
+ mutationFn: async (params: UseCreateBranchParams): Promise<unknown> => {
39
+ const { name, ...options } = params
40
+ const { data, error } = await client.branching.create(name, options)
41
+ if (error) throw error
42
+ return data
43
+ },
44
+ onSuccess: () => {
45
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "branches"] })
46
+ },
47
+ })
48
+ }
49
+
50
+ export function useDeleteBranch() {
51
+ const client = useFluxbaseClient()
52
+ const queryClient = useQueryClient()
53
+ return useMutation({
54
+ mutationFn: async (slug: string): Promise<void> => {
55
+ const { error } = await client.branching.delete(slug)
56
+ if (error) throw error
57
+ },
58
+ onSuccess: () => {
59
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "branches"] })
60
+ },
61
+ })
62
+ }
63
+
64
+ export function useResetBranch() {
65
+ const client = useFluxbaseClient()
66
+ const queryClient = useQueryClient()
67
+ return useMutation({
68
+ mutationFn: async (slug: string): Promise<unknown> => {
69
+ const { data, error } = await client.branching.reset(slug)
70
+ if (error) throw error
71
+ return data
72
+ },
73
+ onSuccess: () => {
74
+ queryClient.invalidateQueries({ queryKey: ["fluxbase", "branches"] })
75
+ },
76
+ })
77
+ }