@weirdfingers/boards 0.1.4

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.
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Hook for managing AI generations with real-time progress via SSE.
3
+ */
4
+
5
+ import { useCallback, useState, useEffect, useRef } from "react";
6
+ import { fetchEventSource } from "@microsoft/fetch-event-source";
7
+ import { useMutation } from "urql";
8
+ import {
9
+ CREATE_GENERATION,
10
+ CANCEL_GENERATION,
11
+ RETRY_GENERATION,
12
+ CreateGenerationInput,
13
+ ArtifactType,
14
+ } from "../graphql/operations";
15
+ import { useAuth } from "../auth/context";
16
+ import { useApiConfig } from "../config/ApiConfigContext";
17
+
18
+ export interface GenerationRequest {
19
+ model: string;
20
+ artifactType: ArtifactType; // Allow string for flexibility with new types
21
+ inputs: GenerationInputs;
22
+ boardId: string;
23
+ options?: GenerationOptions;
24
+ }
25
+
26
+ export interface GenerationInputs {
27
+ prompt: string;
28
+ negativePrompt?: string;
29
+ image?: string | File;
30
+ mask?: string | File;
31
+ loras?: LoRAInput[];
32
+ seed?: number;
33
+ steps?: number;
34
+ guidance?: number;
35
+ aspectRatio?: string;
36
+ style?: string;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface GenerationOptions {
41
+ priority?: "low" | "normal" | "high";
42
+ timeout?: number;
43
+ webhookUrl?: string;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ export interface LoRAInput {
48
+ id: string;
49
+ weight: number;
50
+ }
51
+
52
+ export interface GenerationProgress {
53
+ jobId: string;
54
+ status: "queued" | "processing" | "completed" | "failed" | "cancelled";
55
+ progress: number; // 0-100
56
+ phase: string;
57
+ message?: string | null;
58
+ estimatedTimeRemaining?: number;
59
+ currentStep?: string;
60
+ logs?: string[];
61
+ }
62
+
63
+ export interface GenerationResult {
64
+ id: string;
65
+ jobId: string;
66
+ boardId: string;
67
+ request: GenerationRequest;
68
+ artifacts: Artifact[];
69
+ credits: {
70
+ cost: number;
71
+ balanceBefore: number;
72
+ balance: number;
73
+ };
74
+ performance: {
75
+ queueTime: number;
76
+ processingTime: number;
77
+ totalTime: number;
78
+ };
79
+ createdAt: Date;
80
+ }
81
+
82
+ export interface Artifact {
83
+ id: string;
84
+ type: string;
85
+ url: string;
86
+ thumbnailUrl?: string;
87
+ metadata: Record<string, unknown>;
88
+ }
89
+
90
+ export interface GenerationHook {
91
+ // Current generation state
92
+ progress: GenerationProgress | null;
93
+ result: GenerationResult | null;
94
+ error: Error | null;
95
+ isGenerating: boolean;
96
+
97
+ // Operations
98
+ submit: (request: GenerationRequest) => Promise<string>;
99
+ cancel: (jobId: string) => Promise<void>;
100
+ retry: (jobId: string) => Promise<void>;
101
+
102
+ // History
103
+ history: GenerationResult[];
104
+ clearHistory: () => void;
105
+ }
106
+
107
+ export function useGeneration(): GenerationHook {
108
+ const [progress, setProgress] = useState<GenerationProgress | null>(null);
109
+ const [result, setResult] = useState<GenerationResult | null>(null);
110
+ const [error, setError] = useState<Error | null>(null);
111
+ const [isGenerating, setIsGenerating] = useState(false);
112
+ const [history, setHistory] = useState<GenerationResult[]>([]);
113
+
114
+ // Get API configuration and auth
115
+ const { apiUrl } = useApiConfig();
116
+ const auth = useAuth();
117
+
118
+ // Keep track of active SSE connections (using AbortControllers)
119
+ const abortControllers = useRef<Map<string, AbortController>>(new Map());
120
+
121
+ // Mutations
122
+ const [, createGenerationMutation] = useMutation(CREATE_GENERATION);
123
+ const [, cancelGenerationMutation] = useMutation(CANCEL_GENERATION);
124
+ const [, retryGenerationMutation] = useMutation(RETRY_GENERATION);
125
+
126
+ // Clean up SSE connections on unmount
127
+ useEffect(() => {
128
+ return () => {
129
+ abortControllers.current.forEach((controller) => {
130
+ controller.abort();
131
+ });
132
+ abortControllers.current.clear();
133
+ };
134
+ }, []);
135
+
136
+ const connectToSSE = useCallback(
137
+ async (jobId: string) => {
138
+ // Close existing connection if any
139
+ const existingController = abortControllers.current.get(jobId);
140
+ if (existingController) {
141
+ existingController.abort();
142
+ }
143
+
144
+ // Create new abort controller
145
+ const abortController = new AbortController();
146
+ abortControllers.current.set(jobId, abortController);
147
+
148
+ // Get auth token
149
+ const token = await auth.getToken();
150
+
151
+ // Build headers
152
+ const headers: Record<string, string> = {
153
+ Accept: "text/event-stream",
154
+ };
155
+
156
+ if (token) {
157
+ headers.Authorization = `Bearer ${token}`;
158
+ }
159
+
160
+ // Connect to SSE endpoint directly
161
+ const sseUrl = `${apiUrl}/api/sse/generations/${jobId}/progress`;
162
+ console.log("SSE: Connecting to", sseUrl, "with headers:", headers);
163
+
164
+ try {
165
+ await fetchEventSource(sseUrl, {
166
+ headers,
167
+ signal: abortController.signal,
168
+
169
+ async onopen(response) {
170
+ console.log(
171
+ "SSE: Connection opened",
172
+ response.status,
173
+ response.statusText
174
+ );
175
+ if (response.ok) {
176
+ console.log("SSE: Connection successful");
177
+ } else {
178
+ console.error("SSE: Connection failed", response.status);
179
+ throw new Error(`SSE connection failed: ${response.statusText}`);
180
+ }
181
+ },
182
+
183
+ onmessage(event) {
184
+ console.log("SSE: Raw event received:", event);
185
+
186
+ // Skip empty messages (like keep-alive comments)
187
+ if (!event.data || event.data.trim() === "") {
188
+ console.log("SSE: Skipping empty message");
189
+ return;
190
+ }
191
+
192
+ try {
193
+ const progressData: GenerationProgress = JSON.parse(event.data);
194
+ console.log("SSE: progress data received:", progressData);
195
+ setProgress(progressData);
196
+
197
+ // If generation is complete, handle the result
198
+ if (
199
+ progressData.status === "completed" ||
200
+ progressData.status === "failed" ||
201
+ progressData.status === "cancelled"
202
+ ) {
203
+ setIsGenerating(false);
204
+
205
+ if (progressData.status === "completed") {
206
+ // TODO: Fetch the complete result from GraphQL
207
+ // For now, create a mock result
208
+ const mockResult: GenerationResult = {
209
+ id: progressData.jobId,
210
+ jobId: progressData.jobId,
211
+ boardId: "", // Would be filled from the original request
212
+ request: {} as GenerationRequest,
213
+ artifacts: [],
214
+ credits: { cost: 0, balanceBefore: 0, balance: 0 },
215
+ performance: {
216
+ queueTime: 0,
217
+ processingTime: 0,
218
+ totalTime: 0,
219
+ },
220
+ createdAt: new Date(),
221
+ };
222
+
223
+ setResult(mockResult);
224
+ setHistory((prev) => [...prev, mockResult]);
225
+ } else if (progressData.status === "failed") {
226
+ setError(new Error("Generation failed"));
227
+ }
228
+
229
+ // Close connection
230
+ abortController.abort();
231
+ abortControllers.current.delete(jobId);
232
+ }
233
+ } catch (err) {
234
+ console.error("Failed to parse SSE message:", err);
235
+ setError(new Error("Failed to parse progress update"));
236
+ setIsGenerating(false);
237
+ abortController.abort();
238
+ abortControllers.current.delete(jobId);
239
+ }
240
+ },
241
+
242
+ onerror(err) {
243
+ console.error("SSE connection error:", err);
244
+ console.error("SSE error details:", {
245
+ message: err instanceof Error ? err.message : String(err),
246
+ jobId,
247
+ url: sseUrl,
248
+ });
249
+ setError(new Error("Lost connection to generation progress"));
250
+ setIsGenerating(false);
251
+ abortController.abort();
252
+ abortControllers.current.delete(jobId);
253
+ // Re-throw to stop retry
254
+ throw err;
255
+ },
256
+
257
+ openWhenHidden: true, // Keep connection open when tab is hidden
258
+ });
259
+ } catch (err) {
260
+ // Connection was aborted or failed
261
+ if (abortController.signal.aborted) {
262
+ console.log("SSE connection aborted for job:", jobId);
263
+ } else {
264
+ console.error("SSE connection failed:", err);
265
+ }
266
+ }
267
+ },
268
+ [apiUrl, auth]
269
+ );
270
+
271
+ const submit = useCallback(
272
+ async (request: GenerationRequest): Promise<string> => {
273
+ setError(null);
274
+ setProgress(null);
275
+ setResult(null);
276
+ setIsGenerating(true);
277
+
278
+ // Convert the request to the GraphQL input format
279
+ const input: CreateGenerationInput = {
280
+ boardId: request.boardId,
281
+ generatorName: request.model,
282
+ artifactType: request.artifactType,
283
+ inputParams: {
284
+ ...request.inputs,
285
+ ...request.options,
286
+ },
287
+ };
288
+
289
+ // Retry logic for generation submission
290
+ let lastError: Error | null = null;
291
+ const maxRetries = 2; // Fewer retries for generation as it's expensive
292
+
293
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
294
+ try {
295
+ // Submit generation via GraphQL
296
+ const result = await createGenerationMutation({ input });
297
+
298
+ if (result.error) {
299
+ throw new Error(result.error.message);
300
+ }
301
+
302
+ if (!result.data?.createGeneration) {
303
+ throw new Error("Failed to create generation");
304
+ }
305
+
306
+ const jobId = result.data.createGeneration.id;
307
+
308
+ // Connect to SSE for progress updates
309
+ connectToSSE(jobId);
310
+
311
+ // Re-enable the submit button now that submission is complete
312
+ // The SSE connection will continue tracking progress in the background
313
+ setIsGenerating(false);
314
+
315
+ return jobId;
316
+ } catch (err) {
317
+ lastError =
318
+ err instanceof Error
319
+ ? err
320
+ : new Error("Failed to submit generation");
321
+
322
+ // Don't retry on certain types of errors
323
+ if (
324
+ lastError.message.includes("insufficient credits") ||
325
+ lastError.message.includes("validation") ||
326
+ lastError.message.includes("unauthorized") ||
327
+ lastError.message.includes("forbidden")
328
+ ) {
329
+ setError(lastError);
330
+ setIsGenerating(false);
331
+ throw lastError;
332
+ }
333
+
334
+ // If this was the last attempt, throw the error
335
+ if (attempt === maxRetries) {
336
+ setError(lastError);
337
+ setIsGenerating(false);
338
+ throw lastError;
339
+ }
340
+
341
+ // Wait before retrying (shorter delay for generations)
342
+ await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
343
+ }
344
+ }
345
+
346
+ const finalError =
347
+ lastError || new Error("Failed to submit generation after retries");
348
+ setError(finalError);
349
+ setIsGenerating(false);
350
+ throw finalError;
351
+ },
352
+ [createGenerationMutation, connectToSSE]
353
+ );
354
+
355
+ const cancel = useCallback(
356
+ async (jobId: string): Promise<void> => {
357
+ try {
358
+ // Cancel via GraphQL
359
+ const result = await cancelGenerationMutation({ id: jobId });
360
+
361
+ if (result.error) {
362
+ throw new Error(result.error.message);
363
+ }
364
+
365
+ // Close SSE connection
366
+ const controller = abortControllers.current.get(jobId);
367
+ if (controller) {
368
+ controller.abort();
369
+ abortControllers.current.delete(jobId);
370
+ }
371
+
372
+ setIsGenerating(false);
373
+ setProgress((prev) => (prev ? { ...prev, status: "cancelled" } : null));
374
+ } catch (err) {
375
+ setError(
376
+ err instanceof Error ? err : new Error("Failed to cancel generation")
377
+ );
378
+ }
379
+ },
380
+ [cancelGenerationMutation]
381
+ );
382
+
383
+ const retry = useCallback(
384
+ async (jobId: string): Promise<void> => {
385
+ try {
386
+ setError(null);
387
+ setIsGenerating(true);
388
+
389
+ // Retry via GraphQL
390
+ const result = await retryGenerationMutation({ id: jobId });
391
+
392
+ if (result.error) {
393
+ throw new Error(result.error.message);
394
+ }
395
+
396
+ if (!result.data?.retryGeneration) {
397
+ throw new Error("Failed to retry generation");
398
+ }
399
+
400
+ const newJobId = result.data.retryGeneration.id;
401
+
402
+ // Connect to SSE for the retried job
403
+ connectToSSE(newJobId);
404
+ } catch (err) {
405
+ setError(
406
+ err instanceof Error ? err : new Error("Failed to retry generation")
407
+ );
408
+ setIsGenerating(false);
409
+ }
410
+ },
411
+ [retryGenerationMutation, connectToSSE]
412
+ );
413
+
414
+ const clearHistory = useCallback(() => {
415
+ setHistory([]);
416
+ }, []);
417
+
418
+ return {
419
+ progress,
420
+ result,
421
+ error,
422
+ isGenerating,
423
+ submit,
424
+ cancel,
425
+ retry,
426
+ history,
427
+ clearHistory,
428
+ };
429
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Hook for fetching available generators.
3
+ */
4
+
5
+ import { useMemo } from "react";
6
+ import { useQuery } from "urql";
7
+ import { ArtifactType, GET_GENERATORS } from "../graphql/operations";
8
+
9
+ interface Generator {
10
+ name: string;
11
+ description: string;
12
+ artifactType: ArtifactType;
13
+ inputSchema: Record<string, unknown>;
14
+ }
15
+
16
+ interface UseGeneratorsOptions {
17
+ artifactType?: string;
18
+ }
19
+
20
+ interface GeneratorsHook {
21
+ generators: Generator[];
22
+ loading: boolean;
23
+ error: Error | null;
24
+ }
25
+
26
+ export function useGenerators(
27
+ options: UseGeneratorsOptions = {}
28
+ ): GeneratorsHook {
29
+ const { artifactType } = options;
30
+
31
+ // Query for generators
32
+ const [{ data, fetching, error }] = useQuery({
33
+ query: GET_GENERATORS,
34
+ variables: artifactType ? { artifactType } : {},
35
+ });
36
+
37
+ const generators = useMemo(() => data?.generators || [], [data?.generators]);
38
+
39
+ return {
40
+ generators,
41
+ loading: fetching,
42
+ error: error ? new Error(error.message) : null,
43
+ };
44
+ }
@@ -0,0 +1,7 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ describe("smoke", () => {
4
+ it("runs", () => {
5
+ expect(1 + 1).toBe(2);
6
+ });
7
+ });
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export const VERSION = "0.1.0";
2
+
3
+ // Core auth exports
4
+ export * from "./auth/types";
5
+ export * from "./auth/hooks/useAuth";
6
+ export { AuthProvider } from "./auth/context";
7
+ export { BaseAuthProvider } from "./auth/providers/base";
8
+ export { NoAuthProvider } from "./auth/providers/none"; // Only no-auth included for dev
9
+
10
+ // API configuration
11
+ export { useApiConfig } from "./config/ApiConfigContext";
12
+ export type { ApiConfig } from "./config/ApiConfigContext";
13
+
14
+ // GraphQL exports
15
+ export { createGraphQLClient } from "./graphql/client";
16
+ export * from "./graphql/operations";
17
+
18
+ // Core hooks
19
+ export { useBoards } from "./hooks/useBoards";
20
+ export { useBoard } from "./hooks/useBoard";
21
+ export { useGeneration } from "./hooks/useGeneration";
22
+ export { useGenerators } from "./hooks/useGenerators";
23
+
24
+ // Provider components
25
+ export { BoardsProvider } from "./providers/BoardsProvider";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Main provider component that sets up GraphQL client and auth context.
3
+ */
4
+
5
+ import { ReactNode } from "react";
6
+ import { Provider as UrqlProvider } from "urql";
7
+ import { createGraphQLClient } from "../graphql/client";
8
+ import { AuthProvider } from "../auth/context";
9
+ import { BaseAuthProvider } from "../auth/providers/base";
10
+ import { ApiConfigProvider, ApiConfig } from "../config/ApiConfigContext";
11
+
12
+ interface BoardsProviderProps {
13
+ children: ReactNode;
14
+ /**
15
+ * Base URL for the backend API (e.g., "http://localhost:8088")
16
+ * Used for REST endpoints like SSE streams
17
+ */
18
+ apiUrl: string;
19
+ /**
20
+ * GraphQL endpoint URL (e.g., "http://localhost:8088/graphql")
21
+ * If not provided, defaults to `${apiUrl}/graphql`
22
+ */
23
+ graphqlUrl?: string;
24
+ /**
25
+ * WebSocket URL for GraphQL subscriptions
26
+ */
27
+ subscriptionUrl?: string;
28
+ authProvider: BaseAuthProvider;
29
+ tenantId?: string;
30
+ }
31
+
32
+ export function BoardsProvider({
33
+ children,
34
+ apiUrl,
35
+ graphqlUrl,
36
+ subscriptionUrl,
37
+ authProvider,
38
+ tenantId,
39
+ }: BoardsProviderProps) {
40
+ // Default graphqlUrl if not provided
41
+ const resolvedGraphqlUrl = graphqlUrl || `${apiUrl}/graphql`;
42
+
43
+ // Create API config for hooks
44
+ const apiConfig: ApiConfig = {
45
+ apiUrl,
46
+ graphqlUrl: resolvedGraphqlUrl,
47
+ subscriptionUrl,
48
+ };
49
+
50
+ // Create the GraphQL client with auth integration
51
+ const client = createGraphQLClient({
52
+ url: resolvedGraphqlUrl,
53
+ subscriptionUrl,
54
+ auth: {
55
+ getToken: () =>
56
+ authProvider.getAuthState().then((state) => state.getToken()),
57
+ },
58
+ tenantId,
59
+ });
60
+
61
+ return (
62
+ <AuthProvider provider={authProvider}>
63
+ <ApiConfigProvider config={apiConfig}>
64
+ <UrqlProvider value={client}>{children}</UrqlProvider>
65
+ </ApiConfigProvider>
66
+ </AuthProvider>
67
+ );
68
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Test setup for Vitest.
3
+ */
4
+
5
+ import { vi, afterEach } from 'vitest';
6
+
7
+ // Mock localStorage
8
+ const localStorageMock = {
9
+ getItem: vi.fn(),
10
+ setItem: vi.fn(),
11
+ removeItem: vi.fn(),
12
+ clear: vi.fn(),
13
+ };
14
+
15
+ Object.defineProperty(window, 'localStorage', {
16
+ value: localStorageMock,
17
+ });
18
+
19
+ // Mock fetch
20
+ global.fetch = vi.fn();
21
+
22
+ // Mock console methods to reduce noise in tests
23
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
24
+ vi.spyOn(console, 'error').mockImplementation(() => {});
25
+
26
+ // Reset all mocks after each test
27
+ afterEach(() => {
28
+ vi.clearAllMocks();
29
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": [
6
+ "ES2020",
7
+ "DOM",
8
+ "DOM.Iterable"
9
+ ],
10
+ "jsx": "react-jsx",
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "outDir": "./dist",
14
+ "rootDir": "./src",
15
+ "strict": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noImplicitReturns": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "esModuleInterop": true,
21
+ "skipLibCheck": true,
22
+ "forceConsistentCasingInFileNames": true,
23
+ "resolveJsonModule": true,
24
+ "moduleResolution": "node"
25
+ },
26
+ "include": [
27
+ "src"
28
+ ],
29
+ "exclude": [
30
+ "node_modules",
31
+ "dist",
32
+ "**/*.test.ts",
33
+ "**/__tests__/**/*",
34
+ "src/test-setup.ts",
35
+ "src/graphql/client.ts"
36
+ ]
37
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true,
7
+ splitting: false,
8
+ sourcemap: true,
9
+ clean: true,
10
+ external: ['react', 'react-dom'],
11
+ })
@@ -0,0 +1,10 @@
1
+ /// <reference types="vitest" />
2
+ import { defineConfig } from 'vitest/config';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: 'jsdom',
7
+ globals: true,
8
+ setupFiles: ['./src/test-setup.ts'],
9
+ },
10
+ });