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=""
MATHPIX_APP_KEY=""
POSTGRES_URL=""
DAILY_LLM_CALL_LIMIT="100"
DAILY_LLM_TOKEN_LIMIT="50000"
FRONTEND_URL="http://localhost:5173"
......
from __future__ import annotations
import logging
from threading import Lock
from typing import Any, Dict
import httpx
......@@ -8,10 +9,41 @@ import psycopg
from fastapi import APIRouter
import app.config as config
from app.deterministic_services import llm_quota
router = APIRouter()
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:
base_url = config.get_ollama_settings().base_url.rstrip("/")
......@@ -67,12 +99,36 @@ def _check_postgres() -> dict:
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")
def health() -> Dict[str, Any]:
services = {
"ollama": _check_ollama(),
"openai": _check_openai(),
"postgres": _check_postgres(),
"llm_quota": _check_llm_quota(),
}
required_statuses = {"ok"}
overall = "ok" if all(
......@@ -81,6 +137,14 @@ def health() -> Dict[str, Any]:
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]:
result = health()
status = result.get("status")
......
......@@ -33,8 +33,8 @@ def get_embedding_settings() -> EmbeddingSettings:
return EmbeddingSettings(
embedding_type=embedding_type,
model=os.getenv("SENTENCE_TRANSFORMER_MODEL",
"jinaai/jina-embeddings-v4"),
target_dim=int(os.getenv("EMBEDDING_DIM", "512")),
"jinaai/jina-embeddings-v5-text-small-retrieval"),
target_dim=int(os.getenv("EMBEDDING_DIM", "1024")),
)
if embedding_type == "openai-like":
base_url = os.getenv("OPENAI_BASE_URL")
......@@ -92,6 +92,12 @@ class MathpixSettings:
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:
if value is None or value == "":
return None
......@@ -101,6 +107,15 @@ def _read_float(value: str | None) -> float | 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:
return MathpixSettings(
app_id=os.getenv("MATHPIX_APP_ID"),
......@@ -172,3 +187,10 @@ def get_postgres_url() -> str:
if not pg_url:
raise ValueError("Missing POSTGRES_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):
"""Liefert das SentenceTransformer-Modell (lazy load)."""
if self._model is None:
self._model = SentenceTransformer(
self.model_name, trust_remote_code=True)
self.model_name,
trust_remote_code=True,
)
self._model.max_seq_length = 512
return self._model
......@@ -167,7 +169,7 @@ class SentenceTransformerEmbeddings(BaseEmbeddings):
passage_embeddings = self.model.encode(
sentences=texts,
task="retrieval",
prompt_name="passage",
prompt_name="document",
)
return [self._truncate([float(x) for x in emb]) for emb in passage_embeddings]
......
import inspect
import json
from datetime import date
from typing import Any, Callable
import ollama
......@@ -7,6 +8,7 @@ from openai import OpenAI
from mistralai.client import Mistral
from app import config
from app.deterministic_services import llm_quota
def _filter_kwargs(func, kwargs: dict) -> dict:
......@@ -23,6 +25,39 @@ def _extract_message(response) -> object:
return response.message
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:
settings = config.get_openai_chat_settings()
if not settings:
......@@ -77,21 +112,72 @@ def chat(
use_ollama: bool = False,
use_mistral: bool = False,
) -> dict:
quota_settings = config.get_llm_quota_settings()
pg_url = config.get_postgres_url()
if use_mistral and tools:
raise RuntimeError("Mistral chat is currently only implemented for calls without tools.")
if use_mistral:
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:
return _record_call(mistral_result)
_record_call({"raw": None}, tokens=0)
return mistral_result
if not tools and not use_ollama:
openai_settings = config.get_openai_chat_settings()
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
mistral_settings = config.get_mistral_chat_settings()
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
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]
rows = []
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
rows.append(
{
......
......@@ -5,7 +5,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, context, health, orchestrator, tasks
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__)
......@@ -13,12 +14,33 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
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:
warmup_timing = embedding_provider.warmup_embedder()
logger.info("Embedding warmup finished: %s", warmup_timing)
except Exception:
logger.exception("Embedding warmup failed")
health.set_readiness_failed("Embedding warmup failed")
else:
health.set_readiness_ready(warmup=warmup_timing)
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:
- ../backend/.env
expose:
- "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
volumes:
- ./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 = {
onSend: () => void;
onHistoryNavigate?: (direction: "older" | "newer") => boolean;
onToggleCanvas?: () => void;
onUploadSolution?: (file: File) => void | Promise<void>;
onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>;
......@@ -23,6 +24,7 @@ export default function ChatWindow({
onSend,
onHistoryNavigate,
onToggleCanvas,
onUploadSolution,
onInspectDoc,
docIndex,
docSlugIndex,
......@@ -42,6 +44,7 @@ export default function ChatWindow({
onSend={onSend}
onHistoryNavigate={onHistoryNavigate}
onToggleCanvas={onToggleCanvas}
onUploadSolution={onUploadSolution}
/>
</div>
);
......
import { EditPencil, Send } from "iconoir-react";
import { useRef } from "react";
import { EditPencil, Send, Upload } from "iconoir-react";
import { t } from "../../i18n";
type MessageInputProps = {
......@@ -7,6 +8,7 @@ type MessageInputProps = {
onSend: () => void;
onHistoryNavigate?: (direction: "older" | "newer") => boolean;
onToggleCanvas?: () => void;
onUploadSolution?: (file: File) => void | Promise<void>;
};
export default function MessageInput({
......@@ -15,8 +17,25 @@ export default function MessageInput({
onSend,
onHistoryNavigate,
onToggleCanvas,
onUploadSolution,
}: MessageInputProps) {
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 (
<div className="composer">
......@@ -30,6 +49,22 @@ export default function MessageInput({
>
<EditPencil width={18} height={18} aria-hidden="true" />
</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
className="btn primary"
type="button"
......
......@@ -12,6 +12,7 @@
pen: "Pen",
clear: "Clear",
saveAndConvert: "Save + Convert",
uploadSolution: "Upload solution image",
hide: "Hide",
show: "Show",
newChat: "New Chat",
......@@ -55,6 +56,10 @@
failedLoadSavedChats: "Could not load saved chats.",
failedLoadSelectedChat: "Could not load the selected chat.",
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}).",
canvasSaveFailed: "Saving the canvas failed.",
canvasSaved: "Canvas saved.",
......@@ -91,6 +96,7 @@
pen: "Stift",
clear: "Leeren",
saveAndConvert: "Speichern + Konvertieren",
uploadSolution: "Lösungsbild hochladen",
hide: "Verstecken",
show: "Anzeigen",
newChat: "neuer Chat",
......@@ -138,6 +144,11 @@
failedLoadSelectedChat: "Der ausgewählte Chat konnte nicht geladen werden.",
savingDrawingConverting:
"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.",
canvasSaveFailed: "Speichern des Canvas fehlgeschlagen. Probiere es nochmal oder lade die Seite neu.",
canvasSaved: "Canvas gespeichert.",
......
......@@ -737,10 +737,13 @@ export default function ChatPage() {
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat");
};
const handleCanvasSave = async (dataUrl: string) => {
const handleCanvasSave = async (
dataUrl: string,
statusMessage = t("savingDrawingConverting")
) => {
setCanvasStatus({
kind: "info",
message: t("savingDrawingConverting"),
message: statusMessage,
});
try {
......@@ -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) => {
setDraft((prev) => (prev ? `${prev} [${docId}]` : `[${docId}]`));
};
......@@ -891,6 +949,7 @@ export default function ChatPage() {
onSend={handleSend}
onHistoryNavigate={handleHistoryNavigate}
onToggleCanvas={handleToggleCanvas}
onUploadSolution={handleCanvasUpload}
onInspectDoc={handleInspectDoc}
docIndex={docIndexes.bySourceKey}
docSlugIndex={docIndexes.bySlug}
......
......@@ -42,7 +42,7 @@ body {
.brand-logo {
display: block;
width: auto;
height: 100px;
height: 70px;
max-width: 300px;
object-fit: contain;
}
......@@ -614,6 +614,18 @@ body {
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 {
padding: 12px;
border-radius: 12px;
......@@ -776,6 +788,10 @@ body {
.canvas-actions-header {
margin-left: 0;
}
.canvas-actions-header .btn {
flex: 1 1 120px;
}
}
.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