Commit 653dd6a6 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!8
parents 962df6a9 9640e4df
...@@ -3,19 +3,18 @@ from app.deterministic_services import llm_client ...@@ -3,19 +3,18 @@ from app.deterministic_services import llm_client
HINT_SYSTEM_PROMPT = """ HINT_SYSTEM_PROMPT = """
Du bist ein didaktischer Mathe-Tutor." Du bist ein didaktischer Mathe-Tutor. Du beantwortest die mathematischen Fragen des Benutzers. Wenn ein Benutzer dir eine mathematische Aufgabe vorlegt, solltest du nicht direkt die Lösung nennen. Stattdessen sollst du den Benutzer zur Lösung der Aufgabe anleiten, indem Sie die sokratische Fragetechnik oder Scaffolding-Fragen verwenden, um den Schülern zu helfen, die Antwort selbst zu finden. Nutze dazu den exemplarischen Lösungsweg. Du kannst den Schüler beispielsweise zu einem bestimmten Teil der Frage befragen und ihn bitten, seine Gedanken dazu näher zu erläutern. Du sollst nach der Frage nicht selbst antworten, sondern den Nutzer zuerst antworten lassen. Stattdessen kannst du mögliche Richtungen aufzeigen und die Schüler bitten, weiter darüber nachzudenken, oder sie bitten, ihre Gedanken zu einem bestimmten Teil des Problems zu präzisieren und näher zu erläutern.
1) Antworte NUR mit einem kurzen Tipp (1-2 Sätze), keine Beispiele, keine Herleitung, keine komplette Lösung. 1) Antworte NUR mit einem kurzen Tipp (1 Satz), keine Beispiele, keine Herleitung, keine komplette Lösung.
2) Beziehe dich PRIMÄR auf die 'Aktuelle Frage'. Ignoriere ältere Nebenfragen, außer sie sind nötig. 2) Beziehe dich PRIMÄR auf die 'AKTUELLE Eingabe'.
3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin gib aber keine Lösung an. 3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin.
4) Basiere deine Tipps auf den Hinweisen. 4) Die Übergebene Lösung ist IMMER korrekt.
5) Die Übergebene Lösung ist IMMER korrekt. 5) Der Nutzer kenn die korrekte Lösung und den exemplarischer Lösungsweg nicht.
6) Der Nutzer kenn die Lösung und die Hinweise nicht.
verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$ verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$
Antworte mit folgender Struktur Antworte mit folgender Struktur
Das war richtig/das ist noch nicht richtig/gute Frage Das war richtig. / Das ist noch nicht richtig. / Gute Frage.
[Hier soll der nächster Tipp um weiter zu machen/um den Fehler zu korrigieren stehen ohne Klammern] Hier soll der nächster Tipp, um weiter zu machen oder um den Fehler zu korrigieren, stehen.
""" """
def generate_hint( def generate_hint(
......
...@@ -3,20 +3,15 @@ from __future__ import annotations ...@@ -3,20 +3,15 @@ from __future__ import annotations
import logging import logging
from typing import List, Optional from typing import List, Optional
import app.config as config
from app.deterministic_services import session_store from app.deterministic_services import session_store
from app.deterministic_services.orchestrators.registry import (
get_default_orchestrator,
is_valid_orchestrator,
resolve_orchestrator,
)
from fastapi import APIRouter, HTTPException, Path, Query from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
if config.get_orchestrator() == "tutor":
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:
from app.deterministic_services.orchestrators import orchestrator_qa as orchestrator
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -31,6 +26,7 @@ class ChatRequest(BaseModel): ...@@ -31,6 +26,7 @@ class ChatRequest(BaseModel):
messages: List[ChatMessage] messages: List[ChatMessage]
draft: Optional[str] = None draft: Optional[str] = None
selected_task: Optional[dict[str, str]] = None selected_task: Optional[dict[str, str]] = None
orchestrator: Optional[str] = None
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
...@@ -48,6 +44,7 @@ class ChatArchiveSummary(BaseModel): ...@@ -48,6 +44,7 @@ class ChatArchiveSummary(BaseModel):
saved_at: str saved_at: str
message_count: int message_count: int
preview: str preview: str
orchestrator: Optional[str] = None
class SelectedTaskRef(BaseModel): class SelectedTaskRef(BaseModel):
...@@ -60,6 +57,7 @@ class ChatArchiveDetail(BaseModel): ...@@ -60,6 +57,7 @@ class ChatArchiveDetail(BaseModel):
saved_at: str saved_at: str
history: List[ChatMessage] history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None selected_task: Optional[SelectedTaskRef] = None
orchestrator: str
@router.post("/api/chat", response_model=ChatResponse) @router.post("/api/chat", response_model=ChatResponse)
...@@ -67,16 +65,24 @@ def chat(request: ChatRequest) -> ChatResponse: ...@@ -67,16 +65,24 @@ def chat(request: ChatRequest) -> ChatResponse:
if not request.messages: if not request.messages:
raise HTTPException(status_code=400, detail="messages required") raise HTTPException(status_code=400, detail="messages required")
requested_orchestrator = str(request.orchestrator or "").strip().lower()
if requested_orchestrator and not is_valid_orchestrator(requested_orchestrator):
raise HTTPException(status_code=422, detail="unsupported orchestrator")
orchestrator_name, orchestrator_impl = resolve_orchestrator(
requested_orchestrator or None
)
try: try:
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages] payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
if config.get_orchestrator() == "task": if orchestrator_name == "task":
result = orchestrator.run_chat( result = orchestrator_impl.run_chat(
payload_messages, payload_messages,
draft=request.draft, draft=request.draft,
selected_task=request.selected_task, selected_task=request.selected_task,
) )
else: else:
result = orchestrator.run_chat( result = orchestrator_impl.run_chat(
payload_messages, payload_messages,
draft=request.draft, draft=request.draft,
) )
...@@ -124,6 +130,7 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail: ...@@ -124,6 +130,7 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
saved_at=record.get("saved_at", ""), 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, selected_task=selected_task,
orchestrator=record.get("orchestrator") or get_default_orchestrator(),
) )
...@@ -132,10 +139,16 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse: ...@@ -132,10 +139,16 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
if not request.messages: if not request.messages:
return ChatArchiveResponse(status="skipped", chat_id="unknown") return ChatArchiveResponse(status="skipped", chat_id="unknown")
requested_orchestrator = str(request.orchestrator or "").strip().lower()
if requested_orchestrator and not is_valid_orchestrator(requested_orchestrator):
raise HTTPException(status_code=422, detail="unsupported orchestrator")
archive_orchestrator = requested_orchestrator or get_default_orchestrator()
try: try:
chat_id = session_store.archive_chat( chat_id = session_store.archive_chat(
[{"role": m.role, "content": m.text} for m in request.messages], [{"role": m.role, "content": m.text} for m in request.messages],
draft=request.draft, draft=request.draft,
orchestrator=archive_orchestrator,
) )
except Exception as exc: except Exception as exc:
logger.exception("Chat archive failed") logger.exception("Chat archive failed")
......
...@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]: ...@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]:
"openai": _check_openai(), "openai": _check_openai(),
"postgres": _check_postgres(), "postgres": _check_postgres(),
} }
overall = "ok" required_statuses = {"ok"}
if any(value["status"] in ("error", "unauthorized") for value in services.values()): overall = "ok" if all(
overall = "degraded" service.get("status") in required_statuses for service in services.values()
) else "degraded"
return {"status": overall, "services": services} return {"status": overall, "services": services}
......
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from app.deterministic_services.orchestrators.registry import (
AVAILABLE_ORCHESTRATORS,
get_default_orchestrator,
)
router = APIRouter()
class OrchestratorConfigResponse(BaseModel):
default_orchestrator: str
available_orchestrators: list[str]
@router.get("/api/orchestrator/config", response_model=OrchestratorConfigResponse)
def get_orchestrator_config() -> OrchestratorConfigResponse:
return OrchestratorConfigResponse(
default_orchestrator=get_default_orchestrator(),
available_orchestrators=list(AVAILABLE_ORCHESTRATORS),
)
from __future__ import annotations
from typing import Any
import app.config as config
from app.deterministic_services.orchestrators import (
orchestrator_qa,
orchestrator_task,
orchestrator_tutor,
)
AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task")
_ORCHESTRATOR_MODULES: dict[str, Any] = {
"qa": orchestrator_qa,
"tutor": orchestrator_tutor,
"task": orchestrator_task,
}
def get_default_orchestrator() -> str:
configured = str(config.get_orchestrator() or "").strip().lower()
if configured in _ORCHESTRATOR_MODULES:
return configured
return "qa"
def is_valid_orchestrator(value: str) -> bool:
return value in _ORCHESTRATOR_MODULES
def resolve_orchestrator(value: str | None = None) -> tuple[str, Any]:
if value:
normalized = value.strip().lower()
if normalized in _ORCHESTRATOR_MODULES:
return normalized, _ORCHESTRATOR_MODULES[normalized]
default_name = get_default_orchestrator()
return default_name, _ORCHESTRATOR_MODULES[default_name]
...@@ -26,7 +26,11 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None: ...@@ -26,7 +26,11 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
return {"file_id": file_id, "task_id": task_id} 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,
orchestrator: 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)
if not sheet: if not sheet:
...@@ -38,6 +42,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st ...@@ -38,6 +42,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
record = { record = {
"chat_id": chat_id, "chat_id": chat_id,
"saved_at": _utc_now(), "saved_at": _utc_now(),
"orchestrator": str(orchestrator or "").strip().lower() or None,
"history": sheet.get("history", []), "history": sheet.get("history", []),
"context_sheet": context_store.format_sheet(sheet), "context_sheet": context_store.format_sheet(sheet),
"retrieval_contexts": sheet.get("retrieval_contexts", []), "retrieval_contexts": sheet.get("retrieval_contexts", []),
...@@ -65,6 +70,7 @@ def _summarize_record(record: dict[str, Any]) -> dict[str, Any]: ...@@ -65,6 +70,7 @@ def _summarize_record(record: dict[str, Any]) -> dict[str, Any]:
return { return {
"chat_id": record.get("chat_id", "unknown"), "chat_id": record.get("chat_id", "unknown"),
"saved_at": record.get("saved_at", ""), "saved_at": record.get("saved_at", ""),
"orchestrator": record.get("orchestrator"),
"message_count": len(history), "message_count": len(history),
"preview": preview, "preview": preview,
} }
...@@ -123,6 +129,7 @@ def load_archive(chat_id: str) -> dict[str, Any] | None: ...@@ -123,6 +129,7 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
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", ""),
"orchestrator": record.get("orchestrator"),
"history": history, "history": history,
"selected_task": selected_task, "selected_task": selected_task,
} }
......
...@@ -3,7 +3,7 @@ import logging ...@@ -3,7 +3,7 @@ import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, health, context, tasks from app.api import canvas, chat, context, health, orchestrator, tasks
from app.config import get_frontend_url from app.config import get_frontend_url
from app.deterministic_services import embedding_provider from app.deterministic_services import embedding_provider
...@@ -36,4 +36,5 @@ app.include_router(chat.router) ...@@ -36,4 +36,5 @@ app.include_router(chat.router)
app.include_router(canvas.router) app.include_router(canvas.router)
app.include_router(context.router) app.include_router(context.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(orchestrator.router)
app.include_router(tasks.router) app.include_router(tasks.router)
export type HealthStatus = "ok" | "degraded" | "error" | "unknown";
export type HealthResponse = {
status: HealthStatus;
services?: Record<string, { status?: string; [key: string]: unknown }>;
};
const normalizeHealthStatus = (value: unknown): HealthStatus => {
if (value === "ok" || value === "degraded" || value === "error") {
return value;
}
return "unknown";
};
export async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/api/health");
if (!response.ok) {
throw new Error(`Health check failed: ${response.status}`);
}
const payload = await response.json();
return {
status: normalizeHealthStatus(payload?.status),
services:
payload && typeof payload === "object" && payload.services
? (payload.services as HealthResponse["services"])
: undefined,
};
}
export type OrchestratorName = "qa" | "tutor" | "task";
export type OrchestratorConfigResponse = {
default_orchestrator: OrchestratorName;
available_orchestrators: OrchestratorName[];
};
const FALLBACK_ORCHESTRATORS: OrchestratorName[] = ["qa", "tutor", "task"];
const normalizeOrchestrator = (value: string): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task") {
return value;
}
return null;
};
export const getFallbackOrchestrators = (): OrchestratorName[] =>
[...FALLBACK_ORCHESTRATORS];
export async function fetchOrchestratorConfig(): Promise<OrchestratorConfigResponse> {
const response = await fetch("/api/orchestrator/config");
if (!response.ok) {
throw new Error(`Orchestrator config failed: ${response.status}`);
}
const payload = await response.json();
const defaultOrchestrator =
normalizeOrchestrator(String(payload?.default_orchestrator || "")) || "qa";
const availableRaw = Array.isArray(payload?.available_orchestrators)
? payload.available_orchestrators
: [];
const available = availableRaw
.map((item: unknown) => normalizeOrchestrator(String(item || "")))
.filter((item: OrchestratorName | null): item is OrchestratorName => Boolean(item));
return {
default_orchestrator: defaultOrchestrator,
available_orchestrators: available.length ? available : getFallbackOrchestrators(),
};
}
...@@ -46,7 +46,7 @@ export default function MessageInput({ ...@@ -46,7 +46,7 @@ export default function MessageInput({
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && event.ctrlKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
onSend(); onSend();
} }
......
import { useId } from "react";
import type { OrchestratorName } from "../../api/orchestratorApi";
import { t } from "../../i18n";
type OrchestratorSelectProps = {
value: OrchestratorName;
options: OrchestratorName[];
onChange: (value: OrchestratorName) => void;
disabled?: boolean;
};
type ModeMeta = {
labelKey:
| "orchestratorModeQaLabel"
| "orchestratorModeTutorLabel"
| "orchestratorModeTaskLabel";
descriptionKey:
| "orchestratorModeQaDescription"
| "orchestratorModeTutorDescription"
| "orchestratorModeTaskDescription";
};
const modeMetaMap: Record<OrchestratorName, ModeMeta> = {
qa: {
labelKey: "orchestratorModeQaLabel",
descriptionKey: "orchestratorModeQaDescription",
},
tutor: {
labelKey: "orchestratorModeTutorLabel",
descriptionKey: "orchestratorModeTutorDescription",
},
task: {
labelKey: "orchestratorModeTaskLabel",
descriptionKey: "orchestratorModeTaskDescription",
},
};
type ModeDescriptionDisplay = "tooltip" | "helperText" | "optionSuffix";
// Variants kept local for easy switching without touching page-level integrations.
const MODE_DESCRIPTION_DISPLAY: ModeDescriptionDisplay = "tooltip";
export default function OrchestratorSelect({
value,
options,
onChange,
disabled = false,
}: OrchestratorSelectProps) {
const selectId = useId();
const helpPopoverId = useId();
const selectedModeMeta = modeMetaMap[value];
const renderTooltip = MODE_DESCRIPTION_DISPLAY === "tooltip";
const renderHelperText = MODE_DESCRIPTION_DISPLAY === "helperText";
const renderOptionSuffix = MODE_DESCRIPTION_DISPLAY === "optionSuffix";
return (
<div className="orchestrator-select-wrap">
<div className="orchestrator-select-label-row">
<label className="orchestrator-select-label" htmlFor={selectId}>
{t("orchestratorMode")}
</label>
{renderTooltip ? (
<div className="orchestrator-help">
<button
type="button"
className="orchestrator-help-trigger"
aria-label={t("orchestratorModeDescriptionTitle")}
aria-describedby={helpPopoverId}
>
i
</button>
<div id={helpPopoverId} role="tooltip" className="orchestrator-help-popover">
<div className="orchestrator-help-title">{t("orchestratorModeDescriptionTitle")}</div>
<div className="orchestrator-help-list">
{options.map((option) => (
<div key={option} className="orchestrator-help-item">
<span className="orchestrator-help-mode">{t(modeMetaMap[option].labelKey)}:</span>
<span>{t(modeMetaMap[option].descriptionKey)}</span>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
<select
id={selectId}
className="task-select orchestrator-select"
value={value}
onChange={(event) => onChange(event.target.value as OrchestratorName)}
disabled={disabled}
>
{options.map((option) => (
<option key={option} value={option}>
{renderOptionSuffix
? `${t(modeMetaMap[option].labelKey)} - ${t(modeMetaMap[option].descriptionKey)}`
: t(modeMetaMap[option].labelKey)}
</option>
))}
</select>
{renderHelperText ? (
<div className="orchestrator-mode-helper">{t(selectedModeMeta.descriptionKey)}</div>
) : null}
</div>
);
}
import { useCallback, useEffect, useMemo, useState, type PropsWithChildren } from "react";
import { fetchHealth, type HealthResponse } from "../../api/healthApi";
import { t } from "../../i18n";
import "../../styles/theme.css";
type GateState = "checking" | "healthy" | "unhealthy";
const HEALTH_POLL_INTERVAL_MS = 5000;
const formatServiceStatus = (health: HealthResponse | null): string | null => {
if (!health?.services) {
return null;
}
const failing = Object.entries(health.services)
.filter(([, value]) => {
const status = String(value?.status || "").toLowerCase();
return status && status !== "ok";
})
.map(([service, value]) => `${service}: ${String(value?.status || "unknown")}`);
return failing.length ? failing.join(" | ") : null;
};
export default function BackendHealthGate({ children }: PropsWithChildren) {
const [gateState, setGateState] = useState<GateState>("checking");
const [lastError, setLastError] = useState<string | null>(null);
const [lastHealth, setLastHealth] = useState<HealthResponse | null>(null);
const [isRetrying, setIsRetrying] = useState(false);
const runHealthCheck = useCallback(async () => {
try {
const health = await fetchHealth();
setLastHealth(health);
if (health.status === "ok") {
setGateState("healthy");
setLastError(null);
} else {
setGateState("unhealthy");
setLastError(`status=${health.status}`);
}
} catch (error) {
setGateState("unhealthy");
setLastHealth(null);
if (error instanceof Error && error.message) {
setLastError(error.message);
} else {
setLastError("network_error");
}
}
}, []);
useEffect(() => {
void runHealthCheck();
}, [runHealthCheck]);
useEffect(() => {
if (gateState !== "unhealthy") {
return;
}
const timer = window.setInterval(() => {
void runHealthCheck();
}, HEALTH_POLL_INTERVAL_MS);
return () => {
window.clearInterval(timer);
};
}, [gateState, runHealthCheck]);
const handleRetry = async () => {
setIsRetrying(true);
try {
await runHealthCheck();
} finally {
setIsRetrying(false);
}
};
const failureDetails = useMemo(() => {
const serviceDetails = formatServiceStatus(lastHealth);
if (serviceDetails) {
return serviceDetails;
}
return lastError;
}, [lastError, lastHealth]);
if (gateState === "healthy") {
return <>{children}</>;
}
return (
<div className="backend-health-shell">
<header className="app-header backend-health-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("prototypeWorkspace")}</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="backend-health-main">
<section className="backend-health-card">
{gateState === "checking" ? (
<p className="backend-health-loading">{t("backendChecking")}</p>
) : (
<>
<h1 className="backend-health-title">{t("backendUnavailableTitle")}</h1>
<p className="backend-health-text">{t("backendUnavailableBody")}</p>
{failureDetails ? (
<p className="backend-health-detail">
{t("lastCheckFailed", { detail: failureDetails })}
</p>
) : null}
<button
type="button"
className="btn primary"
onClick={handleRetry}
disabled={isRetrying}
>
{isRetrying ? t("loading") : t("retryConnection")}
</button>
</>
)}
</section>
</main>
</div>
);
}
...@@ -16,6 +16,14 @@ ...@@ -16,6 +16,14 @@
show: "Show", show: "Show",
newChat: "New Chat", newChat: "New Chat",
loading: "Loading...", loading: "Loading...",
orchestratorMode: "Mode",
orchestratorModeQaLabel: "QA",
orchestratorModeTutorLabel: "Tutor",
orchestratorModeTaskLabel: "Task",
orchestratorModeDescriptionTitle: "Mode help",
orchestratorModeQaDescription: "Direct answers based on the script",
orchestratorModeTutorDescription: "Help with your own questions",
orchestratorModeTaskDescription: "Help with textbook exercises",
directChildren: "Direct children", directChildren: "Direct children",
taskChildren: "Task sources", taskChildren: "Task sources",
indirectChildren: "Indirect children", indirectChildren: "Indirect children",
...@@ -35,6 +43,7 @@ ...@@ -35,6 +43,7 @@
noSavedChatsYet: "No saved chats yet.", noSavedChatsYet: "No saved chats yet.",
canvasHidden: "Canvas hidden", canvasHidden: "Canvas hidden",
failedLoadTasks: "Could not load tasks.", failedLoadTasks: "Could not load tasks.",
failedLoadOrchestratorConfig: "Could not load orchestrator config. Fallback mode active.",
chatRequestFailed: "The chat request failed. Please check backend logs.", chatRequestFailed: "The chat request failed. Please check backend logs.",
retrievalFailed: "Source retrieval failed. Please check backend logs.", retrievalFailed: "Source retrieval failed. Please check backend logs.",
failedLoadSavedChats: "Could not load saved chats.", failedLoadSavedChats: "Could not load saved chats.",
...@@ -53,6 +62,12 @@ ...@@ -53,6 +62,12 @@
changeTaskArea: "Change Task Area", changeTaskArea: "Change Task Area",
previousTask: "Previous Task", previousTask: "Previous Task",
nextTask: "Next Task", nextTask: "Next Task",
backendChecking: "Checking backend availability...",
backendUnavailableTitle: "Backend not reachable",
backendUnavailableBody:
"The tutor backend is currently unavailable or still starting up. We will keep trying automatically.",
retryConnection: "Retry now",
lastCheckFailed: "Last check: {detail}",
}, },
de: { de: {
chats: "Chats", chats: "Chats",
...@@ -62,7 +77,7 @@ ...@@ -62,7 +77,7 @@
canvas: "Canvas", canvas: "Canvas",
retrievedNotes: "Quellen", retrievedNotes: "Quellen",
typeQuestionOrLatex: typeQuestionOrLatex:
"Gebe hier deine Frage ein. Nutze $ $ fuer mathematische Eingaben...", "Gebe hier deine Frage ein. Nutze $ $ für mathematische Eingaben...",
noResultsYet: "bisher keine Quellen", noResultsYet: "bisher keine Quellen",
eraser: "Radierer", eraser: "Radierer",
pen: "Stift", pen: "Stift",
...@@ -72,6 +87,14 @@ ...@@ -72,6 +87,14 @@
show: "anzeigen", show: "anzeigen",
newChat: "neuer Chat", newChat: "neuer Chat",
loading: "Lade...", loading: "Lade...",
orchestratorMode: "Modus",
orchestratorModeQaLabel: "QA",
orchestratorModeTutorLabel: "Tutor",
orchestratorModeTaskLabel: "Task",
orchestratorModeDescriptionTitle: "Modus-Hilfe",
orchestratorModeQaDescription: "Direkte Antworten basierend auf dem Skript",
orchestratorModeTutorDescription: "Hilfe bei selbst gestellten Fragen",
orchestratorModeTaskDescription: "Hilfe bei Aufgaben aus dem Lehrwerk",
directChildren: "Direkte Quellen", directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen", taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen", indirectChildren: "Indirekte Quellen",
...@@ -84,34 +107,42 @@ ...@@ -84,34 +107,42 @@
send: "Senden", send: "Senden",
roleUser: "nutzer", roleUser: "nutzer",
roleAssistant: "assistent", roleAssistant: "assistent",
noTasksAvailable: "Keine Aufgaben verfuegbar", noTasksAvailable: "Keine Aufgaben verfügbar",
noTaskSelected: "Keine Aufgabe ausgewählt.", 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.",
canvasHidden: "Canvas ausgeblendet", canvasHidden: "Canvas ausgeblendet",
failedLoadTasks: "Aufgaben konnten nicht geladen werden.", failedLoadTasks: "Aufgaben konnten nicht geladen werden.",
failedLoadOrchestratorConfig:
"Orchestrator-Konfiguration konnte nicht geladen werden. Fallback-Modus aktiv.",
chatRequestFailed: chatRequestFailed:
"Chat-Anfrage ist fehlgeschlagen. Bitte pruefe die Backend-Logs.", "Da ist wohl die Chat-Anfrage fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.",
retrievalFailed: retrievalFailed:
"Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.", "Da ist wohl der Quellenabruf fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.",
failedLoadSavedChats: "Gespeicherte Chats konnten nicht geladen werden.", failedLoadSavedChats: "Gespeicherte Chats konnten nicht geladen werden.",
failedLoadSelectedChat: "Der ausgewählte 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}). Probiere es nochmal oder lade die Seite neu.",
canvasSaveFailed: "Speichern des Canvas fehlgeschlagen.", canvasSaveFailed: "Speichern des Canvas fehlgeschlagen. Probiere es nochmal oder lade die Seite neu.",
canvasSaved: "Canvas gespeichert.", canvasSaved: "Canvas gespeichert.",
canvasSaveFailedCheckLogs: canvasSaveFailedCheckLogs:
"Speichern des Canvas fehlgeschlagen. Bitte pruefe die Backend-Logs.", "Da ist wohl das Speichern des Canvas fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.",
taskSelectionTitle: "Aufgabe auswaehlen", taskSelectionTitle: "Aufgabe auswählen",
taskSelectionSubtitle: "Waehle eine Aufgabe und starte den Tutor-Chat", taskSelectionSubtitle: "Wähle eine Aufgabe und starte den Tutor-Chat",
taskFile: "Aufgabenset", taskFile: "Aufgabenset",
taskId: "Aufgaben-ID", taskId: "Aufgaben-ID",
solveWithTutor: "Mit Tutor lösen", solveWithTutor: "Mit Tutor lösen",
changeTaskArea: "Aufgabengebiet ändern", changeTaskArea: "Aufgabengebiet ändern",
previousTask: "Vorherige Aufgabe", previousTask: "Vorherige Aufgabe",
nextTask: "Nächste Aufgabe", nextTask: "Nächste Aufgabe",
backendChecking: "Backend-Verbindung wird geprüft...",
backendUnavailableTitle: "Backend nicht erreichbar",
backendUnavailableBody:
"Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.",
retryConnection: "Erneut prüfen",
lastCheckFailed: "Letzte Prüfung: {detail}",
}, },
} as const; } as const;
......
...@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client"; ...@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "./index.css"; import "./index.css";
import App from "./pages/App.tsx"; import App from "./pages/App.tsx";
import BackendHealthGate from "./components/System/BackendHealthGate.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <BackendHealthGate>
<App />
</BackendHealthGate>
</BrowserRouter> </BrowserRouter>
</StrictMode> </StrictMode>
); );
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "../styles/theme.css"; import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow"; import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer"; import CanvasDrawer from "../components/Canvas/CanvasDrawer";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import DocPanel from "../components/Retrieval/DocPanel"; import DocPanel from "../components/Retrieval/DocPanel";
import TaskPanel from "../components/Task/TaskPanel"; import TaskPanel from "../components/Task/TaskPanel";
import type { ChatMessage } from "../components/Chat/MessageList"; import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel"; import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
import { t } from "../i18n"; import type { OrchestratorName } from "../api/orchestratorApi";
import { selectTask } from "../api/taskApi"; import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession"; import { createSessionId, useTutorSession } from "../state/tutorSession";
const initialMessages: ChatMessage[] = []; const initialMessages: ChatMessage[] = [];
const normalizeOrchestrator = (value: string | null | undefined): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task") {
return value;
}
return null;
};
type ArchivedChatSummary = { type ArchivedChatSummary = {
chat_id: string; chat_id: string;
saved_at: string; saved_at: string;
message_count: number; message_count: number;
preview: string; preview: string;
orchestrator?: string | null;
}; };
type ArchivedChatDetail = { type ArchivedChatDetail = {
chat_id: string; chat_id: string;
saved_at: string; saved_at: string;
history: ChatMessage[]; history: ChatMessage[];
orchestrator?: string | null;
selected_task?: { selected_task?: {
file_id: string; file_id: string;
task_id: string; task_id: string;
...@@ -91,9 +102,7 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => ( ...@@ -91,9 +102,7 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => (
}); });
export default function ChatPage() { export default function ChatPage() {
const showChatsButton = String( const showChatsButton = String(import.meta.env.VITE_SHOW_CHATS_BUTTON ?? "true")
import.meta.env.VITE_SHOW_CHATS_BUTTON ?? "true"
)
.trim() .trim()
.toLowerCase() !== "false"; .toLowerCase() !== "false";
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -105,6 +114,12 @@ export default function ChatPage() { ...@@ -105,6 +114,12 @@ export default function ChatPage() {
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
selectedTaskFile, selectedTaskFile,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
taskLocked, taskLocked,
lockTask, lockTask,
unlockTask, unlockTask,
...@@ -139,13 +154,7 @@ export default function ChatPage() { ...@@ -139,13 +154,7 @@ export default function ChatPage() {
if (!selectedTaskRef || !taskLocked) { if (!selectedTaskRef || !taskLocked) {
navigate("/select-task", { replace: true }); navigate("/select-task", { replace: true });
} }
}, [ }, [isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef, taskLocked]);
isTaskModeEnabled,
isTasksInitialized,
navigate,
selectedTaskRef,
taskLocked,
]);
const docIndexes = useMemo(() => { const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {}; const bySourceKey: Record<string, RetrievedDoc> = {};
...@@ -243,6 +252,7 @@ export default function ChatPage() { ...@@ -243,6 +252,7 @@ export default function ChatPage() {
const chatPayload: { const chatPayload: {
messages: Array<{ role: "user" | "assistant"; text: string }>; messages: Array<{ role: "user" | "assistant"; text: string }>;
draft: string; draft: string;
orchestrator: OrchestratorName;
selected_task?: { file_id: string; task_id: string }; selected_task?: { file_id: string; task_id: string };
} = { } = {
messages: [...messages, userMessage].map((message) => ({ messages: [...messages, userMessage].map((message) => ({
...@@ -250,6 +260,7 @@ export default function ChatPage() { ...@@ -250,6 +260,7 @@ export default function ChatPage() {
text: message.text, text: message.text,
})), })),
draft: chatSessionId, draft: chatSessionId,
orchestrator: selectedOrchestrator,
}; };
if (isTaskModeEnabled && selectedTaskRef) { if (isTaskModeEnabled && selectedTaskRef) {
chatPayload.selected_task = { chatPayload.selected_task = {
...@@ -410,7 +421,14 @@ export default function ChatPage() { ...@@ -410,7 +421,14 @@ export default function ChatPage() {
setMessages(payload.history || []); setMessages(payload.history || []);
setChatSessionId(payload.chat_id); setChatSessionId(payload.chat_id);
if (isTaskModeEnabled) { const restoredOrchestrator = normalizeOrchestrator(payload.orchestrator);
const nextOrchestrator =
restoredOrchestrator && availableOrchestrators.includes(restoredOrchestrator)
? restoredOrchestrator
: selectedOrchestrator;
setSelectedOrchestrator(nextOrchestrator);
if (nextOrchestrator === "task") {
const restoredTask = payload.selected_task; const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) { if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({ setTaskRef({
...@@ -432,6 +450,10 @@ export default function ChatPage() { ...@@ -432,6 +450,10 @@ export default function ChatPage() {
unlockTask(); unlockTask();
navigate("/select-task", { replace: true }); navigate("/select-task", { replace: true });
} }
} else {
setTaskRef(null);
unlockTask();
navigate("/chat", { replace: true });
} }
} catch (error) { } catch (error) {
setArchiveError(t("failedLoadSelectedChat")); setArchiveError(t("failedLoadSelectedChat"));
...@@ -452,6 +474,7 @@ export default function ChatPage() { ...@@ -452,6 +474,7 @@ export default function ChatPage() {
text: message.text, text: message.text,
})), })),
draft: chatSessionId, draft: chatSessionId,
orchestrator: selectedOrchestrator,
}), }),
}); });
} catch (error) { } catch (error) {
...@@ -484,6 +507,7 @@ export default function ChatPage() { ...@@ -484,6 +507,7 @@ export default function ChatPage() {
text: message.text, text: message.text,
})), })),
draft: chatSessionId, draft: chatSessionId,
orchestrator: selectedOrchestrator,
}), }),
}); });
} catch (error) { } catch (error) {
...@@ -533,6 +557,15 @@ export default function ChatPage() { ...@@ -533,6 +557,15 @@ export default function ChatPage() {
void loadArchives(); void loadArchives();
}; };
const handleSwitchOrchestrator = (next: OrchestratorName) => {
if (next === selectedOrchestrator) {
return;
}
resetChatState();
switchOrchestrator(next);
navigate(next === "task" ? "/select-task" : "/chat");
};
const handleCanvasSave = async (dataUrl: string) => { const handleCanvasSave = async (dataUrl: string) => {
setCanvasStatus({ setCanvasStatus({
kind: "info", kind: "info",
...@@ -655,11 +688,7 @@ export default function ChatPage() { ...@@ -655,11 +688,7 @@ export default function ChatPage() {
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
{showChatsButton ? ( {showChatsButton ? (
<button <button type="button" className="btn sidebar-toggle" onClick={handleOpenSidebar}>
type="button"
className="btn sidebar-toggle"
onClick={handleOpenSidebar}
>
{t("chats")} {t("chats")}
</button> </button>
) : null} ) : null}
...@@ -671,11 +700,18 @@ export default function ChatPage() { ...@@ -671,11 +700,18 @@ export default function ChatPage() {
</div> </div>
</div> </div>
<div className="header-meta"> <div className="header-meta">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
<span className="pill">Postgres + pgvector</span> <span className="pill">Postgres + pgvector</span>
<span className="pill">Python FastAPI</span> <span className="pill">Python FastAPI</span>
<span className="pill">Mathpix</span> <span className="pill">Mathpix</span>
</div> </div>
</header> </header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
<main className="app-main"> <main className="app-main">
<section className="chat-column"> <section className="chat-column">
...@@ -701,11 +737,7 @@ export default function ChatPage() { ...@@ -701,11 +737,7 @@ export default function ChatPage() {
) : null} ) : null}
</section> </section>
<aside <aside className={`retrieval-column ${isTaskModeEnabled ? "retrieval-column-task-mode" : ""}`}>
className={`retrieval-column ${
isTaskModeEnabled ? "retrieval-column-task-mode" : ""
}`}
>
{isTaskModeEnabled && selectedTask ? ( {isTaskModeEnabled && selectedTask ? (
<TaskPanel <TaskPanel
readOnly readOnly
...@@ -736,23 +768,14 @@ export default function ChatPage() { ...@@ -736,23 +768,14 @@ export default function ChatPage() {
<div className={`sidebar ${isSidebarOpen ? "active" : ""}`}> <div className={`sidebar ${isSidebarOpen ? "active" : ""}`}>
<div className="sd-header"> <div className="sd-header">
<h4 className="sd-title">{t("savedChats")}</h4> <h4 className="sd-title">{t("savedChats")}</h4>
<button <button type="button" className="sidebar-button" onClick={() => setIsSidebarOpen(false)}>
type="button"
className="sidebar-button"
onClick={() => setIsSidebarOpen(false)}
>
X X
</button> </button>
</div> </div>
<div className="sd-body"> <div className="sd-body">
<ul className="sd-list"> <ul className="sd-list">
<li> <li>
<button <button type="button" className="sd-link" onClick={handleNewChat} disabled={isArchiving}>
type="button"
className="sd-link"
onClick={handleNewChat}
disabled={isArchiving}
>
{isArchiving ? t("saving") : t("newChat")} {isArchiving ? t("saving") : t("newChat")}
</button> </button>
</li> </li>
...@@ -764,9 +787,7 @@ export default function ChatPage() { ...@@ -764,9 +787,7 @@ export default function ChatPage() {
<li key={item.chat_id}> <li key={item.chat_id}>
<button <button
type="button" type="button"
className={`sd-link ${ className={`sd-link ${selectedArchiveId === item.chat_id ? "active" : ""}`}
selectedArchiveId === item.chat_id ? "active" : ""
}`}
onClick={() => { onClick={() => {
setSelectedArchiveId(item.chat_id); setSelectedArchiveId(item.chat_id);
void handleLoadArchive(item.chat_id); void handleLoadArchive(item.chat_id);
...@@ -782,9 +803,7 @@ export default function ChatPage() { ...@@ -782,9 +803,7 @@ export default function ChatPage() {
</li> </li>
)} )}
</ul> </ul>
{archiveError ? ( {archiveError ? <div className="chat-archive-error">{archiveError}</div> : null}
<div className="chat-archive-error">{archiveError}</div>
) : null}
</div> </div>
</div> </div>
<div <div
......
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "../styles/theme.css"; import type { OrchestratorName } from "../api/orchestratorApi";
import { t } from "../i18n"; import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { selectTask } from "../api/taskApi"; import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession"; import { useTutorSession } from "../state/tutorSession";
import "../styles/theme.css";
export default function TaskSelectionPage() { export default function TaskSelectionPage() {
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -11,6 +13,11 @@ export default function TaskSelectionPage() { ...@@ -11,6 +13,11 @@ export default function TaskSelectionPage() {
chatSessionId, chatSessionId,
isTaskModeEnabled, isTaskModeEnabled,
isTasksInitialized, isTasksInitialized,
selectedOrchestrator,
availableOrchestrators,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
taskFileOptions, taskFileOptions,
...@@ -63,6 +70,14 @@ export default function TaskSelectionPage() { ...@@ -63,6 +70,14 @@ export default function TaskSelectionPage() {
} }
}; };
const handleSwitchOrchestrator = (next: OrchestratorName) => {
if (next === selectedOrchestrator) {
return;
}
switchOrchestrator(next);
navigate(next === "task" ? "/select-task" : "/chat", { replace: true });
};
if (!isTasksInitialized) { if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>; return <div className="app-loading">{t("loading")}</div>;
} }
...@@ -77,7 +92,16 @@ export default function TaskSelectionPage() { ...@@ -77,7 +92,16 @@ export default function TaskSelectionPage() {
<div className="brand-subtitle">{t("taskSelectionSubtitle")}</div> <div className="brand-subtitle">{t("taskSelectionSubtitle")}</div>
</div> </div>
</div> </div>
<div className="header-meta">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
</header> </header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
<main className="task-select-main"> <main className="task-select-main">
<section className="task-select-card"> <section className="task-select-card">
......
...@@ -9,6 +9,11 @@ import { ...@@ -9,6 +9,11 @@ import {
type PropsWithChildren, type PropsWithChildren,
} from "react"; } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import {
fetchOrchestratorConfig,
getFallbackOrchestrators,
type OrchestratorName,
} from "../api/orchestratorApi";
import { fetchTasks, type SelectedTaskRef, type TaskFile } from "../api/taskApi"; import { fetchTasks, type SelectedTaskRef, type TaskFile } from "../api/taskApi";
export type SelectOption = { export type SelectOption = {
...@@ -38,6 +43,12 @@ export type TutorSessionState = TaskSelectionState & { ...@@ -38,6 +43,12 @@ export type TutorSessionState = TaskSelectionState & {
chatSessionId: string; chatSessionId: string;
setChatSessionId: (value: string) => void; setChatSessionId: (value: string) => void;
initTasks: () => Promise<void>; initTasks: () => Promise<void>;
selectedOrchestrator: OrchestratorName;
availableOrchestrators: OrchestratorName[];
setSelectedOrchestrator: (value: OrchestratorName) => void;
switchOrchestrator: (value: OrchestratorName) => void;
isOrchestratorSelectable: boolean;
orchestratorError: string | null;
setTaskRef: (value: SelectedTaskRef | null) => void; setTaskRef: (value: SelectedTaskRef | null) => void;
setTaskFile: (fileId: string) => void; setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void; setTaskId: (taskId: string) => void;
...@@ -56,13 +67,21 @@ export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string => ...@@ -56,13 +67,21 @@ export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string =>
export function TutorSessionProvider({ children }: PropsWithChildren) { export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId()); const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
const [isTaskModeEnabled, setIsTaskModeEnabled] = useState(false); const [selectedOrchestrator, setSelectedOrchestratorState] =
useState<OrchestratorName>("qa");
const [availableOrchestrators, setAvailableOrchestrators] = useState<OrchestratorName[]>(
() => getFallbackOrchestrators()
);
const [isOrchestratorSelectable, setIsOrchestratorSelectable] = useState(false);
const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false); const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]); const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null); const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null); const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false); const [taskLocked, setTaskLocked] = useState(false);
const isTaskModeEnabled = selectedOrchestrator === "task";
const selectedTask = useMemo<SelectedTask | null>(() => { const selectedTask = useMemo<SelectedTask | null>(() => {
if (!selectedTaskRef) { if (!selectedTaskRef) {
return null; return null;
...@@ -108,39 +127,50 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -108,39 +127,50 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const initTasks = useCallback(async () => { const initTasks = useCallback(async () => {
setTasksError(null); setTasksError(null);
setOrchestratorError(null);
try {
const orchestratorPayload = await fetchOrchestratorConfig();
const available = orchestratorPayload.available_orchestrators.length
? orchestratorPayload.available_orchestrators
: getFallbackOrchestrators();
setAvailableOrchestrators(available);
setIsOrchestratorSelectable(true);
setSelectedOrchestratorState((prev) =>
available.includes(prev) ? prev : orchestratorPayload.default_orchestrator
);
} catch (error) {
setAvailableOrchestrators(getFallbackOrchestrators());
setSelectedOrchestratorState("qa");
setIsOrchestratorSelectable(false);
setOrchestratorError(t("failedLoadOrchestratorConfig"));
void error;
}
try { try {
const payload = await fetchTasks(); const payload = await fetchTasks();
const enabled = Boolean(payload.enabled);
const files = payload.task_files || []; const files = payload.task_files || [];
setIsTaskModeEnabled(enabled);
setTaskFiles(files); setTaskFiles(files);
setSelectedTaskRef((prev) => {
if (!enabled) { if (prev) {
setSelectedTaskRef(null); const file = files.find((item) => item.file_id === prev.fileId);
} else { if (file && file.tasks.some((task) => task.task_id === prev.taskId)) {
setSelectedTaskRef((prev) => { return 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]; const firstFile = files[0];
if (!firstFile) { if (!firstFile) {
return null; return null;
} }
const defaultTaskId = getDefaultTaskId(firstFile.tasks || []); const defaultTaskId = getDefaultTaskId(firstFile.tasks || []);
if (!defaultTaskId) { if (!defaultTaskId) {
return null; return null;
} }
return { fileId: firstFile.file_id, taskId: defaultTaskId }; return { fileId: firstFile.file_id, taskId: defaultTaskId };
}); });
}
} catch (error) { } catch (error) {
setTasksError(t("failedLoadTasks")); setTasksError(t("failedLoadTasks"));
setIsTaskModeEnabled(false);
setTaskFiles([]); setTaskFiles([]);
setSelectedTaskRef(null); setSelectedTaskRef(null);
void error; void error;
...@@ -153,27 +183,46 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -153,27 +183,46 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
void initTasks(); void initTasks();
}, [initTasks]); }, [initTasks]);
const setTaskFile = useCallback((fileId: string) => { const setTaskFile = useCallback(
if (!fileId) { (fileId: string) => {
return; if (!fileId) {
} return;
const file = taskFiles.find((item) => item.file_id === fileId); }
if (!file) { const file = taskFiles.find((item) => item.file_id === fileId);
return; if (!file) {
} return;
const defaultTaskId = getDefaultTaskId(file.tasks || []); }
if (!defaultTaskId) { const defaultTaskId = getDefaultTaskId(file.tasks || []);
return; if (!defaultTaskId) {
} return;
setSelectedTaskRef({ fileId: file.file_id, taskId: defaultTaskId }); }
}, [taskFiles]); setSelectedTaskRef({ fileId: file.file_id, taskId: defaultTaskId });
},
[taskFiles]
);
const setTaskId = useCallback((taskId: string) => { const setTaskId = useCallback(
if (!taskId || !selectedTaskRef?.fileId) { (taskId: string) => {
return; if (!taskId || !selectedTaskRef?.fileId) {
return;
}
setSelectedTaskRef({ fileId: selectedTaskRef.fileId, taskId });
},
[selectedTaskRef]
);
const setSelectedOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value);
}, []);
const switchOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value);
setChatSessionId(createSessionId());
setTaskLocked(false);
if (value !== "task") {
setSelectedTaskRef(null);
} }
setSelectedTaskRef({ fileId: selectedTaskRef.fileId, taskId }); }, []);
}, [selectedTaskRef]);
const lockTask = useCallback(() => { const lockTask = useCallback(() => {
setTaskLocked(true); setTaskLocked(true);
...@@ -193,6 +242,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -193,6 +242,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
chatSessionId, chatSessionId,
setChatSessionId, setChatSessionId,
initTasks, initTasks,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
taskFiles, taskFiles,
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
......
...@@ -61,6 +61,111 @@ body { ...@@ -61,6 +61,111 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center;
}
.orchestrator-select-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 4px 8px;
border: 1px solid #d8d1c4;
border-radius: 999px;
background: #fef9f0;
}
.orchestrator-select-label-row {
display: flex;
align-items: center;
gap: 6px;
}
.orchestrator-select-label {
font-size: 12px;
color: #6f675d;
}
.orchestrator-select {
min-width: 96px;
padding: 4px 8px;
}
.orchestrator-help {
position: relative;
}
.orchestrator-help-trigger {
width: 18px;
height: 18px;
border-radius: 999px;
border: 1px solid #b9b2a4;
background: #fff;
color: #6f675d;
font-size: 11px;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: help;
}
.orchestrator-help-trigger:focus-visible {
outline: 2px solid #6f675d;
outline-offset: 1px;
}
.orchestrator-help-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 320px;
padding: 10px;
border: 1px solid #d8d1c4;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 26px rgba(29, 27, 22, 0.15);
color: #1d1b16;
font-size: 12px;
z-index: 40;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.15s ease;
}
.orchestrator-help:hover .orchestrator-help-popover,
.orchestrator-help:focus-within .orchestrator-help-popover {
opacity: 1;
visibility: visible;
}
.orchestrator-help-title {
font-weight: 600;
margin-bottom: 6px;
}
.orchestrator-help-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.orchestrator-help-item {
display: flex;
gap: 6px;
}
.orchestrator-help-mode {
font-weight: 600;
min-width: 42px;
}
.orchestrator-mode-helper {
flex-basis: 100%;
font-size: 12px;
color: #6f675d;
} }
.pill { .pill {
...@@ -561,6 +666,13 @@ body { ...@@ -561,6 +666,13 @@ body {
width: 100%; width: 100%;
} }
.orchestrator-help-popover {
left: auto;
right: 0;
width: auto;
max-width: min(92vw, 360px);
}
.chat-window { .chat-window {
min-height: 360px; min-height: 360px;
} }
...@@ -592,6 +704,61 @@ body { ...@@ -592,6 +704,61 @@ body {
color: #6f675d; color: #6f675d;
} }
.backend-health-shell {
min-height: 100vh;
padding: 24px;
background: radial-gradient(circle at top left, #fff7e6, #f4f1ea);
display: flex;
flex-direction: column;
}
.backend-health-header {
margin-bottom: 24px;
}
.backend-health-main {
flex: 1;
display: grid;
place-items: center;
}
.backend-health-card {
width: min(620px, 100%);
background: #fef9f0;
border: 1px solid #d8d1c4;
border-radius: 16px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.backend-health-title {
margin: 0;
font-size: 20px;
}
.backend-health-text {
margin: 0;
color: #6f675d;
}
.backend-health-loading {
margin: 0;
color: #6f675d;
}
.backend-health-detail {
margin: 0;
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
overflow-wrap: anywhere;
}
.task-select-main { .task-select-main {
flex: 1; flex: 1;
display: grid; display: grid;
...@@ -657,6 +824,14 @@ body { ...@@ -657,6 +824,14 @@ body {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.backend-health-shell {
padding: 16px;
}
.backend-health-card {
width: 100%;
}
.task-select-controls { .task-select-controls {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
......
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