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

erster entwurf mit seperiertem Auswahlfenster

parent 6e95391a
from __future__ import annotations
from typing import List, Optional
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 pydantic import BaseModel, Field
from app.deterministic_services import session_store
import app.config as config
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":
from app.deterministic_services.orchestrators import orchestrator_task as orchestrator
else:
......@@ -50,10 +50,16 @@ class ChatArchiveSummary(BaseModel):
preview: str
class SelectedTaskRef(BaseModel):
file_id: str
task_id: str
class ChatArchiveDetail(BaseModel):
chat_id: str
saved_at: str
history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None
@router.post("/api/chat", response_model=ChatResponse)
......@@ -80,8 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Chat request failed")
raise HTTPException(
status_code=502, detail="chat provider failed") from exc
raise HTTPException(status_code=502, detail="chat provider failed") from exc
return ChatResponse(reply=reply, sources=sources)
......@@ -92,8 +97,7 @@ def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSumma
return session_store.list_archives(limit=limit)
except Exception as exc:
logger.exception("Chat archive list failed")
raise HTTPException(
status_code=502, detail="chat archive list failed") from exc
raise HTTPException(status_code=502, detail="chat archive list failed") from exc
@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:
record = session_store.load_archive(chat_id)
except Exception as exc:
logger.exception("Chat archive load failed")
raise HTTPException(
status_code=502, detail="chat archive load failed") from exc
raise HTTPException(status_code=502, detail="chat archive load failed") from exc
if not record:
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(
chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"])
for item in record["history"]],
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
selected_task=selected_task,
)
......@@ -128,7 +139,6 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
)
except Exception as exc:
logger.exception("Chat archive failed")
raise HTTPException(
status_code=502, detail="chat archive failed") from exc
raise HTTPException(status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id)
......@@ -2,10 +2,10 @@ from __future__ import annotations
import json
import os
from collections import deque
from datetime import datetime
from threading import Lock
from typing import Any
from collections import deque
from app.deterministic_services import context_store
......@@ -18,6 +18,14 @@ def _utc_now() -> str:
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:
chat_id = context_store.get_chat_id(messages, draft=draft)
sheet = context_store.load_sheet(chat_id)
......@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
"retrieval_contexts": sheet.get("retrieval_contexts", []),
"math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []),
"selected_task": _extract_selected_task(sheet),
}
os.makedirs(_LOG_DIR, exist_ok=True)
......@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
continue
if record.get("chat_id") == chat_id:
history = [
{"role": entry.get("role", "user"),
"text": entry.get("content", "")}
{"role": entry.get("role", "user"), "text": entry.get("content", "")}
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 {
"chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""),
"history": history,
"selected_task": selected_task,
}
return None
......@@ -11,7 +11,8 @@
"iconoir-react": "^7.11.0",
"react": "^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": {
"@eslint/js": "^9.39.1",
......@@ -1234,13 +1235,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
......@@ -1356,9 +1357,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
......@@ -1610,6 +1611,19 @@
"dev": true,
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
......@@ -3282,9 +3296,9 @@
"license": "MIT"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
......@@ -3536,6 +3550,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
......@@ -3580,6 +3595,44 @@
"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": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
......@@ -3679,6 +3732,12 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
......
......@@ -13,7 +13,8 @@
"iconoir-react": "^7.11.0",
"react": "^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": {
"@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";
type SelectOption = {
......@@ -7,21 +7,25 @@ type SelectOption = {
};
type TaskPanelProps = {
selectedFileId: string;
fileOptions: SelectOption[];
selectedTaskId: string;
taskOptions: SelectOption[];
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;
onTaskFileChange?: (fileId: string) => void;
onTaskChange?: (taskId: string) => void;
};
export default function TaskPanel({
selectedFileId,
fileOptions,
selectedTaskId,
taskOptions,
readOnly = false,
selectedFileLabel,
selectedFileId = "",
fileOptions = [],
selectedTaskId = "",
taskOptions = [],
selectedTaskText,
tasksError,
onTaskFileChange,
......@@ -44,41 +48,53 @@ export default function TaskPanel({
<section className="task-panel">
<div className="task-panel-header">
<div className="task-panel-title">{t("task")}</div>
<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>
{readOnly ? (
<div className="task-panel-lock">{t("taskLocked")}</div>
) : (
<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>
......
const translations = {
const translations = {
en: {
chats: "Chats",
prototypeWorkspace: "Prototype Workspace",
......@@ -45,6 +45,12 @@ const translations = {
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",
taskLocked: "Task locked",
},
de: {
chats: "Chats",
......@@ -54,7 +60,7 @@ const translations = {
canvas: "Canvas",
retrievedNotes: "Quellen",
typeQuestionOrLatex:
"Gebe hier deine Frage ein. Nutze $ $ für mathematische Eingaben...",
"Gebe hier deine Frage ein. Nutze $ $ fuer mathematische Eingaben...",
noResultsYet: "bisher keine Quellen",
eraser: "Radierer",
pen: "Stift",
......@@ -77,7 +83,7 @@ const translations = {
roleUser: "nutzer",
roleAssistant: "assistent",
noTasksAvailable: "Keine Aufgaben verfuegbar",
noTaskSelected: "Keine Aufgabe ausgewaehlt.",
noTaskSelected: "Keine Aufgabe ausgewählt.",
savedChats: "Gespeicherte Chats",
saving: "Speichere...",
noSavedChatsYet: "Noch keine gespeicherten Chats.",
......@@ -88,7 +94,7 @@ const translations = {
retrievalFailed:
"Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
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:
"Zeichnung wird gespeichert und in LaTeX konvertiert...",
saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}).",
......@@ -96,6 +102,12 @@ const translations = {
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",
taskLocked: "Aufgabe fixiert",
},
} as const;
......
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './pages/App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./pages/App.tsx";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
<BrowserRouter>
<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 {
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