Commit 5a28f888 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!12
parents cd16d7c1 9ebdf9b1
...@@ -33,7 +33,8 @@ Frontend (Vite): ...@@ -33,7 +33,8 @@ Frontend (Vite):
``` powershell ``` powershell
cd math-tutor/frontend cd math-tutor/frontend
npm install corepack enable
pnpm install --frozen-lockfile
``` ```
## Run the app ## Run the app
...@@ -50,17 +51,47 @@ Frontend (Vite): ...@@ -50,17 +51,47 @@ Frontend (Vite):
```powershell ```powershell
cd math-tutor/frontend cd math-tutor/frontend
npm run dev pnpm run dev
``` ```
### Task Deep Links
You can open a task chat directly with URL query parameters:
`/chat?orchestrator=task&file_id=<task_file_id>&task_id=<task_id>`
Example:
`http://localhost:5173/chat?orchestrator=task&file_id=analysis_1&task_id=03`
Notes:
- `file_id` must match an existing task file id from the task catalog.
- `task_id` must exist inside that file.
- If the link is invalid, the frontend falls back to `/select-task`.
- The task is not locked by deep link, so users can still switch tasks afterwards.
To make it accessible over the network. To make it accessible over the network.
Add the frontend- and backend-adress in the `backend/.env`-file in the frontend- and backend-folder. Use the following command to run the front- and backend. Add the frontend- and backend-adress in the `backend/.env`-file in the frontend- and backend-folder. Use the following command to run the front- and backend.
```powershell ```powershell
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
npm run dev -- --host 0.0.0.0 pnpm run dev -- --host 0.0.0.0
``` ```
### Frontend package manager policy (pnpm)
- Required versions:
- Node.js: 22.x or newer
- pnpm: pinned via `math-tutor/frontend/package.json` (`packageManager`)
- Lockfile policy:
- Commit `pnpm-lock.yaml`.
- Use `pnpm install --frozen-lockfile` in local CI-like checks and Docker builds.
- Install-script policy (strict):
- Dependency build/install scripts are allowlisted.
- After adding/updating dependencies, run `pnpm approve-builds` and review what is allowed.
- Check blocked scripts with `pnpm ignored-builds`.
### Vite Proxy (Development) ### Vite Proxy (Development)
The frontend now supports a dev proxy for API calls: The frontend now supports a dev proxy for API calls:
......
...@@ -2,11 +2,13 @@ FROM node:22-alpine AS build ...@@ -2,11 +2,13 @@ FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ RUN corepack enable
RUN npm ci
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN npm run build RUN pnpm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
......
This diff is collapsed.
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
...@@ -32,5 +33,8 @@ ...@@ -32,5 +33,8 @@
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.2.5"
},
"pnpm": {
"onlyBuiltDependencies": []
} }
} }
This diff is collapsed.
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import type { RetrievedDoc } from "../Retrieval/DocPanel"; import type { RetrievedDoc } from "../Retrieval/DocPanel";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown";
type MessageBubbleProps = { type MessageBubbleProps = {
role: "user" | "assistant"; role: "user" | "assistant";
...@@ -27,8 +28,14 @@ export default function MessageBubble({ ...@@ -27,8 +28,14 @@ export default function MessageBubble({
docSlugIndex, docSlugIndex,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const bubbleRef = useRef<HTMLDivElement | null>(null); const bubbleRef = useRef<HTMLDivElement | null>(null);
const [isRawView, setIsRawView] = useState(false);
const renderedText = useMemo(() => escapeAsterisksInsideMath(text), [text]);
useEffect(() => { useEffect(() => {
if (isRawView) {
return;
}
const typeset = () => { const typeset = () => {
if (window.MathJax?.typesetPromise && bubbleRef.current) { if (window.MathJax?.typesetPromise && bubbleRef.current) {
window.MathJax.typesetPromise([bubbleRef.current]).catch(() => undefined); window.MathJax.typesetPromise([bubbleRef.current]).catch(() => undefined);
...@@ -45,14 +52,28 @@ export default function MessageBubble({ ...@@ -45,14 +52,28 @@ export default function MessageBubble({
script.addEventListener("load", typeset); script.addEventListener("load", typeset);
return () => script.removeEventListener("load", typeset); return () => script.removeEventListener("load", typeset);
} }
}, [text]); }, [text, isRawView]);
return ( return (
<div className={`message-bubble ${role}`} ref={bubbleRef}> <div className={`message-bubble ${role}`} ref={bubbleRef}>
<div className="message-header">
<div className="message-role"> <div className="message-role">
{role === "user" ? t("roleUser") : t("roleAssistant")} {role === "user" ? t("roleUser") : t("roleAssistant")}
</div> </div>
<button
type="button"
className="message-toggle"
aria-pressed={isRawView}
title={isRawView ? t("showRendered") : t("showSource")}
onClick={() => setIsRawView((prev) => !prev)}
>
{isRawView ? t("showRendered") : t("showSource")}
</button>
</div>
<div className="message-text"> <div className="message-text">
{isRawView ? (
<pre className="message-raw-text">{text}</pre>
) : (
<ReactMarkdown <ReactMarkdown
urlTransform={(url) => { urlTransform={(url) => {
if (url.startsWith("doc://")) { if (url.startsWith("doc://")) {
...@@ -127,8 +148,9 @@ export default function MessageBubble({ ...@@ -127,8 +148,9 @@ export default function MessageBubble({
}, },
}} }}
> >
{text} {renderedText}
</ReactMarkdown> </ReactMarkdown>
)}
</div> </div>
</div> </div>
); );
......
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown";
declare global { declare global {
interface Window { interface Window {
...@@ -30,6 +31,10 @@ export default function DocCard({ ...@@ -30,6 +31,10 @@ export default function DocCard({
defaultOpen = false, defaultOpen = false,
}: DocCardProps) { }: DocCardProps) {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const renderedSnippet = useMemo(
() => escapeAsterisksInsideMath(snippetMarkdown),
[snippetMarkdown]
);
useEffect(() => { useEffect(() => {
const typeset = () => { const typeset = () => {
...@@ -60,7 +65,7 @@ export default function DocCard({ ...@@ -60,7 +65,7 @@ export default function DocCard({
</summary> </summary>
{subtitle ? <div className="doc-subtitle">{subtitle}</div> : null} {subtitle ? <div className="doc-subtitle">{subtitle}</div> : null}
<div className="doc-snippet" ref={contentRef}> <div className="doc-snippet" ref={contentRef}>
<ReactMarkdown>{snippetMarkdown}</ReactMarkdown> <ReactMarkdown>{renderedSnippet}</ReactMarkdown>
</div> </div>
<div className="doc-actions"> <div className="doc-actions">
<button className="btn small" type="button" onClick={onInspect}> <button className="btn small" type="button" onClick={onInspect}>
......
...@@ -36,6 +36,8 @@ ...@@ -36,6 +36,8 @@
send: "Send", send: "Send",
roleUser: "user", roleUser: "user",
roleAssistant: "assistant", roleAssistant: "assistant",
showSource: "Show source",
showRendered: "Show rendered",
noTasksAvailable: "No tasks available", noTasksAvailable: "No tasks available",
noTaskSelected: "No task selected.", noTaskSelected: "No task selected.",
savedChats: "Saved Chats", savedChats: "Saved Chats",
...@@ -68,6 +70,8 @@ ...@@ -68,6 +70,8 @@
"The tutor backend is currently unavailable or still starting up. We will keep trying automatically.", "The tutor backend is currently unavailable or still starting up. We will keep trying automatically.",
retryConnection: "Retry now", retryConnection: "Retry now",
lastCheckFailed: "Last check: {detail}", lastCheckFailed: "Last check: {detail}",
deepLinkInvalidTask: "Invalid task link. Please choose a task manually.",
deepLinkInitFailed: "Task link initialization failed. Please choose a task manually.",
}, },
de: { de: {
chats: "Chats", chats: "Chats",
...@@ -107,6 +111,8 @@ ...@@ -107,6 +111,8 @@
send: "Senden", send: "Senden",
roleUser: "nutzer", roleUser: "nutzer",
roleAssistant: "assistent", roleAssistant: "assistent",
showSource: "Formeltext anzeigen",
showRendered: "Gerendert anzeigen",
noTasksAvailable: "Keine Aufgaben verfügbar", noTasksAvailable: "Keine Aufgaben verfügbar",
noTaskSelected: "Keine Aufgabe ausgewählt.", noTaskSelected: "Keine Aufgabe ausgewählt.",
savedChats: "Gespeicherte Chats", savedChats: "Gespeicherte Chats",
...@@ -143,6 +149,10 @@ ...@@ -143,6 +149,10 @@
"Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.", "Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.",
retryConnection: "Erneut prüfen", retryConnection: "Erneut prüfen",
lastCheckFailed: "Letzte Prüfung: {detail}", lastCheckFailed: "Letzte Prüfung: {detail}",
deepLinkInvalidTask:
"Ungültiger Aufgaben-Link. Bitte wähle die Aufgabe manuell aus.",
deepLinkInitFailed:
"Der Aufgaben-Link konnte nicht initialisiert werden. Bitte wähle die Aufgabe manuell aus.",
}, },
} as const; } as const;
......
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } 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";
...@@ -106,11 +106,13 @@ export default function ChatPage() { ...@@ -106,11 +106,13 @@ export default function ChatPage() {
.trim() .trim()
.toLowerCase() !== "false"; .toLowerCase() !== "false";
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { const {
chatSessionId, chatSessionId,
setChatSessionId, setChatSessionId,
isTaskModeEnabled, isTaskModeEnabled,
isTasksInitialized, isTasksInitialized,
taskFiles,
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
selectedTaskFile, selectedTaskFile,
...@@ -120,7 +122,6 @@ export default function ChatPage() { ...@@ -120,7 +122,6 @@ export default function ChatPage() {
switchOrchestrator, switchOrchestrator,
isOrchestratorSelectable, isOrchestratorSelectable,
orchestratorError, orchestratorError,
taskLocked,
lockTask, lockTask,
unlockTask, unlockTask,
setTaskRef, setTaskRef,
...@@ -142,19 +143,125 @@ export default function ChatPage() { ...@@ -142,19 +143,125 @@ export default function ChatPage() {
const [sections, setSections] = useState<RetrievedDoc[]>([]); const [sections, setSections] = useState<RetrievedDoc[]>([]);
const [retrievalLoading, setRetrievalLoading] = useState(false); const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState<string | null>(null); const [retrievalError, setRetrievalError] = useState<string | null>(null);
const [deepLinkError, setDeepLinkError] = useState<string | null>(null);
const [canvasStatus, setCanvasStatus] = useState<{ const [canvasStatus, setCanvasStatus] = useState<{
kind: "info" | "error" | "ok"; kind: "info" | "error" | "ok";
message: string; message: string;
} | null>(null); } | null>(null);
const processedDeepLinkRef = useRef<string>("");
const deepLinkTarget = useMemo(() => {
const orchestrator = String(searchParams.get("orchestrator") || "")
.trim()
.toLowerCase();
const fileId = String(searchParams.get("file_id") || "").trim();
const rawTaskId = String(searchParams.get("task_id") || "").trim();
const taskId =
/^\d{1,2}$/.test(rawTaskId) && rawTaskId.length < 2
? rawTaskId.padStart(2, "0")
: rawTaskId;
const hasFileId = Boolean(fileId);
const hasTaskId = Boolean(taskId);
const isTaskOrchestrator = orchestrator === "task";
const hasAnyTaskParam = hasFileId || hasTaskId;
const hasRequiredParams = hasFileId && hasTaskId;
const key = `${orchestrator}|${fileId}|${taskId}`;
return {
fileId,
taskId,
isTaskOrchestrator,
hasAnyTaskParam,
hasRequiredParams,
key,
};
}, [searchParams]);
useEffect(() => {
if (!isTasksInitialized) {
return;
}
if (!deepLinkTarget.isTaskOrchestrator || !deepLinkTarget.hasAnyTaskParam) {
return;
}
if (processedDeepLinkRef.current === deepLinkTarget.key) {
return;
}
processedDeepLinkRef.current = deepLinkTarget.key;
if (!deepLinkTarget.hasRequiredParams) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
return;
}
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
const selectedTask = selectedFile?.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedFile || !selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
return;
}
setSelectedOrchestrator("task");
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
unlockTask();
let cancelled = false;
void (async () => {
try {
await selectTask({
draft: chatSessionId,
fileId: selectedFile.file_id,
taskId: selectedTask.task_id,
});
if (!cancelled) {
setDeepLinkError(null);
}
} catch (error) {
if (!cancelled) {
setDeepLinkError(t("deepLinkInitFailed"));
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
}
void error;
}
})();
return () => {
cancelled = true;
};
}, [
chatSessionId,
deepLinkTarget,
isTasksInitialized,
navigate,
setSelectedOrchestrator,
setTaskRef,
taskFiles,
unlockTask,
]);
useEffect(() => { useEffect(() => {
if (!isTasksInitialized || !isTaskModeEnabled) { if (!isTasksInitialized || !isTaskModeEnabled) {
return; return;
} }
if (!selectedTaskRef || !taskLocked) { if (
deepLinkTarget.isTaskOrchestrator &&
deepLinkTarget.hasAnyTaskParam &&
processedDeepLinkRef.current !== deepLinkTarget.key
) {
return;
}
if (!selectedTaskRef) {
navigate("/select-task", { replace: true }); navigate("/select-task", { replace: true });
} }
}, [isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef, taskLocked]); }, [deepLinkTarget, isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef]);
const docIndexes = useMemo(() => { const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {}; const bySourceKey: Record<string, RetrievedDoc> = {};
...@@ -709,6 +816,7 @@ export default function ChatPage() { ...@@ -709,6 +816,7 @@ export default function ChatPage() {
</div> </div>
</header> </header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null} {orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
{deepLinkError ? <div className="chat-archive-error">{deepLinkError}</div> : null}
<main className="app-main"> <main className="app-main">
<section className="chat-column"> <section className="chat-column">
......
...@@ -420,6 +420,44 @@ body { ...@@ -420,6 +420,44 @@ body {
color: #6f675d; color: #6f675d;
} }
.message-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.message-toggle {
border: 1px solid #d8d1c4;
background: #fef9f0;
color: #6f675d;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
.message-toggle:hover {
background: #f6f0e4;
}
.message-text {
margin-top: 8px;
}
.message-raw-text {
margin: 0;
padding: 10px;
border-radius: 10px;
border: 1px solid #e2ded5;
background: #fffdf8;
font-family: "Fira Code", "Consolas", "Courier New", monospace;
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.composer { .composer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
......
const MATH_SEGMENT_PATTERN =
/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$(?:\\.|[^$\\\n])+\$)/g;
const escapeAsterisks = (value: string) => value.replace(/\*/g, "\\*");
export const escapeAsterisksInsideMath = (input: string): string => {
return input.replace(MATH_SEGMENT_PATTERN, (segment) => {
if (segment.startsWith("$$") && segment.endsWith("$$")) {
const inner = segment.slice(2, -2);
return `$$${escapeAsterisks(inner)}$$`;
}
if (segment.startsWith("\\[") && segment.endsWith("\\]")) {
const inner = segment.slice(2, -2);
return `\\[${escapeAsterisks(inner)}\\]`;
}
if (segment.startsWith("\\(") && segment.endsWith("\\)")) {
const inner = segment.slice(2, -2);
return `\\(${escapeAsterisks(inner)}\\)`;
}
if (segment.startsWith("$") && segment.endsWith("$")) {
const inner = segment.slice(1, -1);
return `$${escapeAsterisks(inner)}$`;
}
return segment;
});
};
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