From bb83d12c8b34a03eada53f0a3b2e6669cc6eaf0d Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 15 Aug 2025 23:18:51 -0700 Subject: [PATCH] AI module - init --- apps/client/src/ee/ai/hooks/use-ai.ts | 61 ++++++++++++++ apps/client/src/ee/ai/queries/ai-query.ts | 45 ++++++++++ apps/client/src/ee/ai/services/ai-service.ts | 89 ++++++++++++++++++++ apps/client/src/ee/ai/types/ai.types.ts | 40 +++++++++ apps/server/src/ee | 2 +- 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/ee/ai/hooks/use-ai.ts create mode 100644 apps/client/src/ee/ai/queries/ai-query.ts create mode 100644 apps/client/src/ee/ai/services/ai-service.ts create mode 100644 apps/client/src/ee/ai/types/ai.types.ts diff --git a/apps/client/src/ee/ai/hooks/use-ai.ts b/apps/client/src/ee/ai/hooks/use-ai.ts new file mode 100644 index 00000000..40c1ca12 --- /dev/null +++ b/apps/client/src/ee/ai/hooks/use-ai.ts @@ -0,0 +1,61 @@ +import { useState, useCallback, useRef } from "react"; +import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts"; +import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts"; + +export function useAiStream() { + const [content, setContent] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const abortControllerRef = useRef(null); + const mutation = useAiGenerateStreamMutation(); + + const startStream = useCallback( + async (data: AiGenerateDto) => { + setContent(""); + setIsStreaming(true); + + try { + const controller = await mutation.mutateAsync({ + ...data, + onChunk: (chunk) => { + setContent((prev) => prev + chunk.content); + }, + onError: (error) => { + console.error("AI stream error:", error); + setIsStreaming(false); + }, + onComplete: () => { + setIsStreaming(false); + }, + }); + + abortControllerRef.current = controller; + } catch (error) { + console.error("Failed to start stream:", error); + setIsStreaming(false); + } + }, + [mutation] + ); + + const stopStream = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + setIsStreaming(false); + } + }, []); + + const resetContent = useCallback(() => { + setContent(""); + }, []); + + return { + content, + isStreaming, + startStream, + stopStream, + resetContent, + isLoading: mutation.isPending, + error: mutation.error, + }; +} \ No newline at end of file diff --git a/apps/client/src/ee/ai/queries/ai-query.ts b/apps/client/src/ee/ai/queries/ai-query.ts new file mode 100644 index 00000000..7a2c7d0d --- /dev/null +++ b/apps/client/src/ee/ai/queries/ai-query.ts @@ -0,0 +1,45 @@ +import { + useMutation, + UseMutationResult, + useQuery, + UseQueryResult, +} from "@tanstack/react-query"; +import { + generateAiContent, + generateAiContentStream, + getAiConfig, +} from "@/ee/ai/services/ai-service.ts"; +import { + AiConfigResponse, + AiContentResponse, + AiGenerateDto, + AiStreamChunk, + AiStreamError, +} from "@/ee/ai/types/ai.types.ts"; + +export function useAiGenerateMutation(): UseMutationResult< + AiContentResponse, + Error, + AiGenerateDto +> { + return useMutation({ + mutationFn: (data: AiGenerateDto) => generateAiContent(data), + }); +} + +interface StreamCallbacks { + onChunk: (chunk: AiStreamChunk) => void; + onError?: (error: AiStreamError) => void; + onComplete?: () => void; +} + +export function useAiGenerateStreamMutation(): UseMutationResult< + AbortController, + Error, + AiGenerateDto & StreamCallbacks +> { + return useMutation({ + mutationFn: ({ onChunk, onError, onComplete, ...data }) => + generateAiContentStream(data, onChunk, onError, onComplete), + }); +} diff --git a/apps/client/src/ee/ai/services/ai-service.ts b/apps/client/src/ee/ai/services/ai-service.ts new file mode 100644 index 00000000..f3634d59 --- /dev/null +++ b/apps/client/src/ee/ai/services/ai-service.ts @@ -0,0 +1,89 @@ +import api from "@/lib/api-client.ts"; +import { + AiGenerateDto, + AiContentResponse, + AiStreamChunk, + AiStreamError, +} from "@/ee/ai/types/ai.types.ts"; + +export async function generateAiContent( + data: AiGenerateDto, +): Promise { + const req = await api.post("/ai/generate", data); + return req.data; +} + +export async function generateAiContentStream( + data: AiGenerateDto, + onChunk: (chunk: AiStreamChunk) => void, + onError?: (error: AiStreamError) => void, + onComplete?: () => void, +): Promise { + const abortController = new AbortController(); + try { + const response = await fetch("/api/ai/generate/stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + signal: abortController.signal, + credentials: "include", // This ensures cookies are sent, matching axios withCredentials + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) { + throw new Error("Response body is not readable"); + } + + const processStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") { + onComplete?.(); + return; + } + try { + const parsed = JSON.parse(data); + if (parsed.error) { + onError?.(parsed); + } else { + onChunk(parsed); + } + } catch (e) { + // Ignore parse errors for incomplete chunks + } + } + } + } + } catch (error) { + if (error.name !== "AbortError") { + onError?.({ error: error.message }); + } + } finally { + reader.releaseLock(); + } + }; + + processStream(); + } catch (error) { + onError?.({ error: error.message }); + } + + return abortController; +} diff --git a/apps/client/src/ee/ai/types/ai.types.ts b/apps/client/src/ee/ai/types/ai.types.ts new file mode 100644 index 00000000..a5fbc253 --- /dev/null +++ b/apps/client/src/ee/ai/types/ai.types.ts @@ -0,0 +1,40 @@ +export enum AiAction { + IMPROVE_WRITING = "improve_writing", + FIX_SPELLING_GRAMMAR = "fix_spelling_grammar", + MAKE_SHORTER = "make_shorter", + MAKE_LONGER = "make_longer", + SIMPLIFY = "simplify", + CHANGE_TONE = "change_tone", + SUMMARIZE = "summarize", + CONTINUE_WRITING = "continue_writing", + TRANSLATE = "translate", + CUSTOM = "custom", +} + +export interface AiGenerateDto { + action?: AiAction; + content: string; + prompt?: string; +} + +export interface AiContentResponse { + content: string; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +export interface AiConfigResponse { + configured: boolean; + availableActions: AiAction[]; +} + +export interface AiStreamChunk { + content: string; +} + +export interface AiStreamError { + error: string; +} diff --git a/apps/server/src/ee b/apps/server/src/ee index fbc01d80..4100345c 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit fbc01d808f3edd7d16a64c21251a0bcb720f1ba4 +Subproject commit 4100345c189026bfc3ba7d04edbef5ae7d28d91c