Commit aa1b2cd9 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev_external_task_selection' into 'main'

Dev external task selection

See merge request kantz/tutor_react!6
parents e31efbb9 a678fa77
import { EditPencil, Send } from "iconoir-react"; import { EditPencil, Send } from "iconoir-react";
import { t } from "../../i18n";
type MessageInputProps = { type MessageInputProps = {
value: string; value: string;
...@@ -22,8 +23,8 @@ export default function MessageInput({ ...@@ -22,8 +23,8 @@ export default function MessageInput({
className="btn" className="btn"
type="button" type="button"
onClick={onToggleCanvas} onClick={onToggleCanvas}
aria-label="Draw" aria-label={t("draw")}
title="Draw" title={t("draw")}
> >
<EditPencil width={18} height={18} aria-hidden="true" /> <EditPencil width={18} height={18} aria-hidden="true" />
</button> </button>
...@@ -32,15 +33,15 @@ export default function MessageInput({ ...@@ -32,15 +33,15 @@ export default function MessageInput({
type="button" type="button"
onClick={onSend} onClick={onSend}
disabled={!canSend} disabled={!canSend}
aria-label="Send" aria-label={t("send")}
title="Send" title={t("send")}
> >
<Send width={18} height={18} aria-hidden="true" /> <Send width={18} height={18} aria-hidden="true" />
</button> </button>
</div> </div>
<textarea <textarea
className="composer-input" className="composer-input"
placeholder="Type your question or paste LaTeX..." placeholder={t("typeQuestionOrLatex")}
rows={3} rows={3}
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
......
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { t } from "../../i18n";
declare global { declare global {
interface Window { interface Window {
...@@ -63,10 +64,10 @@ export default function DocCard({ ...@@ -63,10 +64,10 @@ export default function DocCard({
</div> </div>
<div className="doc-actions"> <div className="doc-actions">
<button className="btn small" type="button" onClick={onInspect}> <button className="btn small" type="button" onClick={onInspect}>
Inspect {t("inspect")}
</button> </button>
<button className="btn small" type="button" onClick={onCite}> <button className="btn small" type="button" onClick={onCite}>
Cite {t("cite")}
</button> </button>
</div> </div>
</details> </details>
......
import DocCard from "./DocCard"; import DocCard from "./DocCard";
import { t } from "../../i18n";
export type RetrievedDoc = { export type RetrievedDoc = {
uid: string; uid: string;
...@@ -21,6 +22,7 @@ export type RetrievedDoc = { ...@@ -21,6 +22,7 @@ export type RetrievedDoc = {
type DocPanelProps = { type DocPanelProps = {
directChildren: RetrievedDoc[]; directChildren: RetrievedDoc[];
taskChildren: RetrievedDoc[];
indirectChildren: RetrievedDoc[]; indirectChildren: RetrievedDoc[];
subsections: RetrievedDoc[]; subsections: RetrievedDoc[];
sections: RetrievedDoc[]; sections: RetrievedDoc[];
...@@ -37,7 +39,7 @@ const buildTitle = (doc: RetrievedDoc) => { ...@@ -37,7 +39,7 @@ const buildTitle = (doc: RetrievedDoc) => {
meta.subsection_title || meta.subsection_title ||
meta.section_title || meta.section_title ||
meta.path || meta.path ||
"Untitled" t("untitled")
); );
}; };
...@@ -97,6 +99,7 @@ const renderGroup = ( ...@@ -97,6 +99,7 @@ const renderGroup = (
export default function DocPanel({ export default function DocPanel({
directChildren, directChildren,
taskChildren,
indirectChildren, indirectChildren,
subsections, subsections,
sections, sections,
...@@ -107,22 +110,24 @@ export default function DocPanel({ ...@@ -107,22 +110,24 @@ export default function DocPanel({
}: DocPanelProps) { }: DocPanelProps) {
const total = const total =
directChildren.length + directChildren.length +
taskChildren.length +
indirectChildren.length + indirectChildren.length +
subsections.length + subsections.length +
sections.length; sections.length;
return ( return (
<div className="retrieval-panel"> <div className="retrieval-panel">
<div className="retrieval-title">Retrieved Notes</div> <div className="retrieval-title">{t("retrievedNotes")}</div>
{isLoading ? <div className="retrieval-state">Loading...</div> : null} {isLoading ? <div className="retrieval-state">{t("loading")}</div> : null}
{error ? <div className="retrieval-state error">{error}</div> : null} {error ? <div className="retrieval-state error">{error}</div> : null}
{!isLoading && !error && total === 0 ? ( {!isLoading && !error && total === 0 ? (
<div className="retrieval-state">No results yet.</div> <div className="retrieval-state">{t("noResultsYet")}</div>
) : null} ) : null}
{renderGroup("Direct children", directChildren, onCiteDoc, onInspectDoc)} {renderGroup(t("taskChildren"), taskChildren, onCiteDoc, onInspectDoc)}
{renderGroup("Indirect children", indirectChildren, onCiteDoc, onInspectDoc)} {renderGroup(t("directChildren"), directChildren, onCiteDoc, onInspectDoc)}
{renderGroup("Subsection summary", subsections, onCiteDoc, onInspectDoc)} {renderGroup(t("indirectChildren"), indirectChildren, onCiteDoc, onInspectDoc)}
{renderGroup("Section summary", sections, onCiteDoc, onInspectDoc)} {renderGroup(t("subsectionSummary"), subsections, onCiteDoc, onInspectDoc)}
{renderGroup(t("sectionSummary"), sections, onCiteDoc, onInspectDoc)}
</div> </div>
); );
} }
import { useEffect, useRef } from "react";
import { t } from "../../i18n";
type SelectOption = {
value: string;
label: string;
};
type TaskPanelProps = {
readOnly?: boolean;
selectedFileLabel?: string;
selectedFileId?: string;
fileOptions?: SelectOption[];
selectedTaskId?: string;
taskOptions?: SelectOption[];
selectedTaskText: string;
tasksError?: string | null;
onTaskFileChange?: (fileId: string) => void;
onTaskChange?: (taskId: string) => void;
onChangeTaskArea?: () => void;
onPreviousTask?: () => void;
isPreviousTaskDisabled?: boolean;
onNextTask?: () => void;
isNextTaskDisabled?: boolean;
};
export default function TaskPanel({
readOnly = false,
selectedFileLabel,
selectedFileId = "",
fileOptions = [],
selectedTaskId = "",
taskOptions = [],
selectedTaskText,
tasksError,
onTaskFileChange,
onTaskChange,
onChangeTaskArea,
onPreviousTask,
isPreviousTaskDisabled = false,
onNextTask,
isNextTaskDisabled = false,
}: TaskPanelProps) {
const taskDisplayRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!selectedTaskText || !taskDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([taskDisplayRef.current]).catch(() => undefined);
}, [selectedTaskText]);
return (
<section className="task-panel">
<div className="task-panel-header">
<div className="task-panel-title">{t("task")}</div>
{readOnly ? (
<button
type="button"
className="btn task-panel-change-btn"
onClick={onChangeTaskArea}
>
{t("changeTaskArea")}
</button>
) : (
<div className="task-panel-controls">
<select
className="task-select"
value={selectedFileId}
onChange={(event) => onTaskFileChange?.(event.target.value)}
disabled={!fileOptions.length}
>
{fileOptions.length ? (
fileOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
<select
className="task-select"
value={selectedTaskId}
onChange={(event) => onTaskChange?.(event.target.value)}
disabled={!taskOptions.length}
>
{taskOptions.length ? (
taskOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
</div>
)}
</div>
{readOnly ? (
<div className="task-panel-meta">
<div>{selectedFileLabel || "-"}</div>
<div>{selectedTaskId ? `${t("taskId")}: ${selectedTaskId}` : ""}</div>
</div>
) : null}
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTaskText || t("noTaskSelected")}
</div>
{readOnly ? (
<div className="task-panel-nav">
<button
type="button"
className="btn task-panel-nav-btn"
onClick={onPreviousTask}
disabled={!onPreviousTask || isPreviousTaskDisabled}
>
{t("previousTask")}
</button>
<button
type="button"
className="btn task-panel-nav-btn"
onClick={onNextTask}
disabled={!onNextTask || isNextTaskDisabled}
>
{t("nextTask")}
</button>
</div>
) : null}
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
</section>
);
}
const translations = {
en: {
chats: "Chats",
prototypeWorkspace: "Prototype Workspace",
task: "Task",
chat: "Chat",
canvas: "Canvas",
retrievedNotes: "Retrieved Notes",
typeQuestionOrLatex: "Type your question or paste LaTeX...",
noResultsYet: "No results yet.",
eraser: "Eraser",
pen: "Pen",
clear: "Clear",
saveAndConvert: "Save + Convert",
hide: "Hide",
show: "Show",
newChat: "New Chat",
loading: "Loading...",
directChildren: "Direct children",
taskChildren: "Task sources",
indirectChildren: "Indirect children",
subsectionSummary: "Subsection summary",
sectionSummary: "Section summary",
untitled: "Untitled",
inspect: "Inspect",
cite: "Cite",
draw: "Draw",
send: "Send",
roleUser: "user",
roleAssistant: "assistant",
noTasksAvailable: "No tasks available",
noTaskSelected: "No task selected.",
savedChats: "Saved Chats",
saving: "Saving...",
noSavedChatsYet: "No saved chats yet.",
canvasHidden: "Canvas hidden",
failedLoadTasks: "Could not load tasks.",
chatRequestFailed: "The chat request failed. Please check backend logs.",
retrievalFailed: "Source retrieval failed. Please check backend logs.",
failedLoadSavedChats: "Could not load saved chats.",
failedLoadSelectedChat: "Could not load the selected chat.",
savingDrawingConverting: "Saving drawing and converting to LaTeX...",
saveFailedStatus: "Saving failed (status: {status}).",
canvasSaveFailed: "Saving the canvas failed.",
canvasSaved: "Canvas saved.",
canvasSaveFailedCheckLogs:
"Saving the canvas failed. Please check backend logs.",
taskSelectionTitle: "Select a Task",
taskSelectionSubtitle: "Choose a task and start a tutor session",
taskFile: "Task Set",
taskId: "Task ID",
solveWithTutor: "Solve with Tutor",
changeTaskArea: "Change Task Area",
previousTask: "Previous Task",
nextTask: "Next Task",
},
de: {
chats: "Chats",
prototypeWorkspace: "Prototyp-Arbeitsbereich",
task: "Aufgabe",
chat: "Chat",
canvas: "Canvas",
retrievedNotes: "Quellen",
typeQuestionOrLatex:
"Gebe hier deine Frage ein. Nutze $ $ fuer mathematische Eingaben...",
noResultsYet: "bisher keine Quellen",
eraser: "Radierer",
pen: "Stift",
clear: "Leeren",
saveAndConvert: "Speichern + Konvertieren",
hide: "verstecken",
show: "anzeigen",
newChat: "neuer Chat",
loading: "Lade...",
directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen",
subsectionSummary: "Unterabschnitt-Zusammenfassung",
sectionSummary: "Abschnitt-Zusammenfassung",
untitled: "Ohne Titel",
inspect: "Ansehen",
cite: "Zitieren",
draw: "Zeichnen",
send: "Senden",
roleUser: "nutzer",
roleAssistant: "assistent",
noTasksAvailable: "Keine Aufgaben verfuegbar",
noTaskSelected: "Keine Aufgabe ausgewählt.",
savedChats: "Gespeicherte Chats",
saving: "Speichere...",
noSavedChatsYet: "Noch keine gespeicherten Chats.",
canvasHidden: "Canvas ausgeblendet",
failedLoadTasks: "Aufgaben konnten nicht geladen werden.",
chatRequestFailed:
"Chat-Anfrage ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
retrievalFailed:
"Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
failedLoadSavedChats: "Gespeicherte Chats konnten nicht geladen werden.",
failedLoadSelectedChat: "Der ausgewählte Chat konnte nicht geladen werden.",
savingDrawingConverting:
"Zeichnung wird gespeichert und in LaTeX konvertiert...",
saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}).",
canvasSaveFailed: "Speichern des Canvas fehlgeschlagen.",
canvasSaved: "Canvas gespeichert.",
canvasSaveFailedCheckLogs:
"Speichern des Canvas fehlgeschlagen. Bitte pruefe die Backend-Logs.",
taskSelectionTitle: "Aufgabe auswaehlen",
taskSelectionSubtitle: "Waehle eine Aufgabe und starte den Tutor-Chat",
taskFile: "Aufgabenset",
taskId: "Aufgaben-ID",
solveWithTutor: "Mit Tutor lösen",
changeTaskArea: "Aufgabengebiet ändern",
previousTask: "Vorherige Aufgabe",
nextTask: "Nächste Aufgabe",
},
} as const;
type Language = keyof typeof translations;
type TranslationKey = keyof (typeof translations)["en"];
const envLanguage = String(import.meta.env.VITE_FRONTEND_LANG || "en")
.trim()
.toLowerCase();
export const language: Language = envLanguage.startsWith("de") ? "de" : "en";
export const t = (
key: TranslationKey,
vars?: Record<string, string | number>
): string => {
let value: string = translations[language][key] || translations.en[key];
if (!vars) {
return value;
}
Object.entries(vars).forEach(([name, raw]) => {
value = value.replaceAll(`{${name}}`, String(raw));
});
return value;
};
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import { BrowserRouter } from "react-router-dom";
import App from './pages/App.tsx' import "./index.css";
import App from "./pages/App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <BrowserRouter>
</StrictMode>, <App />
) </BrowserRouter>
</StrictMode>
);
import { useEffect, useMemo, useState } from "react"; import { Navigate, Route, Routes } from "react-router-dom";
import "../styles/theme.css"; import { t } from "../i18n";
import ChatWindow from "../components/Chat/ChatWindow"; import ChatPage from "./ChatPage";
import CanvasDrawer from "../components/Canvas/CanvasDrawer"; import TaskSelectionPage from "./TaskSelectionPage";
import DocPanel from "../components/Retrieval/DocPanel"; import { TutorSessionProvider, useTutorSession } from "../state/tutorSession";
import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000"; function StartRoute() {
const { isTasksInitialized, isTaskModeEnabled } = useTutorSession();
const initialMessages: ChatMessage[] = []; if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>;
}
type ArchivedChatSummary = { return <Navigate to={isTaskModeEnabled ? "/select-task" : "/chat"} replace />;
chat_id: string; }
saved_at: string;
message_count: number;
preview: string;
};
type ArchivedChatDetail = {
chat_id: string;
saved_at: string;
history: ChatMessage[];
};
type ContextSource = {
source_id: {
chapter_title?: string | null;
section_title?: string | null;
subsection_title?: string | null;
title: string;
doc_type: string;
};
retrieved_as?: string | null;
source_type?: string | null;
score?: number;
markdown?: string | null;
};
const createSessionId = () =>
`session_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const sourceIdToUid = (source: ContextSource, index: number) => {
const parts = [
source.source_id.chapter_title,
source.source_id.section_title,
source.source_id.subsection_title,
source.source_id.title,
source.source_id.doc_type,
].filter(Boolean);
return parts.length ? parts.join("|") : `source_${index}`;
};
const sourceIdToKey = (sourceId: ContextSource["source_id"]) => {
const parts = [
sourceId.chapter_title ?? "",
sourceId.section_title ?? "",
sourceId.subsection_title ?? "",
sourceId.title ?? "",
sourceId.doc_type ?? "",
];
return parts.join("|");
};
const sourceIdToPath = (source: ContextSource) => {
const parts = [
source.source_id.chapter_title,
source.source_id.section_title,
source.source_id.subsection_title,
].filter(Boolean);
return parts.length ? parts.join(" / ") : null;
};
const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => ({
uid: sourceIdToUid(source, index),
sourceKey: sourceIdToKey(source.source_id),
doc_type: source.source_id.doc_type,
score: source.score,
metadata: {
section_title: source.source_id.section_title ?? null,
subsection_title: source.source_id.subsection_title ?? null,
title: source.source_id.title ?? null,
type: source.source_type ?? null,
path: sourceIdToPath(source),
},
markdown: source.markdown || "",
});
export default function App() { export default function App() {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [draft, setDraft] = useState("");
const [chatSessionId, setChatSessionId] = useState(createSessionId);
const [isArchiving, setIsArchiving] = useState(false);
const [archivedChats, setArchivedChats] = useState<ArchivedChatSummary[]>([]);
const [selectedArchiveId, setSelectedArchiveId] = useState("");
const [archiveError, setArchiveError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isCanvasVisible, setIsCanvasVisible] = useState(false);
const [directChildren, setDirectChildren] = useState<RetrievedDoc[]>([]);
const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]);
const [subsections, setSubsections] = useState<RetrievedDoc[]>([]);
const [sections, setSections] = useState<RetrievedDoc[]>([]);
const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState<string | null>(null);
const [canvasStatus, setCanvasStatus] = useState<{
kind: "info" | "error" | "ok";
message: string;
} | null>(null);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
const bySlug: Record<string, RetrievedDoc> = {};
const allDocs = [
...directChildren,
...indirectChildren,
...subsections,
...sections,
];
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
allDocs.forEach((doc) => {
if (doc.sourceKey) {
bySourceKey[doc.sourceKey] = doc;
}
const candidates = [
doc.metadata.title,
doc.metadata.subsection_title,
doc.metadata.section_title,
doc.metadata.path,
]
.filter(Boolean)
.map((item) => String(item));
candidates.forEach((candidate) => {
const slug = slugify(candidate);
if (slug && !bySlug[slug]) {
bySlug[slug] = doc;
}
});
});
return { bySourceKey, bySlug };
}, [directChildren, indirectChildren, subsections, sections]);
const handleSend = async () => {
const trimmed = draft.trim();
if (!trimmed) {
return;
}
const userMessage: ChatMessage = {
id: `m-${Date.now()}-user`,
role: "user",
text: trimmed,
};
setMessages((prev) => [...prev, userMessage]);
setDraft("");
setRetrievalLoading(true);
setRetrievalError(null);
try {
const response = await fetch(`${backendUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [...messages, userMessage].map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
if (!response.ok) {
throw new Error(`Chat failed: ${response.status}`);
}
const payload: { reply?: string } = await response.json();
if (payload.reply) {
setMessages((prev) => [
...prev,
{
id: `m-${Date.now()}-assistant`,
role: "assistant",
text: payload.reply,
},
]);
}
} catch (error) {
setMessages((prev) => [
...prev,
{
id: `m-${Date.now()}-assistant`,
role: "assistant",
text: "Chat request failed. Check the API logs.",
},
]);
setRetrievalError("Retrieval failed. Check the API logs.");
setRetrievalLoading(false);
void error;
return;
}
try {
const contextResponse = await fetch(`${backendUrl}/api/context/retrieval`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [...messages, userMessage].map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
if (!contextResponse.ok) {
throw new Error(`Retrieval failed: ${contextResponse.status}`);
}
const sources: ContextSource[] = await contextResponse.json();
const nextDirect: RetrievedDoc[] = [];
const nextIndirect: RetrievedDoc[] = [];
const nextSubsections: RetrievedDoc[] = [];
const nextSections: RetrievedDoc[] = [];
sources.forEach((source, index) => {
const doc = toRetrievedDoc(source, index);
switch (source.retrieved_as) {
case "children_direct":
nextDirect.push(doc);
break;
case "children_expanded":
nextIndirect.push(doc);
break;
case "subsections":
nextSubsections.push(doc);
break;
case "sections":
nextSections.push(doc);
break;
case "neighbors":
nextIndirect.push(doc);
break;
default:
nextIndirect.push(doc);
}
});
setDirectChildren(nextDirect);
setIndirectChildren(nextIndirect);
setSubsections(nextSubsections);
setSections(nextSections);
} catch (error) {
setRetrievalError("Retrieval failed. Check the API logs.");
void error;
} finally {
setRetrievalLoading(false);
}
};
const handleToggleCanvas = () => {
setIsCanvasVisible((prev) => !prev);
};
const resetChatState = () => {
setMessages(initialMessages);
setDraft("");
setDirectChildren([]);
setIndirectChildren([]);
setSubsections([]);
setSections([]);
setRetrievalLoading(false);
setRetrievalError(null);
setCanvasStatus(null);
setIsCanvasVisible(false);
};
const loadArchives = async () => {
setArchiveError(null);
try {
const response = await fetch(
`${backendUrl}/api/chat/archives?limit=50`
);
if (!response.ok) {
throw new Error(`Archive list failed: ${response.status}`);
}
const payload: ArchivedChatSummary[] = await response.json();
setArchivedChats(payload);
if (payload.length && !selectedArchiveId) {
setSelectedArchiveId(payload[0].chat_id);
}
} catch (error) {
setArchiveError("Failed to load saved chats.");
void error;
}
};
const handleLoadArchive = async (chatId?: string) => {
const targetId = chatId || selectedArchiveId;
if (!targetId) {
return;
}
setArchiveError(null);
try {
const response = await fetch(
`${backendUrl}/api/chat/archive/${targetId}`
);
if (!response.ok) {
throw new Error(`Archive load failed: ${response.status}`);
}
const payload: ArchivedChatDetail = await response.json();
resetChatState();
setMessages(payload.history || []);
setChatSessionId(payload.chat_id);
} catch (error) {
setArchiveError("Failed to load selected chat.");
void error;
}
};
const handleNewChat = async () => {
if (messages.length) {
setIsArchiving(true);
try {
await fetch(`${backendUrl}/api/chat/archive`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: messages.map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
} catch (error) {
void error;
} finally {
setIsArchiving(false);
}
}
resetChatState();
setChatSessionId(createSessionId());
await loadArchives();
};
const handleOpenSidebar = () => {
setIsSidebarOpen(true);
void loadArchives();
};
const handleCanvasSave = async (dataUrl: string) => {
setCanvasStatus({
kind: "info",
message: "Saving drawing and converting to LaTeX...",
});
try {
const response = await fetch(`${backendUrl}/api/canvas/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_url: dataUrl, filename_hint: "canvas" }),
});
if (!response.ok) {
let message = `Save failed: ${response.status}`;
try {
const errorPayload: { detail?: string } = await response.json();
if (errorPayload.detail) {
message = errorPayload.detail;
}
} catch (error) {
void error;
}
setCanvasStatus({ kind: "error", message });
return;
}
const payload: { status?: string; latex?: string } = await response.json();
if (payload.status && payload.status !== "ok") {
setCanvasStatus({
kind: "error",
message: payload.latex || "Canvas save failed.",
});
return;
}
if (payload.latex) {
setDraft((prev) =>
prev ? `${prev} ${payload.latex}` : payload.latex
);
setCanvasStatus({ kind: "ok", message: "Canvas saved." });
setIsCanvasVisible(false);
}
} catch (error) {
setCanvasStatus({
kind: "error",
message: "Canvas save failed. Check the API logs.",
});
void error;
}
};
const handleCiteDoc = (docId: string) => {
setDraft((prev) => (prev ? `${prev} [${docId}]` : `[${docId}]`));
};
const handleInspectDoc = (doc: RetrievedDoc) => {
const title =
doc.metadata.title ||
doc.metadata.subsection_title ||
doc.metadata.section_title ||
doc.metadata.path ||
"Document";
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<style>
body { font-family: "Space Grotesk", sans-serif; margin: 24px; color: #1d1b16; }
h1 { font-size: 22px; margin-bottom: 12px; }
.meta { color: #6f675d; font-size: 12px; margin-bottom: 16px; }
.content { max-width: 900px; }
pre, code { background: #f6f0e4; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1 id="title"></h1>
<div class="meta" id="meta"></div>
<div class="content" id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const markdown = ${JSON.stringify(doc.markdown)};
const title = ${JSON.stringify(title)};
const path = ${JSON.stringify(doc.metadata.path || "")};
document.getElementById("title").textContent = title;
document.getElementById("meta").textContent = path;
document.getElementById("content").innerHTML = marked.parse(markdown);
</script>
<script>
window.MathJax = {
tex: {
inlineMath: [["$", "$"], ["\\\\(", "\\\\)"]],
displayMath: [["$$", "$$"], ["\\\\[", "\\\\]"]],
}
};
</script>
<script
id="mathjax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
></script>
</body>
</html>`;
const tab = window.open("", "_blank");
if (!tab) {
return;
}
tab.document.open();
tab.document.write(html);
tab.document.close();
};
return ( return (
<div className="app-shell"> <TutorSessionProvider>
<header className="app-header"> <Routes>
<button <Route path="/" element={<StartRoute />} />
type="button" <Route path="/select-task" element={<TaskSelectionPage />} />
className="btn sidebar-toggle" <Route path="/chat" element={<ChatPage />} />
onClick={handleOpenSidebar} <Route path="*" element={<StartRoute />} />
> </Routes>
Chats </TutorSessionProvider>
</button>
<div className="brand">
<span className="brand-mark">SUM</span>
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">Prototype Workspace</div>
</div>
</div>
<div className="header-meta">
<span className="pill">Postgres + pgvector</span>
<span className="pill">Python FastAPI</span>
<span className="pill">Mathpix</span>
</div>
</header>
<main className="app-main">
<section className="chat-column">
<ChatWindow
messages={messages}
draft={draft}
onDraftChange={setDraft}
onSend={handleSend}
onToggleCanvas={handleToggleCanvas}
onInspectDoc={handleInspectDoc}
docIndex={docIndexes.bySourceKey}
docSlugIndex={docIndexes.bySlug}
/>
{isCanvasVisible ? (
<CanvasDrawer
isVisible={isCanvasVisible}
onToggle={handleToggleCanvas}
onSave={handleCanvasSave}
statusMessage={canvasStatus?.message}
statusKind={canvasStatus?.kind}
/>
) : null}
</section>
<aside className="retrieval-column">
<DocPanel
directChildren={directChildren}
indirectChildren={indirectChildren}
subsections={subsections}
sections={sections}
isLoading={retrievalLoading}
error={retrievalError}
onCiteDoc={handleCiteDoc}
onInspectDoc={handleInspectDoc}
/>
</aside>
</main>
<div className={`sidebar ${isSidebarOpen ? "active" : ""}`}>
<div className="sd-header">
<h4 className="sd-title">Saved Chats</h4>
<button
type="button"
className="sidebar-button"
onClick={() => setIsSidebarOpen(false)}
>
X
</button>
</div>
<div className="sd-body">
<ul className="sd-list">
<li>
<button
type="button"
className="sd-link"
onClick={handleNewChat}
disabled={isArchiving}
>
{isArchiving ? "Saving..." : "New Chat"}
</button>
</li>
<li>
<div className="sd-separator" />
</li>
{archivedChats.length ? (
archivedChats.map((item) => (
<li key={item.chat_id}>
<button
type="button"
className={`sd-link ${
selectedArchiveId === item.chat_id ? "active" : ""
}`}
onClick={() => {
setSelectedArchiveId(item.chat_id);
void handleLoadArchive(item.chat_id);
}}
>
{item.preview || item.chat_id} ({item.message_count})
</button>
</li>
))
) : (
<li>
<div className="sd-empty">No saved chats yet.</div>
</li>
)}
</ul>
{archiveError ? (
<div className="chat-archive-error">{archiveError}</div>
) : null}
</div>
</div>
<div
className={`sidebar-overlay ${isSidebarOpen ? "active" : ""}`}
onClick={() => setIsSidebarOpen(false)}
/>
</div>
); );
} }
This diff is collapsed.
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/theme.css";
import { t } from "../i18n";
import { selectTask } from "../api/taskApi";
import { useTutorSession } from "../state/tutorSession";
export default function TaskSelectionPage() {
const navigate = useNavigate();
const {
chatSessionId,
isTaskModeEnabled,
isTasksInitialized,
selectedTaskRef,
selectedTask,
taskFileOptions,
taskOptions,
tasksError,
setTaskFile,
setTaskId,
lockTask,
unlockTask,
} = useTutorSession();
const taskDisplayRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isTasksInitialized) {
return;
}
if (!isTaskModeEnabled) {
navigate("/chat", { replace: true });
return;
}
unlockTask();
}, [isTaskModeEnabled, isTasksInitialized, navigate, unlockTask]);
useEffect(() => {
if (!selectedTask?.fullText || !taskDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([taskDisplayRef.current]).catch(() => undefined);
}, [selectedTask?.fullText]);
const handleSolveWithTutor = async () => {
if (!selectedTaskRef) {
return;
}
try {
await selectTask({
draft: chatSessionId,
fileId: selectedTaskRef.fileId,
taskId: selectedTaskRef.taskId,
});
lockTask();
navigate("/chat");
} catch (error) {
void error;
}
};
if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>;
}
return (
<div className="app-shell">
<header className="app-header">
<div className="brand">
<span className="brand-mark">SUM</span>
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">{t("taskSelectionSubtitle")}</div>
</div>
</div>
</header>
<main className="task-select-main">
<section className="task-select-card">
<div className="task-select-header">
<h2 className="task-select-title">{t("taskSelectionTitle")}</h2>
</div>
<div className="task-select-controls">
<label className="task-select-label" htmlFor="task-file-select">
{t("taskFile")}
</label>
<select
id="task-file-select"
className="task-select"
value={selectedTaskRef?.fileId || ""}
onChange={(event) => setTaskFile(event.target.value)}
disabled={!taskFileOptions.length}
>
{taskFileOptions.length ? (
taskFileOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
<label className="task-select-label" htmlFor="task-id-select">
{t("taskId")}
</label>
<select
id="task-id-select"
className="task-select"
value={selectedTaskRef?.taskId || ""}
onChange={(event) => setTaskId(event.target.value)}
disabled={!taskOptions.length}
>
{taskOptions.length ? (
taskOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
</div>
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTask?.fullText || t("noTaskSelected")}
</div>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
<button
type="button"
className="btn primary task-solve-btn"
onClick={handleSolveWithTutor}
disabled={!selectedTaskRef}
>
{t("solveWithTutor")}
</button>
</section>
</main>
</div>
);
}
/* eslint-disable react-refresh/only-export-components */
import {
useCallback,
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from "react";
import { t } from "../i18n";
import { fetchTasks, type SelectedTaskRef, type TaskFile } from "../api/taskApi";
export type SelectOption = {
value: string;
label: string;
};
export type SelectedTask = SelectedTaskRef & {
title: string;
fullText: string;
};
export type TaskSelectionState = {
taskFiles: TaskFile[];
selectedTaskRef: SelectedTaskRef | null;
selectedTask: SelectedTask | null;
selectedTaskFile: TaskFile | null;
taskFileOptions: SelectOption[];
taskOptions: SelectOption[];
tasksError: string | null;
isTaskModeEnabled: boolean;
isTasksInitialized: boolean;
taskLocked: boolean;
};
export type TutorSessionState = TaskSelectionState & {
chatSessionId: string;
setChatSessionId: (value: string) => void;
initTasks: () => Promise<void>;
setTaskRef: (value: SelectedTaskRef | null) => void;
setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void;
lockTask: () => void;
unlockTask: () => void;
resetForNewChat: () => void;
};
const TutorSessionContext = createContext<TutorSessionState | null>(null);
export const createSessionId = () =>
`session_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string =>
tasks.find((task) => task.task_id === "01")?.task_id || tasks[0]?.task_id || "";
export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
const [isTaskModeEnabled, setIsTaskModeEnabled] = useState(false);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false);
const selectedTask = useMemo<SelectedTask | null>(() => {
if (!selectedTaskRef) {
return null;
}
const file = taskFiles.find((item) => item.file_id === selectedTaskRef.fileId);
if (!file) {
return null;
}
const task = file.tasks.find((item) => item.task_id === selectedTaskRef.taskId);
if (!task) {
return null;
}
return {
fileId: file.file_id,
taskId: task.task_id,
title: file.title,
fullText: task.full_text,
};
}, [selectedTaskRef, taskFiles]);
const selectedTaskFile = useMemo(
() => taskFiles.find((file) => file.file_id === selectedTaskRef?.fileId) || null,
[selectedTaskRef?.fileId, taskFiles]
);
const taskFileOptions = useMemo(
() =>
taskFiles.map((file) => ({
value: file.file_id,
label: file.title || file.file_id,
})),
[taskFiles]
);
const taskOptions = useMemo(
() =>
(selectedTaskFile?.tasks || []).map((task) => ({
value: task.task_id,
label: task.task_id,
})),
[selectedTaskFile]
);
const initTasks = useCallback(async () => {
setTasksError(null);
try {
const payload = await fetchTasks();
const enabled = Boolean(payload.enabled);
const files = payload.task_files || [];
setIsTaskModeEnabled(enabled);
setTaskFiles(files);
if (!enabled) {
setSelectedTaskRef(null);
} else {
setSelectedTaskRef((prev) => {
if (prev) {
const file = files.find((item) => item.file_id === prev.fileId);
if (file && file.tasks.some((task) => task.task_id === prev.taskId)) {
return prev;
}
}
const firstFile = files[0];
if (!firstFile) {
return null;
}
const defaultTaskId = getDefaultTaskId(firstFile.tasks || []);
if (!defaultTaskId) {
return null;
}
return { fileId: firstFile.file_id, taskId: defaultTaskId };
});
}
} catch (error) {
setTasksError(t("failedLoadTasks"));
setIsTaskModeEnabled(false);
setTaskFiles([]);
setSelectedTaskRef(null);
void error;
} finally {
setIsTasksInitialized(true);
}
}, []);
useEffect(() => {
void initTasks();
}, [initTasks]);
const setTaskFile = useCallback((fileId: string) => {
if (!fileId) {
return;
}
const file = taskFiles.find((item) => item.file_id === fileId);
if (!file) {
return;
}
const defaultTaskId = getDefaultTaskId(file.tasks || []);
if (!defaultTaskId) {
return;
}
setSelectedTaskRef({ fileId: file.file_id, taskId: defaultTaskId });
}, [taskFiles]);
const setTaskId = useCallback((taskId: string) => {
if (!taskId || !selectedTaskRef?.fileId) {
return;
}
setSelectedTaskRef({ fileId: selectedTaskRef.fileId, taskId });
}, [selectedTaskRef]);
const lockTask = useCallback(() => {
setTaskLocked(true);
}, []);
const unlockTask = useCallback(() => {
setTaskLocked(false);
}, []);
const resetForNewChat = useCallback(() => {
setChatSessionId(createSessionId());
setSelectedTaskRef(null);
setTaskLocked(false);
}, []);
const value: TutorSessionState = {
chatSessionId,
setChatSessionId,
initTasks,
taskFiles,
selectedTaskRef,
selectedTask,
selectedTaskFile,
taskFileOptions,
taskOptions,
setTaskRef: setSelectedTaskRef,
setTaskFile,
setTaskId,
tasksError,
isTaskModeEnabled,
isTasksInitialized,
taskLocked,
lockTask,
unlockTask,
resetForNewChat,
};
return <TutorSessionContext.Provider value={value}>{children}</TutorSessionContext.Provider>;
}
export function useTutorSession(): TutorSessionState {
const context = useContext(TutorSessionContext);
if (!context) {
throw new Error("useTutorSession must be used within TutorSessionProvider");
}
return context;
}
This diff is collapsed.
...@@ -4,4 +4,21 @@ import react from '@vitejs/plugin-react' ...@@ -4,4 +4,21 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true,
sourcemap: false,
target: "es2020",
},
server: {
host: "0.0.0.0",
port: 5173,
allowedHosts: ["vm11.fkc.hft-stuttgart.de"],
proxy: {
"/api": {
target: process.env.VITE_PROXY_TARGET || "http://localhost:8000",
changeOrigin: true,
},
},
},
}) })
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment