Commit bca28457 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!16
parents 93c0d6c0 80cee48d
...@@ -2,6 +2,8 @@ MATHPIX_APP_ID="" ...@@ -2,6 +2,8 @@ MATHPIX_APP_ID=""
MATHPIX_APP_KEY="" MATHPIX_APP_KEY=""
POSTGRES_URL="" POSTGRES_URL=""
DAILY_LLM_CALL_LIMIT="100"
DAILY_LLM_TOKEN_LIMIT="50000"
FRONTEND_URL="http://localhost:5173" FRONTEND_URL="http://localhost:5173"
...@@ -28,4 +30,4 @@ OLLAMA_TIMEOUT="60" ...@@ -28,4 +30,4 @@ OLLAMA_TIMEOUT="60"
MISTRAL_CHAT_MODEL="mistral-large-3-675b-instruct-2512" MISTRAL_CHAT_MODEL="mistral-large-3-675b-instruct-2512"
MISTRAL_API_KEY="" MISTRAL_API_KEY=""
MISTRAL_CHAT_TIMEOUT="60" MISTRAL_CHAT_TIMEOUT="60"
MISTRAL_CHAT_TEMPERATURE="0.2" MISTRAL_CHAT_TEMPERATURE="0.2"
\ No newline at end of file
from __future__ import annotations from __future__ import annotations
import logging import logging
from threading import Lock
from typing import Any, Dict from typing import Any, Dict
import httpx import httpx
...@@ -8,10 +9,41 @@ import psycopg ...@@ -8,10 +9,41 @@ import psycopg
from fastapi import APIRouter from fastapi import APIRouter
import app.config as config import app.config as config
from app.deterministic_services import llm_quota
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_READINESS_LOCK = Lock()
_READINESS_STATE: Dict[str, Any] = {"status": "starting"}
def set_readiness_starting() -> None:
with _READINESS_LOCK:
_READINESS_STATE.clear()
_READINESS_STATE.update({"status": "starting"})
def set_readiness_ready(warmup: Dict[str, Any] | None = None) -> None:
with _READINESS_LOCK:
_READINESS_STATE.clear()
_READINESS_STATE.update({"status": "ready"})
if warmup is not None:
_READINESS_STATE["warmup"] = warmup
def set_readiness_failed(detail: str, *, checks: Dict[str, Any] | None = None) -> None:
with _READINESS_LOCK:
_READINESS_STATE.clear()
_READINESS_STATE.update({"status": "failed", "detail": detail})
if checks is not None:
_READINESS_STATE["checks"] = checks
def get_readiness_state() -> Dict[str, Any]:
with _READINESS_LOCK:
return dict(_READINESS_STATE)
def _check_ollama() -> dict: def _check_ollama() -> dict:
base_url = config.get_ollama_settings().base_url.rstrip("/") base_url = config.get_ollama_settings().base_url.rstrip("/")
...@@ -67,12 +99,36 @@ def _check_postgres() -> dict: ...@@ -67,12 +99,36 @@ def _check_postgres() -> dict:
return {"status": "error", "detail": str(exc)} return {"status": "error", "detail": str(exc)}
def _check_llm_quota() -> dict:
try:
pg_url = config.get_postgres_url()
except ValueError:
return {"status": "missing_config"}
try:
today_usage = llm_quota.get_today_usage(pg_url)
limits = config.get_llm_quota_settings()
return {
"status": "ok",
"usage_date": today_usage.usage_date.isoformat(),
"call_count": today_usage.call_count,
"token_count": today_usage.token_count,
"limits": {
"daily_call_limit": limits.daily_call_limit,
"daily_token_limit": limits.daily_token_limit,
},
}
except Exception as exc:
return {"status": "error", "detail": str(exc)}
@router.get("/api/health") @router.get("/api/health")
def health() -> Dict[str, Any]: def health() -> Dict[str, Any]:
services = { services = {
"ollama": _check_ollama(), "ollama": _check_ollama(),
"openai": _check_openai(), "openai": _check_openai(),
"postgres": _check_postgres(), "postgres": _check_postgres(),
"llm_quota": _check_llm_quota(),
} }
required_statuses = {"ok"} required_statuses = {"ok"}
overall = "ok" if all( overall = "ok" if all(
...@@ -81,6 +137,14 @@ def health() -> Dict[str, Any]: ...@@ -81,6 +137,14 @@ def health() -> Dict[str, Any]:
return {"status": overall, "services": services} return {"status": overall, "services": services}
@router.get("/api/health/ready")
def readiness() -> Dict[str, Any]:
state = get_readiness_state()
if state.get("status") == "ready":
return state
return {"status_code": 503, "content": state}
def run_startup_checks() -> Dict[str, Any]: def run_startup_checks() -> Dict[str, Any]:
result = health() result = health()
status = result.get("status") status = result.get("status")
......
...@@ -33,8 +33,8 @@ def get_embedding_settings() -> EmbeddingSettings: ...@@ -33,8 +33,8 @@ def get_embedding_settings() -> EmbeddingSettings:
return EmbeddingSettings( return EmbeddingSettings(
embedding_type=embedding_type, embedding_type=embedding_type,
model=os.getenv("SENTENCE_TRANSFORMER_MODEL", model=os.getenv("SENTENCE_TRANSFORMER_MODEL",
"jinaai/jina-embeddings-v4"), "jinaai/jina-embeddings-v5-text-small-retrieval"),
target_dim=int(os.getenv("EMBEDDING_DIM", "512")), target_dim=int(os.getenv("EMBEDDING_DIM", "1024")),
) )
if embedding_type == "openai-like": if embedding_type == "openai-like":
base_url = os.getenv("OPENAI_BASE_URL") base_url = os.getenv("OPENAI_BASE_URL")
...@@ -92,6 +92,12 @@ class MathpixSettings: ...@@ -92,6 +92,12 @@ class MathpixSettings:
app_key: str app_key: str
@dataclass(frozen=True)
class LLMQuotaSettings:
daily_call_limit: int | None
daily_token_limit: int | None
def _read_float(value: str | None) -> float | None: def _read_float(value: str | None) -> float | None:
if value is None or value == "": if value is None or value == "":
return None return None
...@@ -101,6 +107,15 @@ def _read_float(value: str | None) -> float | None: ...@@ -101,6 +107,15 @@ def _read_float(value: str | None) -> float | None:
return None return None
def _read_int(value: str | None) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except ValueError:
return None
def get_mathpix_settings() -> MathpixSettings: def get_mathpix_settings() -> MathpixSettings:
return MathpixSettings( return MathpixSettings(
app_id=os.getenv("MATHPIX_APP_ID"), app_id=os.getenv("MATHPIX_APP_ID"),
...@@ -172,3 +187,10 @@ def get_postgres_url() -> str: ...@@ -172,3 +187,10 @@ def get_postgres_url() -> str:
if not pg_url: if not pg_url:
raise ValueError("Missing POSTGRES_URL") raise ValueError("Missing POSTGRES_URL")
return pg_url return pg_url
def get_llm_quota_settings() -> LLMQuotaSettings:
return LLMQuotaSettings(
daily_call_limit=_read_int(os.getenv("DAILY_LLM_CALL_LIMIT")),
daily_token_limit=_read_int(os.getenv("DAILY_LLM_TOKEN_LIMIT")),
)
...@@ -158,7 +158,9 @@ class SentenceTransformerEmbeddings(BaseEmbeddings): ...@@ -158,7 +158,9 @@ class SentenceTransformerEmbeddings(BaseEmbeddings):
"""Liefert das SentenceTransformer-Modell (lazy load).""" """Liefert das SentenceTransformer-Modell (lazy load)."""
if self._model is None: if self._model is None:
self._model = SentenceTransformer( self._model = SentenceTransformer(
self.model_name, trust_remote_code=True) self.model_name,
trust_remote_code=True,
)
self._model.max_seq_length = 512 self._model.max_seq_length = 512
return self._model return self._model
...@@ -167,7 +169,7 @@ class SentenceTransformerEmbeddings(BaseEmbeddings): ...@@ -167,7 +169,7 @@ class SentenceTransformerEmbeddings(BaseEmbeddings):
passage_embeddings = self.model.encode( passage_embeddings = self.model.encode(
sentences=texts, sentences=texts,
task="retrieval", task="retrieval",
prompt_name="passage", prompt_name="document",
) )
return [self._truncate([float(x) for x in emb]) for emb in passage_embeddings] return [self._truncate([float(x) for x in emb]) for emb in passage_embeddings]
......
import inspect import inspect
import json import json
from datetime import date
from typing import Any, Callable from typing import Any, Callable
import ollama import ollama
...@@ -7,6 +8,7 @@ from openai import OpenAI ...@@ -7,6 +8,7 @@ from openai import OpenAI
from mistralai.client import Mistral from mistralai.client import Mistral
from app import config from app import config
from app.deterministic_services import llm_quota
def _filter_kwargs(func, kwargs: dict) -> dict: def _filter_kwargs(func, kwargs: dict) -> dict:
...@@ -23,6 +25,39 @@ def _extract_message(response) -> object: ...@@ -23,6 +25,39 @@ def _extract_message(response) -> object:
return response.message return response.message
return {} return {}
def _extract_total_tokens(response: object) -> int:
usage = None
if isinstance(response, dict):
usage = response.get("usage")
elif hasattr(response, "usage"):
usage = getattr(response, "usage")
if usage is None:
return 0
if isinstance(usage, dict):
total = usage.get("total_tokens")
if total is not None:
return int(total or 0)
prompt = usage.get("prompt_tokens") or usage.get("input_tokens") or 0
completion = usage.get("completion_tokens") or usage.get("output_tokens") or 0
return int(prompt) + int(completion)
total = getattr(usage, "total_tokens", None)
if total is not None:
return int(total or 0)
prompt = getattr(usage, "prompt_tokens", None) or getattr(usage, "input_tokens", None) or 0
completion = getattr(usage, "completion_tokens", None) or getattr(usage, "output_tokens", None) or 0
return int(prompt) + int(completion)
def _record_call(result: dict, tokens: int | None = None) -> dict:
pg_url = config.get_postgres_url()
token_count = _extract_total_tokens(result.get("raw")) if tokens is None else tokens
llm_quota.record_usage(pg_url, date.today(), calls=1, tokens=token_count)
return result
def _chat_openai(messages: list[dict]) -> dict: def _chat_openai(messages: list[dict]) -> dict:
settings = config.get_openai_chat_settings() settings = config.get_openai_chat_settings()
if not settings: if not settings:
...@@ -77,21 +112,72 @@ def chat( ...@@ -77,21 +112,72 @@ def chat(
use_ollama: bool = False, use_ollama: bool = False,
use_mistral: bool = False, use_mistral: bool = False,
) -> dict: ) -> dict:
quota_settings = config.get_llm_quota_settings()
pg_url = config.get_postgres_url()
if use_mistral and tools: if use_mistral and tools:
raise RuntimeError("Mistral chat is currently only implemented for calls without tools.") raise RuntimeError("Mistral chat is currently only implemented for calls without tools.")
if use_mistral: if use_mistral:
mistral_result = _chat_mistral(messages) mistral_settings = config.get_mistral_chat_settings()
if not mistral_settings:
return {}
llm_quota.ensure_within_limits(
pg_url,
date.today(),
call_limit=quota_settings.daily_call_limit,
token_limit=quota_settings.daily_token_limit,
add_calls=1,
add_tokens=0,
)
try:
mistral_result = _chat_mistral(messages)
except Exception:
_record_call({"raw": None}, tokens=0)
raise
if mistral_result: if mistral_result:
return mistral_result return _record_call(mistral_result)
_record_call({"raw": None}, tokens=0)
return mistral_result
if not tools and not use_ollama: if not tools and not use_ollama:
openai_result = _chat_openai(messages) openai_settings = config.get_openai_chat_settings()
if openai_result: if openai_settings:
llm_quota.ensure_within_limits(
pg_url,
date.today(),
call_limit=quota_settings.daily_call_limit,
token_limit=quota_settings.daily_token_limit,
add_calls=1,
add_tokens=0,
)
try:
openai_result = _chat_openai(messages)
except Exception:
_record_call({"raw": None}, tokens=0)
raise
if openai_result:
return _record_call(openai_result)
_record_call({"raw": None}, tokens=0)
return openai_result return openai_result
mistral_result = _chat_mistral(messages) mistral_settings = config.get_mistral_chat_settings()
if mistral_result: if mistral_settings:
llm_quota.ensure_within_limits(
pg_url,
date.today(),
call_limit=quota_settings.daily_call_limit,
token_limit=quota_settings.daily_token_limit,
add_calls=1,
add_tokens=0,
)
try:
mistral_result = _chat_mistral(messages)
except Exception:
_record_call({"raw": None}, tokens=0)
raise
if mistral_result:
return _record_call(mistral_result)
_record_call({"raw": None}, tokens=0)
return mistral_result return mistral_result
settings = config.get_ollama_settings() settings = config.get_ollama_settings()
......
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Any
import psycopg
from psycopg.rows import dict_row
DATABASE_CREATION_SQL = """
CREATE TABLE IF NOT EXISTS llm_daily_usage (
usage_date DATE PRIMARY KEY,
call_count BIGINT NOT NULL DEFAULT 0,
token_count BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""
@dataclass(frozen=True)
class LLMDailyUsage:
usage_date: date
call_count: int
token_count: int
updated_at: str | None = None
def init_db(pg_url: str) -> None:
with psycopg.connect(pg_url) as conn:
with conn.cursor() as cur:
cur.execute(DATABASE_CREATION_SQL)
conn.commit()
def _ensure_row(conn: psycopg.Connection[Any], usage_date: date) -> None:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO llm_daily_usage (usage_date)
VALUES (%s)
ON CONFLICT (usage_date) DO NOTHING
""",
(usage_date,),
)
def get_daily_usage(pg_url: str, usage_date: date) -> LLMDailyUsage:
init_db(pg_url)
with psycopg.connect(pg_url, row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT usage_date, call_count, token_count, updated_at
FROM llm_daily_usage
WHERE usage_date = %s
""",
(usage_date,),
)
row = cur.fetchone()
if not row:
return LLMDailyUsage(usage_date=usage_date, call_count=0, token_count=0)
return LLMDailyUsage(
usage_date=row["usage_date"],
call_count=int(row["call_count"]),
token_count=int(row["token_count"]),
updated_at=row.get("updated_at").isoformat() if row.get("updated_at") else None,
)
def ensure_within_limits(
pg_url: str,
usage_date: date,
*,
call_limit: int | None,
token_limit: int | None,
add_calls: int = 1,
add_tokens: int = 0,
) -> None:
init_db(pg_url)
with psycopg.connect(pg_url) as conn:
with conn.transaction():
_ensure_row(conn, usage_date)
with conn.cursor() as cur:
cur.execute(
"""
SELECT call_count, token_count
FROM llm_daily_usage
WHERE usage_date = %s
FOR UPDATE
""",
(usage_date,),
)
row = cur.fetchone() or (0, 0)
next_calls = int(row[0]) + add_calls
next_tokens = int(row[1]) + add_tokens
if call_limit is not None and next_calls > call_limit:
raise QuotaExceededError("daily LLM call limit exceeded")
if token_limit is not None and next_tokens > token_limit:
raise QuotaExceededError("daily LLM token limit exceeded")
def record_usage(
pg_url: str,
usage_date: date,
*,
calls: int = 1,
tokens: int = 0,
) -> None:
init_db(pg_url)
with psycopg.connect(pg_url) as conn:
with conn.transaction():
_ensure_row(conn, usage_date)
with conn.cursor() as cur:
cur.execute(
"""
UPDATE llm_daily_usage
SET call_count = call_count + %s,
token_count = token_count + %s,
updated_at = NOW()
WHERE usage_date = %s
""",
(calls, tokens, usage_date),
)
def get_today_usage(pg_url: str) -> LLMDailyUsage:
return get_daily_usage(pg_url, date.today())
class QuotaExceededError(RuntimeError):
pass
...@@ -216,6 +216,10 @@ def upsert_docs(pg_url: str, docs: List[DocRecord], embeddings: List[List[float] ...@@ -216,6 +216,10 @@ def upsert_docs(pg_url: str, docs: List[DocRecord], embeddings: List[List[float]
rows = [] rows = []
for doc, emb in zip(docs, embeddings): for doc, emb in zip(docs, embeddings):
if len(emb) != embedding_dim:
raise ValueError(
f"Embedding dimension {len(emb)} does not match configured target_dim {embedding_dim}"
)
m = doc.metadata m = doc.metadata
rows.append( rows.append(
{ {
......
...@@ -5,7 +5,8 @@ from fastapi import FastAPI ...@@ -5,7 +5,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, context, health, orchestrator, 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, llm_quota
from app import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -13,12 +14,33 @@ logger = logging.getLogger(__name__) ...@@ -13,12 +14,33 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
health.run_startup_checks() health.set_readiness_starting()
startup_result = health.run_startup_checks()
if startup_result.get("status") != "ok":
health.set_readiness_failed(
"Startup health checks degraded",
checks=startup_result,
)
yield
return
try:
llm_quota.init_db(config.get_postgres_url())
logger.info("LLM quota table ensured")
except Exception:
logger.exception("LLM quota init failed")
health.set_readiness_failed("LLM quota init failed")
yield
return
try: try:
warmup_timing = embedding_provider.warmup_embedder() warmup_timing = embedding_provider.warmup_embedder()
logger.info("Embedding warmup finished: %s", warmup_timing) logger.info("Embedding warmup finished: %s", warmup_timing)
except Exception: except Exception:
logger.exception("Embedding warmup failed") logger.exception("Embedding warmup failed")
health.set_readiness_failed("Embedding warmup failed")
else:
health.set_readiness_ready(warmup=warmup_timing)
yield yield
......
from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
import unittest
from unittest.mock import MagicMock, patch
BACKEND_ROOT = Path(__file__).resolve().parents[1]
def _load_module(name: str, relative_path: str):
path = BACKEND_ROOT / relative_path
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to load module spec for {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
config = _load_module("backend_config_test_module", "app/config.py")
fake_sentence_transformers = type(sys)("sentence_transformers")
fake_sentence_transformers.SentenceTransformer = object
sys.modules.setdefault("sentence_transformers", fake_sentence_transformers)
embeddings = _load_module("backend_embeddings_test_module", "app/deterministic_services/embeddings.py")
class SentenceTransformerJinaV5Test(unittest.TestCase):
def test_config_defaults_to_jina_v5(self) -> None:
with patch.dict(os.environ, {"EMBEDDING_TYPE": "sentence-transformer"}, clear=False):
settings = config.get_embedding_settings()
self.assertEqual(settings.model, "jinaai/jina-embeddings-v5-text-small-retrieval")
self.assertEqual(settings.target_dim, 1024)
def test_embedder_uses_document_and_query_prompts(self) -> None:
fake_model = MagicMock()
fake_model.encode.side_effect = [
[[0.1, 0.2, 0.3, 0.4]],
[[0.4, 0.3, 0.2, 0.1]],
]
with patch.object(embeddings, "SentenceTransformer", return_value=fake_model) as ctor:
embedder = embeddings.SentenceTransformerEmbeddings(
embeddings.SentenceTransformerConfig(
model="jinaai/jina-embeddings-v5-text-small-retrieval",
target_dim=4,
)
)
docs = embedder.embed_documents(["doc text"])
query = embedder.embed_query("query text")
ctor.assert_called_once()
self.assertEqual(fake_model.encode.call_args_list[0].kwargs["prompt_name"], "document")
self.assertEqual(fake_model.encode.call_args_list[1].kwargs["prompt_name"], "query")
self.assertEqual(len(docs[0]), 4)
self.assertEqual(len(query), 4)
if __name__ == "__main__":
unittest.main()
import json
import unittest
from app.api import health
from fastapi.responses import JSONResponse
class HealthReadinessUnitTest(unittest.TestCase):
def tearDown(self) -> None:
health.set_readiness_starting()
def test_readiness_returns_503_while_starting(self) -> None:
health.set_readiness_starting()
response = health.readiness()
self.assertIsInstance(response, JSONResponse)
self.assertEqual(response.status_code, 503)
self.assertEqual(json.loads(response.body), {"status": "starting"})
def test_readiness_returns_200_when_ready(self) -> None:
warmup = {"total_warmup_ms": 123.45}
health.set_readiness_ready(warmup=warmup)
response = health.readiness()
self.assertEqual(response, {"status": "ready", "warmup": warmup})
def test_readiness_returns_503_when_failed(self) -> None:
checks = {"status": "degraded"}
health.set_readiness_failed("Embedding warmup failed", checks=checks)
response = health.readiness()
self.assertIsInstance(response, JSONResponse)
self.assertEqual(response.status_code, 503)
self.assertEqual(
json.loads(response.body),
{
"status": "failed",
"detail": "Embedding warmup failed",
"checks": checks,
},
)
if __name__ == "__main__":
unittest.main()
...@@ -7,6 +7,18 @@ services: ...@@ -7,6 +7,18 @@ services:
- ../backend/.env - ../backend/.env
expose: expose:
- "8000" - "8000"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health/ready')",
]
interval: 5s
timeout: 3s
retries: 10
start_period: 120s
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
......
math-tutor/frontend/SuMINT-Logo.png

157 KB | W: | H:

math-tutor/frontend/SuMINT-Logo.png

100 KB | W: | H:

math-tutor/frontend/SuMINT-Logo.png
math-tutor/frontend/SuMINT-Logo.png
math-tutor/frontend/SuMINT-Logo.png
math-tutor/frontend/SuMINT-Logo.png
  • 2-up
  • Swipe
  • Onion skin
...@@ -11,6 +11,7 @@ type ChatWindowProps = { ...@@ -11,6 +11,7 @@ type ChatWindowProps = {
onSend: () => void; onSend: () => void;
onHistoryNavigate?: (direction: "older" | "newer") => boolean; onHistoryNavigate?: (direction: "older" | "newer") => boolean;
onToggleCanvas?: () => void; onToggleCanvas?: () => void;
onUploadSolution?: (file: File) => void | Promise<void>;
onInspectDoc?: (doc: RetrievedDoc) => void; onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>; docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>; docSlugIndex?: Record<string, RetrievedDoc>;
...@@ -23,6 +24,7 @@ export default function ChatWindow({ ...@@ -23,6 +24,7 @@ export default function ChatWindow({
onSend, onSend,
onHistoryNavigate, onHistoryNavigate,
onToggleCanvas, onToggleCanvas,
onUploadSolution,
onInspectDoc, onInspectDoc,
docIndex, docIndex,
docSlugIndex, docSlugIndex,
...@@ -42,6 +44,7 @@ export default function ChatWindow({ ...@@ -42,6 +44,7 @@ export default function ChatWindow({
onSend={onSend} onSend={onSend}
onHistoryNavigate={onHistoryNavigate} onHistoryNavigate={onHistoryNavigate}
onToggleCanvas={onToggleCanvas} onToggleCanvas={onToggleCanvas}
onUploadSolution={onUploadSolution}
/> />
</div> </div>
); );
......
import { EditPencil, Send } from "iconoir-react"; import { useRef } from "react";
import { EditPencil, Send, Upload } from "iconoir-react";
import { t } from "../../i18n"; import { t } from "../../i18n";
type MessageInputProps = { type MessageInputProps = {
...@@ -7,6 +8,7 @@ type MessageInputProps = { ...@@ -7,6 +8,7 @@ type MessageInputProps = {
onSend: () => void; onSend: () => void;
onHistoryNavigate?: (direction: "older" | "newer") => boolean; onHistoryNavigate?: (direction: "older" | "newer") => boolean;
onToggleCanvas?: () => void; onToggleCanvas?: () => void;
onUploadSolution?: (file: File) => void | Promise<void>;
}; };
export default function MessageInput({ export default function MessageInput({
...@@ -15,8 +17,25 @@ export default function MessageInput({ ...@@ -15,8 +17,25 @@ export default function MessageInput({
onSend, onSend,
onHistoryNavigate, onHistoryNavigate,
onToggleCanvas, onToggleCanvas,
onUploadSolution,
}: MessageInputProps) { }: MessageInputProps) {
const canSend = value.trim().length > 0; const canSend = value.trim().length > 0;
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const handleUploadClick = () => {
uploadInputRef.current?.click();
};
const handleUploadChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
await onUploadSolution?.(file);
};
return ( return (
<div className="composer"> <div className="composer">
...@@ -30,6 +49,22 @@ export default function MessageInput({ ...@@ -30,6 +49,22 @@ export default function MessageInput({
> >
<EditPencil width={18} height={18} aria-hidden="true" /> <EditPencil width={18} height={18} aria-hidden="true" />
</button> </button>
<button
className="btn"
type="button"
onClick={handleUploadClick}
aria-label={t("uploadSolution")}
title={t("uploadSolution")}
>
<Upload width={18} height={18} aria-hidden="true" />
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
className="canvas-upload-input"
onChange={handleUploadChange}
/>
<button <button
className="btn primary" className="btn primary"
type="button" type="button"
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
pen: "Pen", pen: "Pen",
clear: "Clear", clear: "Clear",
saveAndConvert: "Save + Convert", saveAndConvert: "Save + Convert",
uploadSolution: "Upload solution image",
hide: "Hide", hide: "Hide",
show: "Show", show: "Show",
newChat: "New Chat", newChat: "New Chat",
...@@ -55,6 +56,10 @@ ...@@ -55,6 +56,10 @@
failedLoadSavedChats: "Could not load saved chats.", failedLoadSavedChats: "Could not load saved chats.",
failedLoadSelectedChat: "Could not load the selected chat.", failedLoadSelectedChat: "Could not load the selected chat.",
savingDrawingConverting: "Saving drawing and converting to LaTeX...", savingDrawingConverting: "Saving drawing and converting to LaTeX...",
uploadingSolutionConverting: "Uploading image and converting to LaTeX...",
uploadFailed: "Uploading the image failed.",
uploadReadFailed: "Could not read the selected image.",
uploadInvalidFileType: "Please select an image file.",
saveFailedStatus: "Saving failed (status: {status}).", saveFailedStatus: "Saving failed (status: {status}).",
canvasSaveFailed: "Saving the canvas failed.", canvasSaveFailed: "Saving the canvas failed.",
canvasSaved: "Canvas saved.", canvasSaved: "Canvas saved.",
...@@ -91,6 +96,7 @@ ...@@ -91,6 +96,7 @@
pen: "Stift", pen: "Stift",
clear: "Leeren", clear: "Leeren",
saveAndConvert: "Speichern + Konvertieren", saveAndConvert: "Speichern + Konvertieren",
uploadSolution: "Lösungsbild hochladen",
hide: "Verstecken", hide: "Verstecken",
show: "Anzeigen", show: "Anzeigen",
newChat: "neuer Chat", newChat: "neuer Chat",
...@@ -138,6 +144,11 @@ ...@@ -138,6 +144,11 @@
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...",
uploadingSolutionConverting:
"Bild wird hochgeladen und in LaTeX konvertiert...",
uploadFailed: "Das Hochladen des Bildes ist fehlgeschlagen.",
uploadReadFailed: "Das ausgewählte Bild konnte nicht gelesen werden.",
uploadInvalidFileType: "Bitte wähle eine Bilddatei aus.",
saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}). Probiere es nochmal oder lade die Seite neu.", saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}). Probiere es nochmal oder lade die Seite neu.",
canvasSaveFailed: "Speichern des Canvas fehlgeschlagen. Probiere es nochmal oder lade die Seite neu.", canvasSaveFailed: "Speichern des Canvas fehlgeschlagen. Probiere es nochmal oder lade die Seite neu.",
canvasSaved: "Canvas gespeichert.", canvasSaved: "Canvas gespeichert.",
......
...@@ -737,10 +737,13 @@ export default function ChatPage() { ...@@ -737,10 +737,13 @@ export default function ChatPage() {
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat"); navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat");
}; };
const handleCanvasSave = async (dataUrl: string) => { const handleCanvasSave = async (
dataUrl: string,
statusMessage = t("savingDrawingConverting")
) => {
setCanvasStatus({ setCanvasStatus({
kind: "info", kind: "info",
message: t("savingDrawingConverting"), message: statusMessage,
}); });
try { try {
...@@ -787,6 +790,61 @@ export default function ChatPage() { ...@@ -787,6 +790,61 @@ export default function ChatPage() {
} }
}; };
const fileToPngDataUrl = async (file: File): Promise<string> => {
if (!file.type.startsWith("image/")) {
throw new Error(t("uploadInvalidFileType"));
}
const fileDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error(t("uploadReadFailed")));
};
reader.onerror = () => reject(new Error(t("uploadReadFailed")));
reader.readAsDataURL(file);
});
if (file.type === "image/png") {
return fileDataUrl;
}
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(t("uploadReadFailed")));
img.src = fileDataUrl;
});
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error(t("uploadReadFailed"));
}
context.drawImage(image, 0, 0);
return canvas.toDataURL("image/png");
};
const handleCanvasUpload = async (file: File) => {
if (!file) {
return;
}
try {
const dataUrl = await fileToPngDataUrl(file);
await handleCanvasSave(dataUrl, t("uploadingSolutionConverting"));
} catch (error) {
const message = error instanceof Error ? error.message : t("uploadFailed");
setCanvasStatus({ kind: "error", message });
void error;
}
};
const handleCiteDoc = (docId: string) => { const handleCiteDoc = (docId: string) => {
setDraft((prev) => (prev ? `${prev} [${docId}]` : `[${docId}]`)); setDraft((prev) => (prev ? `${prev} [${docId}]` : `[${docId}]`));
}; };
...@@ -891,6 +949,7 @@ export default function ChatPage() { ...@@ -891,6 +949,7 @@ export default function ChatPage() {
onSend={handleSend} onSend={handleSend}
onHistoryNavigate={handleHistoryNavigate} onHistoryNavigate={handleHistoryNavigate}
onToggleCanvas={handleToggleCanvas} onToggleCanvas={handleToggleCanvas}
onUploadSolution={handleCanvasUpload}
onInspectDoc={handleInspectDoc} onInspectDoc={handleInspectDoc}
docIndex={docIndexes.bySourceKey} docIndex={docIndexes.bySourceKey}
docSlugIndex={docIndexes.bySlug} docSlugIndex={docIndexes.bySlug}
......
...@@ -42,7 +42,7 @@ body { ...@@ -42,7 +42,7 @@ body {
.brand-logo { .brand-logo {
display: block; display: block;
width: auto; width: auto;
height: 100px; height: 70px;
max-width: 300px; max-width: 300px;
object-fit: contain; object-fit: contain;
} }
...@@ -614,6 +614,18 @@ body { ...@@ -614,6 +614,18 @@ body {
margin-left: auto; margin-left: auto;
} }
.canvas-upload-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.canvas-hidden { .canvas-hidden {
padding: 12px; padding: 12px;
border-radius: 12px; border-radius: 12px;
...@@ -776,6 +788,10 @@ body { ...@@ -776,6 +788,10 @@ body {
.canvas-actions-header { .canvas-actions-header {
margin-left: 0; margin-left: 0;
} }
.canvas-actions-header .btn {
flex: 1 1 120px;
}
} }
.app-loading { .app-loading {
......
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