Commit 4cb2901e authored by Kantz's avatar Kantz
Browse files

erster entwurf mit seperiertem Auswahlfenster

parent 6e95391a
from __future__ import annotations from __future__ import annotations
from typing import List, Optional
import logging import logging
from typing import List, Optional
import app.config as config
from app.deterministic_services import session_store
from fastapi import APIRouter, HTTPException, Path, Query from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.deterministic_services import session_store
import app.config as config
if config.get_orchestrator() == "tutor": if config.get_orchestrator() == "tutor":
from app.deterministic_services.orchestrators import orchestrator_tutor as orchestrator from app.deterministic_services.orchestrators import (
orchestrator_tutor as orchestrator,
)
elif config.get_orchestrator() == "task": elif config.get_orchestrator() == "task":
from app.deterministic_services.orchestrators import orchestrator_task as orchestrator from app.deterministic_services.orchestrators import orchestrator_task as orchestrator
else: else:
...@@ -50,10 +50,16 @@ class ChatArchiveSummary(BaseModel): ...@@ -50,10 +50,16 @@ class ChatArchiveSummary(BaseModel):
preview: str preview: str
class SelectedTaskRef(BaseModel):
file_id: str
task_id: str
class ChatArchiveDetail(BaseModel): class ChatArchiveDetail(BaseModel):
chat_id: str chat_id: str
saved_at: str saved_at: str
history: List[ChatMessage] history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None
@router.post("/api/chat", response_model=ChatResponse) @router.post("/api/chat", response_model=ChatResponse)
...@@ -80,8 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse: ...@@ -80,8 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
logger.exception("Chat request failed") logger.exception("Chat request failed")
raise HTTPException( raise HTTPException(status_code=502, detail="chat provider failed") from exc
status_code=502, detail="chat provider failed") from exc
return ChatResponse(reply=reply, sources=sources) return ChatResponse(reply=reply, sources=sources)
...@@ -92,8 +97,7 @@ def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSumma ...@@ -92,8 +97,7 @@ def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSumma
return session_store.list_archives(limit=limit) return session_store.list_archives(limit=limit)
except Exception as exc: except Exception as exc:
logger.exception("Chat archive list failed") logger.exception("Chat archive list failed")
raise HTTPException( raise HTTPException(status_code=502, detail="chat archive list failed") from exc
status_code=502, detail="chat archive list failed") from exc
@router.get("/api/chat/archive/{chat_id}", response_model=ChatArchiveDetail) @router.get("/api/chat/archive/{chat_id}", response_model=ChatArchiveDetail)
...@@ -102,17 +106,24 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail: ...@@ -102,17 +106,24 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
record = session_store.load_archive(chat_id) record = session_store.load_archive(chat_id)
except Exception as exc: except Exception as exc:
logger.exception("Chat archive load failed") logger.exception("Chat archive load failed")
raise HTTPException( raise HTTPException(status_code=502, detail="chat archive load failed") from exc
status_code=502, detail="chat archive load failed") from exc
if not record: if not record:
raise HTTPException(status_code=404, detail="chat archive not found") raise HTTPException(status_code=404, detail="chat archive not found")
selected_task_raw = record.get("selected_task")
selected_task: Optional[SelectedTaskRef] = None
if isinstance(selected_task_raw, dict):
file_id = str(selected_task_raw.get("file_id", "")).strip()
task_id = str(selected_task_raw.get("task_id", "")).strip()
if file_id and task_id:
selected_task = SelectedTaskRef(file_id=file_id, task_id=task_id)
return ChatArchiveDetail( return ChatArchiveDetail(
chat_id=record["chat_id"], chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""), saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
for item in record["history"]], selected_task=selected_task,
) )
...@@ -128,7 +139,6 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse: ...@@ -128,7 +139,6 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
) )
except Exception as exc: except Exception as exc:
logger.exception("Chat archive failed") logger.exception("Chat archive failed")
raise HTTPException( raise HTTPException(status_code=502, detail="chat archive failed") from exc
status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id) return ChatArchiveResponse(status="ok", chat_id=chat_id)
...@@ -2,10 +2,10 @@ from __future__ import annotations ...@@ -2,10 +2,10 @@ from __future__ import annotations
import json import json
import os import os
from collections import deque
from datetime import datetime from datetime import datetime
from threading import Lock from threading import Lock
from typing import Any from typing import Any
from collections import deque
from app.deterministic_services import context_store from app.deterministic_services import context_store
...@@ -18,6 +18,14 @@ def _utc_now() -> str: ...@@ -18,6 +18,14 @@ def _utc_now() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
if not file_id or not task_id:
return None
return {"file_id": file_id, "task_id": task_id}
def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str: def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str:
chat_id = context_store.get_chat_id(messages, draft=draft) chat_id = context_store.get_chat_id(messages, draft=draft)
sheet = context_store.load_sheet(chat_id) sheet = context_store.load_sheet(chat_id)
...@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st ...@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
"retrieval_contexts": sheet.get("retrieval_contexts", []), "retrieval_contexts": sheet.get("retrieval_contexts", []),
"math_solutions": sheet.get("math_solutions", []), "math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []), "sources": sheet.get("sources", []),
"selected_task": _extract_selected_task(sheet),
} }
os.makedirs(_LOG_DIR, exist_ok=True) os.makedirs(_LOG_DIR, exist_ok=True)
...@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None: ...@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
continue continue
if record.get("chat_id") == chat_id: if record.get("chat_id") == chat_id:
history = [ history = [
{"role": entry.get("role", "user"), {"role": entry.get("role", "user"), "text": entry.get("content", "")}
"text": entry.get("content", "")}
for entry in record.get("history", []) for entry in record.get("history", [])
] ]
selected_task_raw = record.get("selected_task")
selected_task: dict[str, str] | None = None
if isinstance(selected_task_raw, dict):
file_id = str(selected_task_raw.get("file_id", "")).strip()
task_id = str(selected_task_raw.get("task_id", "")).strip()
if file_id and task_id:
selected_task = {"file_id": file_id, "task_id": task_id}
return { return {
"chat_id": record.get("chat_id", chat_id), "chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""), "saved_at": record.get("saved_at", ""),
"history": history, "history": history,
"selected_task": selected_task,
} }
return None return None
...@@ -11,7 +11,8 @@ ...@@ -11,7 +11,8 @@
"iconoir-react": "^7.11.0", "iconoir-react": "^7.11.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0" "react-markdown": "^10.1.0",
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
...@@ -1234,13 +1235,13 @@ ...@@ -1234,13 +1235,13 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
...@@ -1356,9 +1357,9 @@ ...@@ -1356,9 +1357,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
...@@ -1610,6 +1611,19 @@ ...@@ -1610,6 +1611,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
...@@ -3282,9 +3296,9 @@ ...@@ -3282,9 +3296,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
...@@ -3536,6 +3550,7 @@ ...@@ -3536,6 +3550,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
...@@ -3580,6 +3595,44 @@ ...@@ -3580,6 +3595,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/remark-parse": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
...@@ -3679,6 +3732,12 @@ ...@@ -3679,6 +3732,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
......
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
"iconoir-react": "^7.11.0", "iconoir-react": "^7.11.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0" "react-markdown": "^10.1.0",
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
......
export type TaskItem = {
task_id: string;
statement: string;
full_text: string;
};
export type TaskFile = {
file_id: string;
title: string;
intro: string;
tasks: TaskItem[];
};
export type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
};
export type SelectedTaskRef = {
fileId: string;
taskId: string;
};
export type SelectTaskResponse = {
status: string;
file_id: string;
task_id: string;
};
export async function fetchTasks(): Promise<TasksResponse> {
const response = await fetch("/api/tasks");
if (!response.ok) {
throw new Error(`Tasks failed: ${response.status}`);
}
return response.json();
}
export async function selectTask(input: {
draft: string;
fileId: string;
taskId: string;
}): Promise<SelectTaskResponse> {
const response = await fetch("/api/tasks/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: input.draft,
file_id: input.fileId,
task_id: input.taskId,
}),
});
if (!response.ok) {
throw new Error(`Task selection failed: ${response.status}`);
}
return response.json();
}
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { t } from "../../i18n"; import { t } from "../../i18n";
type SelectOption = { type SelectOption = {
...@@ -7,21 +7,25 @@ type SelectOption = { ...@@ -7,21 +7,25 @@ type SelectOption = {
}; };
type TaskPanelProps = { type TaskPanelProps = {
selectedFileId: string; readOnly?: boolean;
fileOptions: SelectOption[]; selectedFileLabel?: string;
selectedTaskId: string; selectedFileId?: string;
taskOptions: SelectOption[]; fileOptions?: SelectOption[];
selectedTaskId?: string;
taskOptions?: SelectOption[];
selectedTaskText: string; selectedTaskText: string;
tasksError?: string | null; tasksError?: string | null;
onTaskFileChange: (fileId: string) => void; onTaskFileChange?: (fileId: string) => void;
onTaskChange: (taskId: string) => void; onTaskChange?: (taskId: string) => void;
}; };
export default function TaskPanel({ export default function TaskPanel({
selectedFileId, readOnly = false,
fileOptions, selectedFileLabel,
selectedTaskId, selectedFileId = "",
taskOptions, fileOptions = [],
selectedTaskId = "",
taskOptions = [],
selectedTaskText, selectedTaskText,
tasksError, tasksError,
onTaskFileChange, onTaskFileChange,
...@@ -44,41 +48,53 @@ export default function TaskPanel({ ...@@ -44,41 +48,53 @@ export default function TaskPanel({
<section className="task-panel"> <section className="task-panel">
<div className="task-panel-header"> <div className="task-panel-header">
<div className="task-panel-title">{t("task")}</div> <div className="task-panel-title">{t("task")}</div>
<div className="task-panel-controls"> {readOnly ? (
<select <div className="task-panel-lock">{t("taskLocked")}</div>
className="task-select" ) : (
value={selectedFileId} <div className="task-panel-controls">
onChange={(event) => onTaskFileChange(event.target.value)} <select
disabled={!fileOptions.length} className="task-select"
> value={selectedFileId}
{fileOptions.length ? ( onChange={(event) => onTaskFileChange?.(event.target.value)}
fileOptions.map((option) => ( disabled={!fileOptions.length}
<option key={option.value} value={option.value}> >
{option.label} {fileOptions.length ? (
</option> fileOptions.map((option) => (
)) <option key={option.value} value={option.value}>
) : ( {option.label}
<option value="">{t("noTasksAvailable")}</option> </option>
)} ))
</select> ) : (
<select <option value="">{t("noTasksAvailable")}</option>
className="task-select" )}
value={selectedTaskId} </select>
onChange={(event) => onTaskChange(event.target.value)} <select
disabled={!taskOptions.length} className="task-select"
> value={selectedTaskId}
{taskOptions.length ? ( onChange={(event) => onTaskChange?.(event.target.value)}
taskOptions.map((option) => ( disabled={!taskOptions.length}
<option key={option.value} value={option.value}> >
{option.label} {taskOptions.length ? (
</option> taskOptions.map((option) => (
)) <option key={option.value} value={option.value}>
) : ( {option.label}
<option value="">{t("noTasksAvailable")}</option> </option>
)} ))
</select> ) : (
</div> <option value="">{t("noTasksAvailable")}</option>
)}
</select>
</div>
)}
</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}> <div className="task-panel-content" ref={taskDisplayRef}>
{selectedTaskText || t("noTaskSelected")} {selectedTaskText || t("noTaskSelected")}
</div> </div>
......
const translations = { const translations = {
en: { en: {
chats: "Chats", chats: "Chats",
prototypeWorkspace: "Prototype Workspace", prototypeWorkspace: "Prototype Workspace",
...@@ -45,6 +45,12 @@ const translations = { ...@@ -45,6 +45,12 @@ const translations = {
canvasSaved: "Canvas saved.", canvasSaved: "Canvas saved.",
canvasSaveFailedCheckLogs: canvasSaveFailedCheckLogs:
"Saving the canvas failed. Please check backend logs.", "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",
taskLocked: "Task locked",
}, },
de: { de: {
chats: "Chats", chats: "Chats",
...@@ -54,7 +60,7 @@ const translations = { ...@@ -54,7 +60,7 @@ const translations = {
canvas: "Canvas", canvas: "Canvas",
retrievedNotes: "Quellen", retrievedNotes: "Quellen",
typeQuestionOrLatex: typeQuestionOrLatex:
"Gebe hier deine Frage ein. Nutze $ $ für mathematische Eingaben...", "Gebe hier deine Frage ein. Nutze $ $ fuer mathematische Eingaben...",
noResultsYet: "bisher keine Quellen", noResultsYet: "bisher keine Quellen",
eraser: "Radierer", eraser: "Radierer",
pen: "Stift", pen: "Stift",
...@@ -77,7 +83,7 @@ const translations = { ...@@ -77,7 +83,7 @@ const translations = {
roleUser: "nutzer", roleUser: "nutzer",
roleAssistant: "assistent", roleAssistant: "assistent",
noTasksAvailable: "Keine Aufgaben verfuegbar", noTasksAvailable: "Keine Aufgaben verfuegbar",
noTaskSelected: "Keine Aufgabe ausgewaehlt.", noTaskSelected: "Keine Aufgabe ausgewählt.",
savedChats: "Gespeicherte Chats", savedChats: "Gespeicherte Chats",
saving: "Speichere...", saving: "Speichere...",
noSavedChatsYet: "Noch keine gespeicherten Chats.", noSavedChatsYet: "Noch keine gespeicherten Chats.",
...@@ -88,7 +94,7 @@ const translations = { ...@@ -88,7 +94,7 @@ const translations = {
retrievalFailed: retrievalFailed:
"Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.", "Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
failedLoadSavedChats: "Gespeicherte Chats konnten nicht geladen werden.", failedLoadSavedChats: "Gespeicherte Chats konnten nicht geladen werden.",
failedLoadSelectedChat: "Der ausgewaehlte Chat konnte nicht geladen werden.", failedLoadSelectedChat: "Der ausgewählte Chat konnte nicht geladen werden.",
savingDrawingConverting: savingDrawingConverting:
"Zeichnung wird gespeichert und in LaTeX konvertiert...", "Zeichnung wird gespeichert und in LaTeX konvertiert...",
saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}).", saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}).",
...@@ -96,6 +102,12 @@ const translations = { ...@@ -96,6 +102,12 @@ const translations = {
canvasSaved: "Canvas gespeichert.", canvasSaved: "Canvas gespeichert.",
canvasSaveFailedCheckLogs: canvasSaveFailedCheckLogs:
"Speichern des Canvas fehlgeschlagen. Bitte pruefe die Backend-Logs.", "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",
taskLocked: "Aufgabe fixiert",
}, },
} as const; } as const;
......
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>
);
This diff is collapsed.
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;
}
...@@ -572,3 +572,76 @@ body { ...@@ -572,3 +572,76 @@ body {
margin-left: 0; margin-left: 0;
} }
} }
.app-loading {
min-height: 100vh;
display: grid;
place-items: center;
font-size: 14px;
color: #6f675d;
}
.task-select-main {
flex: 1;
display: grid;
place-items: center;
}
.task-select-card {
width: min(820px, 100%);
display: flex;
flex-direction: column;
gap: 12px;
background: #fef9f0;
border: 1px solid #d8d1c4;
border-radius: 16px;
padding: 18px;
}
.task-select-title {
margin: 0;
font-size: 20px;
}
.task-select-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.task-select-label {
font-size: 12px;
color: #6f675d;
}
.task-solve-btn {
align-self: flex-end;
}
.task-panel-lock {
margin-left: auto;
font-size: 12px;
color: #6f675d;
border: 1px solid #d8d1c4;
border-radius: 999px;
padding: 4px 8px;
background: #fff;
}
.task-panel-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #6f675d;
}
@media (max-width: 900px) {
.task-select-controls {
grid-template-columns: 1fr;
}
.task-solve-btn {
width: 100%;
}
}
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