| |
| |
| |
| |
|
|
| import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from "react"; |
| import type { ReactNode } from "react"; |
| import type { AnalysisState, AIStreamChunk } from "@shared/types"; |
|
|
| const initialAnalysisState: AnalysisState = { |
| isOpen: false, |
| isLoading: false, |
| reasoning: "", |
| content: "", |
| isReasoningComplete: false, |
| error: null, |
| }; |
|
|
| interface AnalysisContextValue { |
| isBlurred: boolean; |
| analysisState: AnalysisState; |
| postContent: string | null; |
| postTitle: string | null; |
| setPostInfo: (content: string | null, title: string | null) => void; |
| startAnalysis: () => Promise<void>; |
| resetAnalysis: () => void; |
| } |
|
|
| const AnalysisContext = createContext<AnalysisContextValue | null>(null); |
|
|
| interface AnalysisProviderProps { |
| children: ReactNode; |
| } |
|
|
| export const AnalysisProvider = ({ children }: AnalysisProviderProps): JSX.Element => { |
| const [analysisState, setAnalysisState] = useState<AnalysisState>(initialAnalysisState); |
| const [postContent, setPostContent] = useState<string | null>(null); |
| const [postTitle, setPostTitle] = useState<string | null>(null); |
| const abortControllerRef = useRef<AbortController | null>(null); |
|
|
| const isBlurred = analysisState.isOpen; |
|
|
| const setPostInfo = useCallback((content: string | null, title: string | null): void => { |
| setPostContent(content); |
| setPostTitle(title); |
| }, []); |
|
|
| const resetAnalysis = useCallback((): void => { |
| if (abortControllerRef.current) { |
| abortControllerRef.current.abort(); |
| abortControllerRef.current = null; |
| } |
| setAnalysisState(initialAnalysisState); |
| }, []); |
|
|
| const startAnalysis = useCallback(async (): Promise<void> => { |
| if (!postContent || !postTitle) { |
| return; |
| } |
|
|
| if (abortControllerRef.current) { |
| abortControllerRef.current.abort(); |
| } |
|
|
| abortControllerRef.current = new AbortController(); |
|
|
| setAnalysisState({ |
| isOpen: true, |
| isLoading: true, |
| reasoning: "", |
| content: "", |
| isReasoningComplete: false, |
| error: null, |
| }); |
|
|
| try { |
| const response = await fetch("/api/analyze", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ content: postContent, title: postTitle }), |
| signal: abortControllerRef.current.signal, |
| }); |
|
|
| if (!response.ok) { |
| throw new Error("Failed to start analysis"); |
| } |
|
|
| const reader = response.body?.getReader(); |
| if (!reader) { |
| throw new Error("No response stream"); |
| } |
|
|
| const decoder = new TextDecoder("utf-8"); |
| let buffer = ""; |
| let hasReceivedContent = false; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
|
|
| if (done) { |
| break; |
| } |
|
|
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split("\n"); |
| buffer = lines.pop() || ""; |
|
|
| for (const line of lines) { |
| const trimmedLine = line.trim(); |
|
|
| if (!trimmedLine || !trimmedLine.startsWith("data: ")) { |
| continue; |
| } |
|
|
| const dataContent = trimmedLine.slice(6); |
|
|
| try { |
| const chunk: AIStreamChunk = JSON.parse(dataContent); |
|
|
| if (chunk.type === "error") { |
| setAnalysisState((previous) => ({ |
| ...previous, |
| isLoading: false, |
| error: chunk.content || "An error occurred", |
| })); |
| return; |
| } |
|
|
| if (chunk.type === "done") { |
| setAnalysisState((previous) => ({ |
| ...previous, |
| isLoading: false, |
| isReasoningComplete: true, |
| })); |
| return; |
| } |
|
|
| if (chunk.type === "reasoning" && chunk.content) { |
| setAnalysisState((previous) => ({ |
| ...previous, |
| reasoning: previous.reasoning + chunk.content, |
| })); |
| } |
|
|
| if (chunk.type === "content" && chunk.content) { |
| if (!hasReceivedContent) { |
| hasReceivedContent = true; |
| setAnalysisState((previous) => ({ |
| ...previous, |
| isReasoningComplete: true, |
| })); |
| } |
| setAnalysisState((previous) => ({ |
| ...previous, |
| content: previous.content + chunk.content, |
| })); |
| } |
| } catch { |
| continue; |
| } |
| } |
| } |
|
|
| setAnalysisState((previous) => ({ |
| ...previous, |
| isLoading: false, |
| isReasoningComplete: true, |
| })); |
| } catch (error) { |
| if (error instanceof Error && error.name === "AbortError") { |
| return; |
| } |
|
|
| setAnalysisState((previous) => ({ |
| ...previous, |
| isLoading: false, |
| error: error instanceof Error ? error.message : "An error occurred", |
| })); |
| } |
| }, [postContent, postTitle]); |
|
|
| useEffect((): (() => void) => { |
| const handlePopState = (): void => { |
| if (analysisState.isOpen) { |
| resetAnalysis(); |
| } |
| }; |
|
|
| window.addEventListener("popstate", handlePopState); |
|
|
| return (): void => { |
| window.removeEventListener("popstate", handlePopState); |
| }; |
| }, [analysisState.isOpen, resetAnalysis]); |
|
|
| useEffect((): (() => void) => { |
| return (): void => { |
| if (abortControllerRef.current) { |
| abortControllerRef.current.abort(); |
| abortControllerRef.current = null; |
| } |
| }; |
| }, []); |
|
|
| const contextValue = useMemo((): AnalysisContextValue => ({ |
| isBlurred, |
| analysisState, |
| postContent, |
| postTitle, |
| setPostInfo, |
| startAnalysis, |
| resetAnalysis, |
| }), [isBlurred, analysisState, postContent, postTitle, setPostInfo, startAnalysis, resetAnalysis]); |
|
|
| return ( |
| <AnalysisContext.Provider value={contextValue}> |
| {children} |
| </AnalysisContext.Provider> |
| ); |
| }; |
|
|
| export const useAnalysisContext = (): AnalysisContextValue => { |
| const context = useContext(AnalysisContext); |
| if (!context) { |
| throw new Error("useAnalysisContext must be used within an AnalysisProvider"); |
| } |
| return context; |
| }; |