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):
``` powershell
cd math-tutor/frontend
npm install
corepack enable
pnpm install --frozen-lockfile
```
## Run the app
......@@ -50,17 +51,47 @@ Frontend (Vite):
```powershell
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.
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
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)
The frontend now supports a dev proxy for API calls:
......
......@@ -2,11 +2,13 @@ FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN npm run build
RUN pnpm run build
FROM nginx:1.27-alpine
......
This diff is collapsed.
......@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
......@@ -32,5 +33,8 @@
},
"overrides": {
"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 type { RetrievedDoc } from "../Retrieval/DocPanel";
import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown";
type MessageBubbleProps = {
role: "user" | "assistant";
......@@ -27,8 +28,14 @@ export default function MessageBubble({
docSlugIndex,
}: MessageBubbleProps) {
const bubbleRef = useRef<HTMLDivElement | null>(null);
const [isRawView, setIsRawView] = useState(false);
const renderedText = useMemo(() => escapeAsterisksInsideMath(text), [text]);
useEffect(() => {
if (isRawView) {
return;
}
const typeset = () => {
if (window.MathJax?.typesetPromise && bubbleRef.current) {
window.MathJax.typesetPromise([bubbleRef.current]).catch(() => undefined);
......@@ -45,32 +52,75 @@ export default function MessageBubble({
script.addEventListener("load", typeset);
return () => script.removeEventListener("load", typeset);
}
}, [text]);
}, [text, isRawView]);
return (
<div className={`message-bubble ${role}`} ref={bubbleRef}>
<div className="message-role">
{role === "user" ? t("roleUser") : t("roleAssistant")}
<div className="message-header">
<div className="message-role">
{role === "user" ? t("roleUser") : t("roleAssistant")}
</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">
<ReactMarkdown
urlTransform={(url) => {
if (url.startsWith("doc://")) {
return url;
}
return defaultUrlTransform(url);
}}
components={{
a: ({ href, children, ...props }) => {
const target = href || "";
if (!target.startsWith("doc://")) {
const mdMatch = target.match(/([^/]+)\.md$/i);
if (!mdMatch) {
{isRawView ? (
<pre className="message-raw-text">{text}</pre>
) : (
<ReactMarkdown
urlTransform={(url) => {
if (url.startsWith("doc://")) {
return url;
}
return defaultUrlTransform(url);
}}
components={{
a: ({ href, children, ...props }) => {
const target = href || "";
if (!target.startsWith("doc://")) {
const mdMatch = target.match(/([^/]+)\.md$/i);
if (!mdMatch) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
{...props}
>
{children}
</a>
);
}
const file = mdMatch[1] || "";
const slugFromFile = file
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const slug = slugFromFile.split("-").pop() || slugFromFile;
const doc = docSlugIndex ? docSlugIndex[slug] : undefined;
return (
<a
href={href}
target="_blank"
rel="noreferrer"
onClick={(event) => {
if (!doc || !onInspectDoc) {
return;
}
event.preventDefault();
onInspectDoc(doc);
}}
{...props}
>
{children}
......@@ -78,18 +128,8 @@ export default function MessageBubble({
);
}
const file = mdMatch[1] || "";
const slugFromFile = file
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const slug = slugFromFile.split("-").pop() || slugFromFile;
const doc = docSlugIndex ? docSlugIndex[slug] : undefined;
const key = decodeURIComponent(target.slice("doc://".length));
const doc = docIndex ? docIndex[key] : undefined;
return (
<a
href={href}
......@@ -105,30 +145,12 @@ export default function MessageBubble({
{children}
</a>
);
}
const key = decodeURIComponent(target.slice("doc://".length));
const doc = docIndex ? docIndex[key] : undefined;
return (
<a
href={href}
onClick={(event) => {
if (!doc || !onInspectDoc) {
return;
}
event.preventDefault();
onInspectDoc(doc);
}}
{...props}
>
{children}
</a>
);
},
}}
>
{text}
</ReactMarkdown>
},
}}
>
{renderedText}
</ReactMarkdown>
)}
</div>
</div>
);
......
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown";
import { t } from "../../i18n";
import { escapeAsterisksInsideMath } from "../../utils/mathMarkdown";
declare global {
interface Window {
......@@ -30,6 +31,10 @@ export default function DocCard({
defaultOpen = false,
}: DocCardProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const renderedSnippet = useMemo(
() => escapeAsterisksInsideMath(snippetMarkdown),
[snippetMarkdown]
);
useEffect(() => {
const typeset = () => {
......@@ -60,7 +65,7 @@ export default function DocCard({
</summary>
{subtitle ? <div className="doc-subtitle">{subtitle}</div> : null}
<div className="doc-snippet" ref={contentRef}>
<ReactMarkdown>{snippetMarkdown}</ReactMarkdown>
<ReactMarkdown>{renderedSnippet}</ReactMarkdown>
</div>
<div className="doc-actions">
<button className="btn small" type="button" onClick={onInspect}>
......
......@@ -36,6 +36,8 @@
send: "Send",
roleUser: "user",
roleAssistant: "assistant",
showSource: "Show source",
showRendered: "Show rendered",
noTasksAvailable: "No tasks available",
noTaskSelected: "No task selected.",
savedChats: "Saved Chats",
......@@ -68,6 +70,8 @@
"The tutor backend is currently unavailable or still starting up. We will keep trying automatically.",
retryConnection: "Retry now",
lastCheckFailed: "Last check: {detail}",
deepLinkInvalidTask: "Invalid task link. Please choose a task manually.",
deepLinkInitFailed: "Task link initialization failed. Please choose a task manually.",
},
de: {
chats: "Chats",
......@@ -107,6 +111,8 @@
send: "Senden",
roleUser: "nutzer",
roleAssistant: "assistent",
showSource: "Formeltext anzeigen",
showRendered: "Gerendert anzeigen",
noTasksAvailable: "Keine Aufgaben verfügbar",
noTaskSelected: "Keine Aufgabe ausgewählt.",
savedChats: "Gespeicherte Chats",
......@@ -143,6 +149,10 @@
"Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.",
retryConnection: "Erneut prüfen",
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;
......
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
......@@ -106,11 +106,13 @@ export default function ChatPage() {
.trim()
.toLowerCase() !== "false";
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const {
chatSessionId,
setChatSessionId,
isTaskModeEnabled,
isTasksInitialized,
taskFiles,
selectedTaskRef,
selectedTask,
selectedTaskFile,
......@@ -120,7 +122,6 @@ export default function ChatPage() {
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
taskLocked,
lockTask,
unlockTask,
setTaskRef,
......@@ -142,19 +143,125 @@ export default function ChatPage() {
const [sections, setSections] = useState<RetrievedDoc[]>([]);
const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState<string | null>(null);
const [deepLinkError, setDeepLinkError] = useState<string | null>(null);
const [canvasStatus, setCanvasStatus] = useState<{
kind: "info" | "error" | "ok";
message: string;
} | 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(() => {
if (!isTasksInitialized || !isTaskModeEnabled) {
return;
}
if (!selectedTaskRef || !taskLocked) {
if (
deepLinkTarget.isTaskOrchestrator &&
deepLinkTarget.hasAnyTaskParam &&
processedDeepLinkRef.current !== deepLinkTarget.key
) {
return;
}
if (!selectedTaskRef) {
navigate("/select-task", { replace: true });
}
}, [isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef, taskLocked]);
}, [deepLinkTarget, isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef]);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
......@@ -709,6 +816,7 @@ export default function ChatPage() {
</div>
</header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
{deepLinkError ? <div className="chat-archive-error">{deepLinkError}</div> : null}
<main className="app-main">
<section className="chat-column">
......
......@@ -420,6 +420,44 @@ body {
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 {
display: flex;
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