Commit ff6c3d7f authored by Kantz's avatar Kantz
Browse files

implementierung des Modus wechsels

parent 92ce484a
...@@ -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")
......
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 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(),
};
}
import type { OrchestratorName } from "../../api/orchestratorApi";
import { t } from "../../i18n";
type OrchestratorSelectProps = {
value: OrchestratorName;
options: OrchestratorName[];
onChange: (value: OrchestratorName) => void;
disabled?: boolean;
};
const labelMap: Record<OrchestratorName, string> = {
qa: "QA",
tutor: "Tutor",
task: "Task",
};
export default function OrchestratorSelect({
value,
options,
onChange,
disabled = false,
}: OrchestratorSelectProps) {
return (
<label className="orchestrator-select-wrap" htmlFor="orchestrator-select">
<span className="orchestrator-select-label">{t("orchestratorMode")}</span>
<select
id="orchestrator-select"
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}>
{labelMap[option]}
</option>
))}
</select>
</label>
);
}
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
show: "Show", show: "Show",
newChat: "New Chat", newChat: "New Chat",
loading: "Loading...", loading: "Loading...",
orchestratorMode: "Mode",
directChildren: "Direct children", directChildren: "Direct children",
taskChildren: "Task sources", taskChildren: "Task sources",
indirectChildren: "Indirect children", indirectChildren: "Indirect children",
...@@ -35,6 +36,7 @@ ...@@ -35,6 +36,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.",
...@@ -72,6 +74,7 @@ ...@@ -72,6 +74,7 @@
show: "anzeigen", show: "anzeigen",
newChat: "neuer Chat", newChat: "neuer Chat",
loading: "Lade...", loading: "Lade...",
orchestratorMode: "Modus",
directChildren: "Direkte Quellen", directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen", taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen", indirectChildren: "Indirekte Quellen",
...@@ -91,6 +94,8 @@ ...@@ -91,6 +94,8 @@
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.", "Chat-Anfrage ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
retrievalFailed: retrievalFailed:
......
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,27 @@ body { ...@@ -61,6 +61,27 @@ 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;
gap: 8px;
padding: 4px 8px;
border: 1px solid #d8d1c4;
border-radius: 999px;
background: #fef9f0;
}
.orchestrator-select-label {
font-size: 12px;
color: #6f675d;
}
.orchestrator-select {
min-width: 96px;
padding: 4px 8px;
} }
.pill { .pill {
......
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