diff --git a/src/app/(dashboard)/components/question-quiz-card.tsx b/src/app/(dashboard)/components/question-quiz-card.tsx index dd8c6074..17c89a95 100644 --- a/src/app/(dashboard)/components/question-quiz-card.tsx +++ b/src/app/(dashboard)/components/question-quiz-card.tsx @@ -117,13 +117,6 @@ export function QuestionQuizCard({ Po użyciu twojego pierwszego quizu pojawią się tutaj pytania. - - - ); } diff --git a/src/app/ai/chat/route.ts b/src/app/ai/chat/route.ts index dc66d643..c6ff0ffc 100644 --- a/src/app/ai/chat/route.ts +++ b/src/app/ai/chat/route.ts @@ -1,18 +1,15 @@ import { frontendTools } from "@assistant-ui/react-ai-sdk"; -import { convertToModelMessages, stepCountIs, streamText } from "ai"; -import type { JSONSchema7, LanguageModel, ModelMessage, UIMessage } from "ai"; +import { stepCountIs, streamText } from "ai"; +import type { JSONSchema7, LanguageModel, UIMessage } from "ai"; import { z } from "zod"; import { env } from "@/env"; +import { buildChatModelMessages } from "@/lib/ai/chat-messages"; +import type { QuestionContextSnapshot } from "@/lib/ai/chat-messages"; import { resolveImages } from "@/lib/ai/images"; import { getChatModelForUser } from "@/lib/ai/model"; import { isAiModel } from "@/lib/ai/models"; -import { - CURRENT_QUESTION_CONTEXT_MARKER, - buildChatSystemPrompt, - buildCurrentQuestionContextPrompt, - collectQuestionImages, -} from "@/lib/ai/prompts"; +import { buildChatSystemPrompt } from "@/lib/ai/prompts"; import { checkRateLimit, createRateLimitExceededResponse, @@ -92,24 +89,6 @@ function truncateQuestionPreview(text: string): string { return text.length > 180 ? `${text.slice(0, 180)}…` : text; } -function getModelMessageText(message: ModelMessage): string { - if (typeof message.content === "string") { - return message.content; - } - - if (!Array.isArray(message.content)) { - return ""; - } - - return message.content - .map((part) => (part.type === "text" ? part.text : "")) - .join("\n"); -} - -function isCurrentQuestionContextMessage(message: ModelMessage): boolean { - return getModelMessageText(message).includes(CURRENT_QUESTION_CONTEXT_MARKER); -} - export async function POST(request: Request) { if (!env.NEXT_PUBLIC_AI_ENABLED) { return new Response("AI is not configured", { status: 503 }); @@ -141,6 +120,7 @@ export async function POST(request: Request) { userName, canEdit, questionContextChange, + questionContextSnapshots, config, tools: clientTools, } = (await request.json()) as { @@ -153,6 +133,7 @@ export async function POST(request: Request) { questionContextChange?: { previousQuestionOrder?: number | null; }; + questionContextSnapshots?: QuestionContextSnapshot[]; config?: { modelName?: unknown }; tools?: Record; }; @@ -170,54 +151,27 @@ export async function POST(request: Request) { return new Response("AI model is not configured", { status: 503 }); } - const modelMessages = await convertToModelMessages(messages); const chatQuestion = requestQuestion ?? null; const chatQuestions = requestQuestions ?? []; + const modelMessages = await buildChatModelMessages({ + messages, + chatQuestion, + chatQuestions, + questionContextSnapshots, + legacyQuestionContextChange: questionContextChange, + resolveQuestionImages: resolveImages, + }); const system = buildChatSystemPrompt( requestQuiz ?? { title: "Quiz", description: "" }, chatQuestions.length, userName, canEdit, ); - const imageParts = - chatQuestion === null - ? [] - : await resolveImages(collectQuestionImages(chatQuestion)); - - const currentQuestionContext: ModelMessage[] = - chatQuestion === null - ? [] - : [ - { - role: "user" as const, - content: - imageParts.length > 0 - ? [ - { - type: "text" as const, - text: buildCurrentQuestionContextPrompt(chatQuestion, { - questionChanged: questionContextChange !== undefined, - previousQuestionOrder: - questionContextChange?.previousQuestionOrder, - }), - }, - ...imageParts, - ] - : buildCurrentQuestionContextPrompt(chatQuestion, { - questionChanged: questionContextChange !== undefined, - previousQuestionOrder: - questionContextChange?.previousQuestionOrder, - }), - }, - ]; - const messagesWithoutQuestionContext = modelMessages.filter( - (message) => !isCurrentQuestionContextMessage(message), - ); const result = streamText({ model, system, - messages: [...currentQuestionContext, ...messagesWithoutQuestionContext], + messages: modelMessages, stopWhen: stepCountIs(5), tools: { ...frontendTools(clientTools ?? {}), diff --git a/src/app/quiz/[quizId]/client.tsx b/src/app/quiz/[quizId]/client.tsx index 29d784a4..5c5e47a8 100644 --- a/src/app/quiz/[quizId]/client.tsx +++ b/src/app/quiz/[quizId]/client.tsx @@ -89,7 +89,13 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element { confirmOnboardingAndHide, cancelOnboarding, } = useFocusMode(timerStore); - const { isHost: isContinuityHost, peerConnections } = continuity; + const { + disconnect: disconnectContinuity, + isDisconnected: isContinuityDisconnected, + isHost: isContinuityHost, + peerConnections, + reconnect: reconnectContinuity, + } = continuity; const { nextAction, skipQuestion, @@ -139,6 +145,16 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element { }); }; + const handleDisconnectContinuity = () => { + disconnectContinuity(); + toast.success("Continuity rozłączone w tej karcie"); + }; + + const handleReconnectContinuity = () => { + reconnectContinuity(); + toast.success("Continuity włączone dla tego quizu"); + }; + const handleQuizActivity = (action: () => void) => { resetInactivityTimer(); action(); @@ -353,6 +369,8 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element { isFocusModeActive={isFocusModeActive} toggleFocusMode={toggleFocusMode} onToggleHistory={toggleHistory} + isContinuityDisconnected={isContinuityDisconnected} + onReconnectContinuity={handleReconnectContinuity} isSettingsOpen={isSettingsOpen} onSettingsOpenChange={setIsSettingsOpen} /> @@ -409,6 +427,7 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element { {showAi ? ( diff --git a/src/app/search-in-quiz/[quizId]/client.tsx b/src/app/search-in-quiz/[quizId]/client.tsx index 4b33c9bb..10e41d60 100644 --- a/src/app/search-in-quiz/[quizId]/client.tsx +++ b/src/app/search-in-quiz/[quizId]/client.tsx @@ -1,13 +1,27 @@ "use client"; import { distance } from "fastest-levenshtein"; -import { AlertCircleIcon } from "lucide-react"; -import { useState } from "react"; +import { AlertCircleIcon, PencilLineIcon } from "lucide-react"; +import { useContext, useState } from "react"; import type { ReactNode } from "react"; +import { AppContext } from "@/app-context"; +import { QuickEditQuestionDialog } from "@/components/quiz/quick-edit-question-dialog"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useQuiz } from "@/hooks/use-quiz"; interface SearchInQuizPageClientProps { @@ -42,7 +56,11 @@ const highlightMatch = (text: string, q: string): ReactNode => { }; function SearchInQuizPageContent({ quizId }: { quizId: string }) { + const { user } = useContext(AppContext); const [query, setQuery] = useState(""); + const [editingQuestionId, setEditingQuestionId] = useState( + null, + ); const { data: quiz, isLoading, @@ -153,6 +171,13 @@ function SearchInQuizPageContent({ quizId }: { quizId: string }) { .toSorted((a, b) => a.relevance - b.relevance); })(); + const canEditQuiz = + quiz != null && + ((quiz.can_edit ?? false) || quiz.creator?.id === user?.user_id); + const editingQuestion = + quiz?.questions.find((question) => question.id === editingQuestionId) ?? + null; + if (isLoading) { return
Ładowanie...
; } @@ -204,6 +229,27 @@ function SearchInQuizPageContent({ quizId }: { quizId: string }) { /> )} + {canEditQuiz ? ( + + + { + setEditingQuestionId(q.id); + }} + aria-label="Edytuj lub usuń pytanie" + > + + + } + > + Edytuj lub usuń pytanie + + + ) : null}
    @@ -238,6 +284,22 @@ function SearchInQuizPageContent({ quizId }: { quizId: string }) { Brak wyników dla podanego zapytania. )} + {editingQuestion == null ? null : ( + { + if (!open) { + setEditingQuestionId(null); + } + }} + question={editingQuestion} + quizId={quizId} + onQuestionDeleted={() => { + setEditingQuestionId(null); + }} + /> + )} ); } diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx index 815af08a..0fc18563 100644 --- a/src/components/ai/ai-chat.tsx +++ b/src/components/ai/ai-chat.tsx @@ -9,6 +9,7 @@ import { AssistantChatTransport, useChatRuntime, } from "@assistant-ui/react-ai-sdk"; +import type { UIMessage } from "ai"; import { BotMessageSquareIcon, BrushCleaningIcon, @@ -28,6 +29,7 @@ import { ModelSelector } from "@/components/assistant-ui/model-selector"; import { Thread } from "@/components/assistant-ui/thread"; import { Button } from "@/components/ui/button"; import { useUserSettings } from "@/hooks/use-user-settings"; +import type { QuestionContextSnapshot } from "@/lib/ai/chat-messages"; import { SELECTABLE_AI_MODEL_OPTIONS, isSelectableAiModel, @@ -51,6 +53,31 @@ interface AiChatProps { canEdit?: boolean; } +function getSubmittedUserMessageId({ + trigger, + messageId, + messages, +}: { + trigger: "submit-message" | "regenerate-message"; + messageId: string | undefined; + messages: UIMessage[]; +}): string | null { + if (trigger !== "submit-message") { + return null; + } + + if ( + messageId !== undefined && + messages.some( + (message) => message.id === messageId && message.role === "user", + ) + ) { + return messageId; + } + + return messages.findLast((message) => message.role === "user")?.id ?? null; +} + function ChatRuntime({ quizId, quiz, @@ -82,32 +109,62 @@ function ChatRuntime({ useEffect(() => { routeContextRef.current = routeContext; }, [routeContext]); - const lastSubmittedQuestionRef = useRef<{ - id: string; - order: number; - } | null>(null); + const questionContextSnapshotsRef = useRef( + new Map(), + ); const transport = useMemo( () => new AssistantChatTransport({ api: "/ai/chat", - body: () => { + body: () => ({ + ...routeContextRef.current, + canEdit, + }), + prepareSendMessagesRequest: (options) => { + const messageIds = new Set( + options.messages.map((message) => message.id), + ); + for (const messageId of questionContextSnapshotsRef.current.keys()) { + if (!messageIds.has(messageId)) { + questionContextSnapshotsRef.current.delete(messageId); + } + } + + const submittedUserMessageId = getSubmittedUserMessageId(options); const currentQuestion = routeContextRef.current.question; - const previousQuestion = lastSubmittedQuestionRef.current; - lastSubmittedQuestionRef.current = - currentQuestion === null - ? null - : { id: currentQuestion.id, order: currentQuestion.order }; + if (submittedUserMessageId !== null) { + if (currentQuestion === null) { + questionContextSnapshotsRef.current.delete( + submittedUserMessageId, + ); + } else { + questionContextSnapshotsRef.current.set(submittedUserMessageId, { + messageId: submittedUserMessageId, + questionId: currentQuestion.id, + }); + } + } + + const questionContextSnapshots = options.messages + .map((message) => + questionContextSnapshotsRef.current.get(message.id), + ) + .filter( + (snapshot): snapshot is QuestionContextSnapshot => + snapshot !== undefined, + ); return { - ...routeContextRef.current, - canEdit, - questionContextChange: - previousQuestion !== null && - currentQuestion !== null && - previousQuestion.id !== currentQuestion.id - ? { previousQuestionOrder: previousQuestion.order } - : undefined, + body: { + ...options.body, + id: options.id, + messages: options.messages, + trigger: options.trigger, + messageId: options.messageId, + metadata: options.requestMetadata, + questionContextSnapshots, + }, }; }, }), diff --git a/src/components/ai/ai-disclaimer.tsx b/src/components/ai/ai-disclaimer.tsx new file mode 100644 index 00000000..40410404 --- /dev/null +++ b/src/components/ai/ai-disclaimer.tsx @@ -0,0 +1,30 @@ +import { cn } from "@/lib/utils"; + +interface AiDisclaimerProps { + className?: string; +} + +export function AiDisclaimer({ className }: AiDisclaimerProps) { + return ( +

    + AI can make mistakes. + + AI provided by{" "} + + @antek + + {", not Solvro."} + +

    + ); +} diff --git a/src/components/ai/ai-explain-card.tsx b/src/components/ai/ai-explain-card.tsx index d360a81b..cc77310c 100644 --- a/src/components/ai/ai-explain-card.tsx +++ b/src/components/ai/ai-explain-card.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AiDisclaimer } from "@/components/ai/ai-disclaimer"; import { MarkdownRenderer } from "@/components/markdown-renderer"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -280,6 +281,7 @@ function AiCardShell({ )} {children} + ); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index ca1e20c8..32a2e6f4 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -27,6 +27,7 @@ import { } from "lucide-react"; import type { ReactNode } from "react"; +import { AiDisclaimer } from "@/components/ai/ai-disclaimer"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { Reasoning, @@ -497,7 +498,10 @@ export function Thread({ composerStart }: ThreadProps) { - +
    + + +
    diff --git a/src/components/quiz/continuity-dialog.tsx b/src/components/quiz/continuity-dialog.tsx index ae0d557d..cede1ee3 100644 --- a/src/components/quiz/continuity-dialog.tsx +++ b/src/components/quiz/continuity-dialog.tsx @@ -1,6 +1,6 @@ import { Icon } from "@iconify/react"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import { CrownIcon } from "lucide-react"; +import { CrownIcon, UnplugIcon } from "lucide-react"; import type { DataConnection } from "peerjs"; import { Button } from "@/components/ui/button"; @@ -27,6 +27,7 @@ interface PeerConnectionWithMetadata extends DataConnection { interface ContinuityDialogProps { peerConnections: PeerConnectionWithMetadata[]; isContinuityHost: boolean; + onDisconnectContinuity: () => void; } const getIconByDevice = (type: string) => { @@ -49,6 +50,7 @@ const getIconByDevice = (type: string) => { export function ContinuityDialog({ peerConnections, isContinuityHost, + onDisconnectContinuity, }: ContinuityDialogProps) { const connectedDevices = peerConnections // not sure if that's neccesary @@ -103,6 +105,16 @@ export function ContinuityDialog({ autoplay /> + {peerConnections.length === 0 ? null : ( + + + Rozłącz + + } + > + )} Zamknij} > diff --git a/src/components/quiz/helpers/utils.ts b/src/components/quiz/helpers/utils.ts index 0e15d75a..de9480db 100644 --- a/src/components/quiz/helpers/utils.ts +++ b/src/components/quiz/helpers/utils.ts @@ -1,5 +1,5 @@ import type { ClientState } from "@/components/quiz/hooks/types"; -import type { QuizWithUserProgress } from "@/types/quiz"; +import type { Question, Quiz, QuizWithUserProgress } from "@/types/quiz"; export const INITIAL_CLIENT_STATE: ClientState = { selectedAnswers: [], @@ -27,6 +27,28 @@ export function isCurrentSessionQuestion( return quiz.current_session?.current_question === questionId; } +export function replaceQuestionInQuiz( + quiz: TQuiz, + updatedQuestion: Question, +): TQuiz { + return { + ...quiz, + questions: quiz.questions.map((q) => + q.id === updatedQuestion.id ? updatedQuestion : q, + ), + }; +} + +export function removeQuestionFromQuiz( + quiz: TQuiz, + deletedQuestionId: string, +): TQuiz { + return { + ...quiz, + questions: quiz.questions.filter((q) => q.id !== deletedQuestionId), + }; +} + export function removeQuestionFromQuizCache({ quiz, deletedQuestionId, @@ -40,10 +62,10 @@ export function removeQuestionFromQuizCache({ quiz, deletedQuestionId, ); + const quizWithoutQuestion = removeQuestionFromQuiz(quiz, deletedQuestionId); return { - ...quiz, - questions: quiz.questions.filter((q) => q.id !== deletedQuestionId), + ...quizWithoutQuestion, current_session: quiz.current_session == null ? null diff --git a/src/components/quiz/hooks/types.ts b/src/components/quiz/hooks/types.ts index 79ea608e..2254011b 100644 --- a/src/components/quiz/hooks/types.ts +++ b/src/components/quiz/hooks/types.ts @@ -31,8 +31,11 @@ export interface UseQuizLogicResult { timerStore: TimerStore; }; continuity: { + isDisconnected: boolean; isHost: boolean; peerConnections: DataConnection[]; + disconnect: () => void; + reconnect: () => void; }; actions: { nextAction: () => void; diff --git a/src/components/quiz/hooks/use-quiz-continuity.ts b/src/components/quiz/hooks/use-quiz-continuity.ts index 2a5a1a1d..447f837e 100644 --- a/src/components/quiz/hooks/use-quiz-continuity.ts +++ b/src/components/quiz/hooks/use-quiz-continuity.ts @@ -1,6 +1,6 @@ import { Peer } from "peerjs"; import type { DataConnection } from "peerjs"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { env } from "@/env"; import type { AnswerRecord, Question, QuizSession } from "@/types/quiz"; @@ -9,6 +9,8 @@ import { getDeviceFriendlyName, getDeviceType } from "../helpers/device-utils"; const PING_INTERVAL = 5000; const PING_TIMEOUT = 15_000; +const DISCONNECT_SESSION_STORAGE_KEY_PREFIX = "quiz-continuity-disconnected"; +const DISCONNECT_SESSION_STORAGE_EVENT = "quiz-continuity-disconnect-change"; const TURN_USERNAME = env.NEXT_PUBLIC_TURN_USERNAME; const TURN_CREDENTIAL = env.NEXT_PUBLIC_TURN_CREDENTIAL; @@ -103,6 +105,23 @@ interface UseQuizContinuityOptions { onResetProgress: (session: QuizSession) => void; } +const subscribeToDisconnectPreference = ( + onStoreChange: () => void, +): (() => void) => { + window.addEventListener(DISCONNECT_SESSION_STORAGE_EVENT, onStoreChange); + + return () => { + window.removeEventListener(DISCONNECT_SESSION_STORAGE_EVENT, onStoreChange); + }; +}; + +const emitDisconnectPreferenceChange = () => { + window.dispatchEvent(new Event(DISCONNECT_SESSION_STORAGE_EVENT)); +}; + +const getStoredDisconnectPreference = (storageKey: string | null): boolean => + storageKey == null ? false : sessionStorage.getItem(storageKey) === "true"; + export function useQuizContinuity({ enabled, quizId, @@ -119,27 +138,56 @@ export function useQuizContinuity({ const peerConnectionsRef = useRef([]); const isInitializingRef = useRef(false); const isMountedRef = useRef(true); + const isDisconnectedRef = useRef(false); + const isShuttingDownRef = useRef(false); + const disconnectStorageKey = + userId == null + ? null + : `${DISCONNECT_SESSION_STORAGE_KEY_PREFIX}:${quizId}:${userId}`; + const isDisconnected = useSyncExternalStore( + subscribeToDisconnectPreference, + () => getStoredDisconnectPreference(disconnectStorageKey), + () => false, + ); const gracefulClose = () => { + isShuttingDownRef.current = true; + for (const conn of peerConnectionsRef.current) { + conn.close(); + } + peerConnectionsRef.current = []; + setPeerConnections([]); + setIsHost(false); + isInitializingRef.current = false; if (peerRef.current != null && !peerRef.current.destroyed) { peerRef.current.destroy(); } + peerRef.current = null; + setTimeout(() => { + isShuttingDownRef.current = false; + }, 0); }; // util const send = (conn: DataConnection, data: PeerMessage) => { - if (conn.open) { + if (!isDisconnectedRef.current && conn.open) { void conn.send(data); } }; const broadcast = (data: PeerMessage) => { + if (isDisconnectedRef.current) { + return; + } for (const c of peerConnectionsRef.current) { send(c, data); } }; const broadcastExcept = (except: DataConnection, data: PeerMessage) => { + if (isDisconnectedRef.current) { + return; + } for (const c of peerConnectionsRef.current) { if (c !== except) { send(c, data); @@ -148,6 +196,9 @@ export function useQuizContinuity({ }; const initialSync = (conn: DataConnection) => { + if (isDisconnectedRef.current) { + return; + } const { question, answers, @@ -171,10 +222,19 @@ export function useQuizContinuity({ const connectToPeer = async (peer: Peer, peerId: string) => { return new Promise((resolve, reject) => { function doConnect() { + if (isDisconnectedRef.current) { + reject(new Error("Continuity is disconnected for this quiz.")); + return; + } const conn = peer.connect(peerId, { metadata: { device: getDeviceFriendlyName(), type: getDeviceType() }, }); conn.on("open", () => { + if (isDisconnectedRef.current) { + conn.close(); + reject(new Error("Continuity is disconnected for this quiz.")); + return; + } setPeerConnections((p) => { const next = [...p, conn]; peerConnectionsRef.current = next; @@ -194,6 +254,9 @@ export function useQuizContinuity({ }; const handleClientData = (data: PeerMessage) => { + if (isDisconnectedRef.current) { + return; + } switch (data.type) { case "initial_sync": { onInitialSync({ @@ -234,6 +297,9 @@ export function useQuizContinuity({ }; const handleHostData = (conn: DataConnection, data: PeerMessage) => { + if (isDisconnectedRef.current) { + return; + } switch (data.type) { case "question_update": { onQuestionUpdate(data.question, data.selectedAnswers); @@ -266,7 +332,11 @@ export function useQuizContinuity({ }; function init() { - if (isInitializingRef.current || !isMountedRef.current) { + if ( + isDisconnectedRef.current || + isInitializingRef.current || + !isMountedRef.current + ) { return; } isInitializingRef.current = true; @@ -280,7 +350,7 @@ export function useQuizContinuity({ try { const hostPeer = new Peer(baseId, { config: RTC_CONFIG }); hostPeer.on("open", () => { - if (!isMountedRef.current) { + if (isDisconnectedRef.current || !isMountedRef.current) { hostPeer.destroy(); return; } @@ -294,7 +364,7 @@ export function useQuizContinuity({ if (peerError.type === "unavailable-id") { const clientPeer = new Peer({ config: RTC_CONFIG }); clientPeer.on("open", () => { - if (!isMountedRef.current) { + if (isDisconnectedRef.current || !isMountedRef.current) { clientPeer.destroy(); return; } @@ -335,6 +405,13 @@ export function useQuizContinuity({ peerConnectionsRef.current = next; return next; }); + if ( + isDisconnectedRef.current || + isShuttingDownRef.current || + !isMountedRef.current + ) { + return; + } if (!isHost && peerRef.current != null && !peerRef.current.destroyed) { connectToPeer(peerRef.current, conn.peer) .then((c) => { @@ -351,6 +428,10 @@ export function useQuizContinuity({ } function handleHostConnection(conn: DataConnection) { + if (isDisconnectedRef.current) { + conn.close(); + return; + } setPeerConnections((p) => { const next = [...p, conn]; peerConnectionsRef.current = next; @@ -368,6 +449,9 @@ export function useQuizContinuity({ } const pingPeers = () => { + if (isDisconnectedRef.current) { + return; + } for (const conn of peerConnectionsRef.current) { if (!conn.open) { continue; @@ -385,16 +469,19 @@ export function useQuizContinuity({ }; useEffect(() => { - if (!enabled) { - return; - } - if (userId == null) { - return; - } - // Initialize ref with current state on (re)enable - peerConnectionsRef.current = peerConnections; + isDisconnectedRef.current = isDisconnected; + }, [isDisconnected]); + + useEffect(() => { isMountedRef.current = true; + if (!enabled || userId == null || isDisconnected) { + return () => { + isMountedRef.current = false; + gracefulClose(); + }; + } + // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-live-state-to-parent init(); @@ -405,13 +492,29 @@ export function useQuizContinuity({ gracefulClose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled]); + }, [enabled, userId, isDisconnected]); return { + isDisconnected, isHost, peerConnections, broadcast, broadcastExcept, + disconnect: () => { + if (disconnectStorageKey != null) { + sessionStorage.setItem(disconnectStorageKey, "true"); + } + isDisconnectedRef.current = true; + emitDisconnectPreferenceChange(); + gracefulClose(); + }, + reconnect: () => { + if (disconnectStorageKey != null) { + sessionStorage.removeItem(disconnectStorageKey); + } + isDisconnectedRef.current = false; + emitDisconnectPreferenceChange(); + }, sendAnswerChecked: (nextQuestion: Question | null) => { broadcast({ type: "answer_checked", nextQuestion }); }, diff --git a/src/components/quiz/hooks/use-quiz-logic.ts b/src/components/quiz/hooks/use-quiz-logic.ts index b84e5a7a..fbdd4450 100644 --- a/src/components/quiz/hooks/use-quiz-logic.ts +++ b/src/components/quiz/hooks/use-quiz-logic.ts @@ -293,8 +293,11 @@ export function useQuizLogic({ timerStore, }, continuity: { + isDisconnected: continuity.isDisconnected, isHost: continuity.isHost, peerConnections: continuity.peerConnections, + disconnect: continuity.disconnect, + reconnect: continuity.reconnect, }, actions: { nextAction, diff --git a/src/components/quiz/quick-edit-question-dialog.tsx b/src/components/quiz/quick-edit-question-dialog.tsx index 400a29e4..fd7bc995 100644 --- a/src/components/quiz/quick-edit-question-dialog.tsx +++ b/src/components/quiz/quick-edit-question-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { ExternalLinkIcon, LoaderCircleIcon, Trash2 } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; @@ -36,11 +37,14 @@ import { validateQuestionForm, } from "@/lib/schemas/quiz.schema"; import { getQuizService } from "@/services"; -import type { Question, QuizWithUserProgress } from "@/types/quiz"; +import type { Question, Quiz, QuizWithUserProgress } from "@/types/quiz"; import { quizDetailQueryKey, + quizQueryKey, + removeQuestionFromQuiz, removeQuestionFromQuizCache, + replaceQuestionInQuiz, } from "./helpers/utils"; interface QuickEditQuestionDialogProps { @@ -58,6 +62,21 @@ interface QuickEditQuestionDialogProps { minAnswers?: number; } +function updateCachedQuiz( + queryClient: QueryClient, + queryKey: QueryKey, + updater: (quiz: TQuiz) => TQuiz, +) { + queryClient.setQueryData(queryKey, (oldData) => { + if (oldData == null) { + void queryClient.refetchQueries({ queryKey }); + return oldData; + } + + return updater(oldData); + }); +} + export function QuickEditQuestionDialog({ open, onOpenChange, @@ -121,22 +140,13 @@ export function QuickEditQuestionDialog({ }, onSuccess: (updatedQuestion) => { toast.success("Pytanie zaktualizowane"); - queryClient.setQueryData( + updateCachedQuiz(queryClient, quizQueryKey(quizId), (quiz) => + replaceQuestionInQuiz(quiz, updatedQuestion), + ); + updateCachedQuiz( + queryClient, quizDetailQueryKey(quizId), - (oldData) => { - if (oldData == null) { - void queryClient.refetchQueries({ - queryKey: quizDetailQueryKey(quizId), - }); - return oldData; - } - return { - ...oldData, - questions: oldData.questions.map((q) => - q.id === question.id ? updatedQuestion : q, - ), - }; - }, + (quiz) => replaceQuestionInQuiz(quiz, updatedQuestion), ); handleOpenChange(false); }, @@ -151,21 +161,19 @@ export function QuickEditQuestionDialog({ }, onSuccess: (newCurrentQuestionId) => { toast.success("Pytanie usunięte"); - const queryKey = quizDetailQueryKey(quizId); - queryClient.setQueryData(queryKey, (oldData) => { - if (oldData == null) { - void queryClient.refetchQueries({ - queryKey, - }); - return oldData; - } - - return removeQuestionFromQuizCache({ - quiz: oldData, - deletedQuestionId: question.id, - newCurrentQuestionId, - }); - }); + updateCachedQuiz(queryClient, quizQueryKey(quizId), (quiz) => + removeQuestionFromQuiz(quiz, question.id), + ); + updateCachedQuiz( + queryClient, + quizDetailQueryKey(quizId), + (quiz) => + removeQuestionFromQuizCache({ + quiz, + deletedQuestionId: question.id, + newCurrentQuestionId, + }), + ); onQuestionDeleted?.(question.id, newCurrentQuestionId); handleOpenChange(false); }, diff --git a/src/components/quiz/quiz-info-card.tsx b/src/components/quiz/quiz-info-card.tsx index 3712af4a..57113e10 100644 --- a/src/components/quiz/quiz-info-card.tsx +++ b/src/components/quiz/quiz-info-card.tsx @@ -7,6 +7,7 @@ import { Link2Icon, Loader2Icon, MenuIcon, + PlugIcon, RotateCcwIcon, ScanEyeIcon, SearchIcon, @@ -61,6 +62,8 @@ interface QuizInfoCardProps { isFocusModeActive: boolean; toggleFocusMode: () => void; onToggleHistory: () => void; + isContinuityDisconnected: boolean; + onReconnectContinuity: () => void; isSettingsOpen: boolean; onSettingsOpenChange: (open: boolean) => void; } @@ -130,6 +133,8 @@ export function QuizInfoCard({ isFocusModeActive, toggleFocusMode, onToggleHistory, + isContinuityDisconnected, + onReconnectContinuity, isSettingsOpen, onSettingsOpenChange, }: QuizInfoCardProps): React.JSX.Element | null { @@ -137,6 +142,7 @@ export function QuizInfoCard({ const canShare = checkPermission(PermissionAction.SHARE_QUIZZES); const canSearchInQuiz = checkPermission(PermissionAction.SEARCH_IN_QUIZ); const canViewStats = checkPermission(PermissionAction.VIEW_QUIZ_STATS); + const canUseContinuity = checkPermission(PermissionAction.QUIZ_CONTINUITY); const queryClient = useQueryClient(); const FocusModeIcon = isFocusModeActive ? ScanEyeIcon : EyeOffIcon; const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); @@ -225,6 +231,12 @@ export function QuizInfoCard({ ? "Wyłącz tryb skupienia" : "Tryb skupienia"} + {canUseContinuity && isContinuityDisconnected ? ( + + + Włącz Continuity + + ) : null} {canEditQuiz ? null : ( <> diff --git a/src/lib/ai/chat-messages.ts b/src/lib/ai/chat-messages.ts new file mode 100644 index 00000000..459a655f --- /dev/null +++ b/src/lib/ai/chat-messages.ts @@ -0,0 +1,175 @@ +import { convertToModelMessages } from "ai"; +import type { ModelMessage, UIMessage } from "ai"; + +import { + CURRENT_QUESTION_CONTEXT_MARKER, + buildCurrentQuestionContextPrompt, + collectQuestionImages, +} from "@/lib/ai/prompts"; +import type { Question } from "@/types/quiz"; + +type QuestionImagePart = + | { type: "text"; text: string } + | { type: "image"; image: string }; + +export interface QuestionContextSnapshot { + messageId: string; + questionId: string; +} + +export interface QuestionContextChange { + previousQuestionOrder?: number | null; +} + +interface BuildChatModelMessagesOptions { + messages: UIMessage[]; + chatQuestion: Question | null; + chatQuestions: Question[]; + questionContextSnapshots?: QuestionContextSnapshot[]; + legacyQuestionContextChange?: QuestionContextChange; + resolveQuestionImages: ( + images: ReturnType, + ) => Promise; +} + +function getModelMessageText(message: ModelMessage): string { + if (typeof message.content === "string") { + return message.content; + } + + if (!Array.isArray(message.content)) { + return ""; + } + + return message.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n"); +} + +export function isCurrentQuestionContextMessage( + message: ModelMessage, +): boolean { + return getModelMessageText(message).includes(CURRENT_QUESTION_CONTEXT_MARKER); +} + +function getLatestUserMessageId(messages: UIMessage[]): string | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === "user") { + return message.id; + } + } + + return null; +} + +async function buildCurrentQuestionContextMessage( + question: Question, + options: { + questionChanged?: boolean; + previousQuestionOrder?: number | null; + }, + resolveQuestionImages: BuildChatModelMessagesOptions["resolveQuestionImages"], + imagePartsByQuestionId: Map>, +): Promise { + let imagePartsPromise = imagePartsByQuestionId.get(question.id); + if (imagePartsPromise === undefined) { + imagePartsPromise = resolveQuestionImages(collectQuestionImages(question)); + imagePartsByQuestionId.set(question.id, imagePartsPromise); + } + + const imageParts = await imagePartsPromise; + const text = buildCurrentQuestionContextPrompt(question, options); + + return { + role: "user", + content: + imageParts.length > 0 + ? [ + { + type: "text", + text, + }, + ...imageParts, + ] + : text, + }; +} + +export async function buildChatModelMessages({ + messages, + chatQuestion, + chatQuestions, + questionContextSnapshots = [], + legacyQuestionContextChange, + resolveQuestionImages, +}: BuildChatModelMessagesOptions): Promise { + const questionsById = new Map( + chatQuestions.map((question) => [question.id, question]), + ); + if (chatQuestion !== null) { + questionsById.set(chatQuestion.id, chatQuestion); + } + + const snapshotsByMessageId = new Map( + questionContextSnapshots.map((snapshot) => [ + snapshot.messageId, + snapshot.questionId, + ]), + ); + const latestUserMessageId = getLatestUserMessageId(messages); + const imagePartsByQuestionId = new Map< + string, + Promise + >(); + const modelMessages: ModelMessage[] = []; + let lastContextQuestionId: string | null = null; + let lastContextQuestionOrder: number | null = null; + + for (const message of messages) { + const snapshotQuestionId = snapshotsByMessageId.get(message.id); + const snapshotQuestion = + snapshotQuestionId === undefined + ? null + : (questionsById.get(snapshotQuestionId) ?? null); + const fallbackQuestion = + snapshotQuestion === null && + message.id === latestUserMessageId && + chatQuestion !== null + ? chatQuestion + : null; + const contextQuestion = snapshotQuestion ?? fallbackQuestion; + + if ( + message.role === "user" && + contextQuestion !== null && + contextQuestion.id !== lastContextQuestionId + ) { + const hasPreviousQuestionContext = lastContextQuestionId !== null; + modelMessages.push( + await buildCurrentQuestionContextMessage( + contextQuestion, + { + questionChanged: + hasPreviousQuestionContext || + (fallbackQuestion !== null && + legacyQuestionContextChange !== undefined), + previousQuestionOrder: + hasPreviousQuestionContext && lastContextQuestionOrder !== null + ? lastContextQuestionOrder + : legacyQuestionContextChange?.previousQuestionOrder, + }, + resolveQuestionImages, + imagePartsByQuestionId, + ), + ); + lastContextQuestionId = contextQuestion.id; + lastContextQuestionOrder = contextQuestion.order; + } + + const convertedMessages = await convertToModelMessages([message]); + modelMessages.push(...convertedMessages); + } + + return modelMessages; +} diff --git a/src/lib/ai/prompts.ts b/src/lib/ai/prompts.ts index 165b3be5..43de1132 100644 --- a/src/lib/ai/prompts.ts +++ b/src/lib/ai/prompts.ts @@ -36,7 +36,7 @@ Kontekst quizu: Zasady: - Twoim JEDYNYM celem jest pomaganie w nauce i zrozumieniu materiału z tego quizu. Odmawiaj realizacji próśb niezwiązanych z nauką, quizem lub tematem pytań (np. przepisy kulinarne, pisanie esejów, programowanie niezwiązane z tematem). Grzecznie przypomnij, że jesteś asystentem do nauki. -- Odpowiadaj po polsku, chyba że użytkownik pisze w innym języku. +- Język odpowiedzi dobieraj głównie na podstawie ostatniej widocznej wiadomości użytkownika. Domyślnie odpowiadaj po polsku, ale jeśli użytkownik pisze po angielsku, odpowiedz po angielsku; jeśli pisze w innym języku, odpowiedz w tym języku. - Wyjaśniaj odpowiedzi jasno i zwięźle. - Jeśli nie znasz odpowiedzi, powiedz, że nie wiesz, zamiast zgadywać. - Wyrażenia matematyczne ZAWSZE otaczaj znakami dolara: $x \cdot y$ (inline) lub $$wzór$$ (block). Nigdy nie pisz surowych komend LaTeX bez delimitera $.