Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1103 lines
39 KiB
Python

"""
Settings API Router
===================
UI preferences, configuration catalog management, and detailed streamed tests.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, List, Literal, Optional
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
from deeptutor.multi_user.context import get_current_user
from deeptutor.multi_user.model_access import allowed_llm_options
from deeptutor.services.config import (
get_config_test_runner,
get_model_catalog_service,
get_runtime_settings_service,
)
from deeptutor.services.config.origins import normalize_origins
from deeptutor.services.embedding.client import reset_embedding_client
from deeptutor.services.llm.client import reset_llm_client
from deeptutor.services.llm.config import clear_llm_config_cache
from deeptutor.services.model_selection import list_llm_options
from deeptutor.services.path_service import get_path_service
from deeptutor.tools.builtin import USER_TOGGLEABLE_TOOL_NAMES
router = APIRouter()
TOUR_CACHE = None
def _settings_file():
return get_path_service().get_settings_file("interface")
def _tour_cache_file():
if TOUR_CACHE is not None:
return TOUR_CACHE
return get_path_service().get_settings_dir() / ".tour_cache.json"
DEFAULT_SIDEBAR_NAV_ORDER = {
"start": ["/", "/history", "/knowledge", "/notebook"],
"learnResearch": ["/question", "/solver", "/research", "/co_writer"],
}
DEFAULT_UI_SETTINGS = {
# "snow" is the pure-white neutral theme, shown as "Default" in the UI.
"theme": "snow",
"language": "en",
"sidebar_description": "✨ Data Intelligence Lab @ HKU",
"sidebar_nav_order": DEFAULT_SIDEBAR_NAV_ORDER,
# User-toggleable chat tools. Default = all on; the /settings/tools page
# is the single switchboard. Removed names (e.g. tools that ship later
# and the user hasn't seen yet) are ignored on read; missing names from a
# legacy file fall back to the default (all on).
"enabled_optional_tools": list(USER_TOGGLEABLE_TOOL_NAMES),
# When true, chat auto-plays each assistant reply via TTS. Per-user UI
# preference (not catalog); the chat surface also keeps a per-session
# override on top of this global default.
"voice_autoplay": False,
# Seconds the chat UI waits for any turn event before declaring the
# connection timed out. Bumped from 60 → 180 so slow tools (image/video
# generation) don't trip it; user-adjustable in Settings > Network.
"chat_response_timeout": 180,
}
# Bounds for the chat idle timeout (seconds): long enough for video renders,
# capped so a typo can't wedge a turn open forever.
CHAT_RESPONSE_TIMEOUT_MIN = 30
CHAT_RESPONSE_TIMEOUT_MAX = 1800
class SidebarNavOrder(BaseModel):
start: List[str]
learnResearch: List[str]
class UISettings(BaseModel):
theme: Literal["light", "dark", "glass", "snow"] = "snow"
language: Literal["zh", "en"] = "en"
sidebar_description: Optional[str] = None
sidebar_nav_order: Optional[SidebarNavOrder] = None
class VoiceAutoplayUpdate(BaseModel):
voice_autoplay: bool
class ChatResponseTimeoutUpdate(BaseModel):
chat_response_timeout: int = Field(ge=CHAT_RESPONSE_TIMEOUT_MIN, le=CHAT_RESPONSE_TIMEOUT_MAX)
class ThemeUpdate(BaseModel):
theme: Literal["light", "dark", "glass", "snow"]
class LanguageUpdate(BaseModel):
language: Literal["zh", "en"]
class SidebarDescriptionUpdate(BaseModel):
description: str
class SidebarNavOrderUpdate(BaseModel):
nav_order: SidebarNavOrder
class EnabledToolsUpdate(BaseModel):
enabled_tools: List[str]
class CatalogPayload(BaseModel):
catalog: dict[str, Any]
class FetchModelsPayload(BaseModel):
binding: str = ""
base_url: str
api_key: Optional[str] = None
class NetworkSettingsUpdate(BaseModel):
backend_port: int = Field(ge=1, le=65535)
frontend_port: int = Field(ge=1, le=65535)
public_api_base: str = ""
cors_origins: list[str] = Field(default_factory=list)
class MinerUSettingsUpdate(BaseModel):
"""MinerU PDF-parsing backend settings.
``api_token`` is tri-state: ``None`` keeps the stored token (the UI sends
None when the user didn't edit the secret field), ``""`` clears it, and a
non-empty string replaces it. The GET payload never echoes the raw token.
"""
mode: Literal["local", "cloud"] = "local"
api_base_url: str = "https://mineru.net"
api_token: Optional[str] = None
local_cli_path: str = ""
model_download_source: Literal["huggingface", "modelscope"] = "huggingface"
model_download_endpoint: str = ""
model_version: Literal["pipeline", "vlm"] = "pipeline"
language: str = "auto"
enable_formula: bool = True
enable_table: bool = True
is_ocr: bool = False
# Off by default → a local parse fails fast rather than silently pulling
# multi-GB model weights on first run.
allow_local_model_download: bool = False
class MinerUModelDownloadPayload(BaseModel):
"""One-click model download request (draft form values, like /test)."""
model_type: Literal["pipeline", "vlm", "all"] = "pipeline"
source: Literal["huggingface", "modelscope"] = "huggingface"
endpoint: str = ""
local_cli_path: str = ""
class DocumentParsingUpdate(BaseModel):
"""Document-parsing settings update (the multi-engine control panel).
``engine`` (when provided) switches the active parse engine. ``engines``
carries partial per-engine updates merged over the stored slices. For the
MinerU engine, ``api_token`` stays tri-state: omit it (or send ``None``) to
keep the stored token, ``""`` clears it, a non-empty string replaces it.
The MinerU engine's own knobs can also be edited via the legacy
``/mineru`` endpoints; both preserve the other engines' settings.
"""
engine: Optional[str] = None
engines: Optional[dict[str, dict]] = None
class DocumentParsingTest(BaseModel):
"""Readiness test for one engine (defaults to the active engine)."""
engine: Optional[str] = None
class DocumentParsingInstall(BaseModel):
"""One-click pip install of an optional parser engine's package(s)."""
engine: str
def _invalidate_runtime_caches() -> None:
"""Force runtime clients/config to pick up the latest saved catalog.
The LLM and embedding clients are process-wide singletons, so resetting
them here will affect any user turn that is mid-flight on another worker.
Admins issuing Apply during active sessions accept that trade-off; we log
a WARNING so the cause is visible in the audit trail.
"""
logger.warning(
"Admin applied catalog; resetting global LLM/embedding clients. "
"In-flight user turns may flip backend client mid-call."
)
clear_llm_config_cache()
reset_llm_client()
reset_embedding_client()
def load_ui_settings() -> dict[str, Any]:
settings_file = _settings_file()
if settings_file.exists():
try:
with open(settings_file, encoding="utf-8") as handle:
saved = json.load(handle)
merged = {**DEFAULT_UI_SETTINGS, **saved}
# Filter persisted enabled_optional_tools to current
# toggleable set so retired tool names can't leak into
# the per-turn payload.
merged["enabled_optional_tools"] = _sanitize_enabled_tools(
merged.get("enabled_optional_tools")
)
return merged
except Exception:
pass
return DEFAULT_UI_SETTINGS.copy()
def _sanitize_enabled_tools(value: Any) -> list[str]:
if not isinstance(value, list):
return list(USER_TOGGLEABLE_TOOL_NAMES)
allowed = set(USER_TOGGLEABLE_TOOL_NAMES)
seen: set[str] = set()
out: list[str] = []
for name in value:
if isinstance(name, str) and name in allowed and name not in seen:
seen.add(name)
out.append(name)
return out
def get_enabled_optional_tools() -> list[str]:
"""Return the user's currently-enabled toggleable tool names.
Source of truth for the chat pipeline when a turn doesn't ship an
explicit ``tools`` list. Intersected with the admin grant whitelist so
a restricted user's saved toggles can't resurrect a revoked tool.
"""
from deeptutor.multi_user.tool_access import allowed_optional_tools
enabled = _sanitize_enabled_tools(load_ui_settings().get("enabled_optional_tools"))
allowed = allowed_optional_tools()
if allowed is not None:
enabled = [name for name in enabled if name in allowed]
return enabled
def save_ui_settings(settings: dict[str, Any]) -> None:
settings_file = _settings_file()
settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(settings_file, "w", encoding="utf-8") as handle:
json.dump(settings, handle, ensure_ascii=False, indent=2)
def _require_settings_admin() -> None:
if not get_current_user().is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Model configuration is managed by an administrator.",
)
def _provider_choices() -> dict[str, list[dict[str, str]]]:
"""Build dropdown options for provider selection, keyed by service type."""
from deeptutor.services.config.provider_runtime import (
EMBEDDING_PROVIDERS,
IMAGEGEN_PROVIDERS,
STT_PROVIDERS,
TTS_PROVIDERS,
VIDEOGEN_PROVIDERS,
)
from deeptutor.services.provider_registry import PROVIDERS
llm = sorted(
[
{
"value": s.name,
"label": (
"Custom (OpenAI API)"
if s.name == "custom"
else "Custom (Anthropic API)"
if s.name == "custom_anthropic"
else s.label
),
"base_url": s.default_api_base,
}
for s in PROVIDERS
],
key=lambda p: p["label"].lower(),
)
embedding = sorted(
[
{
"value": name,
"label": spec.label,
"base_url": spec.default_api_base,
"default_dim": str(spec.default_dim) if spec.default_dim else "",
}
for name, spec in EMBEDDING_PROVIDERS.items()
if name != "custom_openai_sdk"
],
key=lambda p: p["label"].lower(),
)
search = [
{"value": "none", "label": "None", "base_url": ""},
{"value": "brave", "label": "Brave", "base_url": ""},
{"value": "tavily", "label": "Tavily", "base_url": ""},
{"value": "jina", "label": "Jina", "base_url": ""},
{"value": "searxng", "label": "SearXNG", "base_url": ""},
{"value": "duckduckgo", "label": "DuckDuckGo", "base_url": ""},
{"value": "perplexity", "label": "Perplexity", "base_url": ""},
{"value": "serper", "label": "Serper", "base_url": ""},
]
tts = sorted(
[
{
"value": name,
"label": spec.label,
"base_url": spec.default_api_base,
"default_model": spec.default_model,
"default_voice": spec.default_voice,
}
for name, spec in TTS_PROVIDERS.items()
],
key=lambda p: p["label"].lower(),
)
stt = sorted(
[
{
"value": name,
"label": spec.label,
"base_url": spec.default_api_base,
"default_model": spec.default_model,
}
for name, spec in STT_PROVIDERS.items()
],
key=lambda p: p["label"].lower(),
)
imagegen = sorted(
[
{
"value": name,
"label": spec.label,
"base_url": spec.default_api_base,
"default_model": spec.default_model,
}
for name, spec in IMAGEGEN_PROVIDERS.items()
],
key=lambda p: p["label"].lower(),
)
videogen = sorted(
[
{
"value": name,
"label": spec.label,
"base_url": spec.default_api_base,
"default_model": spec.default_model,
}
for name, spec in VIDEOGEN_PROVIDERS.items()
],
key=lambda p: p["label"].lower(),
)
return {
"llm": llm,
"embedding": embedding,
"search": search,
"tts": tts,
"stt": stt,
"imagegen": imagegen,
"videogen": videogen,
}
def _api_base_source(system: dict[str, Any]) -> str:
if system.get("next_public_api_base_external"):
return "next_public_api_base_external"
if system.get("next_public_api_base"):
return "next_public_api_base"
return "default_backend_url"
def _network_settings_payload() -> dict[str, Any]:
service = get_runtime_settings_service()
file_system = service.load_system(include_process_overrides=False)
effective_system = service.load_system(include_process_overrides=True)
auth = service.load_auth(include_process_overrides=True)
backend_url = f"http://localhost:{effective_system['backend_port']}"
browser_api_base = (
effective_system["next_public_api_base_external"]
or effective_system["next_public_api_base"]
or backend_url
)
cors_origins = normalize_origins(
[effective_system["cors_origin"], effective_system["cors_origins"]]
)
auth_enabled = bool(auth["enabled"])
cookie_secure = bool(auth["cookie_secure"])
return {
"settings": {
"backend_port": file_system["backend_port"],
"frontend_port": file_system["frontend_port"],
"public_api_base": file_system["next_public_api_base_external"],
"cors_origins": normalize_origins(
[file_system["cors_origin"], file_system["cors_origins"]]
),
},
"effective": {
"backend_url": backend_url,
"frontend_url": f"http://localhost:{effective_system['frontend_port']}",
"browser_api_base": browser_api_base,
"api_base_source": _api_base_source(effective_system),
"cors_mode": "explicit" if auth_enabled else "permissive",
"cors_origins": cors_origins,
"allow_remote_http_origins": not auth_enabled,
},
"auth": {
"enabled": auth_enabled,
"cookie_secure": cookie_secure,
"cookie_samesite": "none" if cookie_secure else "lax",
"cross_site_cookie_ready": bool(auth_enabled and cookie_secure),
},
"restart_required": True,
}
@router.get("")
async def get_settings():
user = get_current_user()
if not user.is_admin:
# Non-admins never see the catalog (provider URLs/keys); their model
# choices come from /settings/llm-options (grant-filtered).
return {"ui": load_ui_settings()}
return {
"ui": load_ui_settings(),
"catalog": get_model_catalog_service().load(),
"providers": _provider_choices(),
}
@router.get("/catalog")
async def get_catalog():
_require_settings_admin()
return {"catalog": get_model_catalog_service().load()}
@router.get("/network")
async def get_network_settings():
_require_settings_admin()
return _network_settings_payload()
@router.put("/network")
async def update_network_settings(payload: NetworkSettingsUpdate):
_require_settings_admin()
service = get_runtime_settings_service()
current = service.load_system(include_process_overrides=False)
service.save_system(
{
**current,
"backend_port": payload.backend_port,
"frontend_port": payload.frontend_port,
"next_public_api_base_external": payload.public_api_base.strip(),
"cors_origin": "",
"cors_origins": normalize_origins(payload.cors_origins),
}
)
return _network_settings_payload()
def _mineru_settings_payload() -> dict[str, Any]:
"""MinerU settings for the UI, with the API token redacted to a boolean.
``local_cli`` is a fast PATH probe (no subprocess) so the page can show
install status at config time instead of failing at parse time; the
definitive ``--version`` check runs behind the explicit Test button.
"""
from deeptutor.services.parsing.engines.mineru.backend import local_cli_probe
service = get_runtime_settings_service()
settings = service.load_mineru(include_process_overrides=True)
public = {key: value for key, value in settings.items() if key != "api_token"}
return {
"settings": public,
"api_token_set": bool(settings.get("api_token")),
"local_cli": local_cli_probe(str(settings.get("local_cli_path") or "")),
}
def _document_parsing_payload() -> dict[str, Any]:
"""State for the Document Parsing settings page: active engine, all engine
slices (MinerU token redacted), engine availability, and per-engine
readiness (so the UI can surface the "models not downloaded" gate)."""
from deeptutor.services.parsing.engines._install import (
installable_engines,
model_downloadable_engines,
)
from deeptutor.services.parsing.engines.factory import (
get_parser,
list_engines,
)
from deeptutor.services.parsing.engines.mineru.backend import local_cli_probe
service = get_runtime_settings_service()
full = service.load_document_parsing(include_process_overrides=True)
engines = full.get("engines", {})
redacted: dict[str, Any] = {}
for name, slice_ in engines.items():
clean = dict(slice_)
clean.pop("api_token", None)
redacted[name] = clean
readiness: dict[str, Any] = {}
available = list_engines()
for entry in available:
if not entry["available"]:
continue
try:
parser = get_parser(entry["id"])
report = parser.is_ready(parser.resolve_config())
readiness[entry["id"]] = {
"ready": report.ready,
"reason": report.reason,
"message": report.message,
}
except Exception: # pragma: no cover - defensive
continue
mineru_slice = engines.get("mineru", {})
return {
"engine": full.get("engine"),
"engines": redacted,
"available_engines": available,
"readiness": readiness,
# Engine ids that support one-click pip install / model download here.
"installable": sorted(installable_engines()),
"model_downloadable": sorted(model_downloadable_engines()),
# MinerU-specific UI state (token presence + CLI probe).
"mineru": {
"api_token_set": bool(mineru_slice.get("api_token")),
"local_cli": local_cli_probe(str(mineru_slice.get("local_cli_path") or "")),
},
}
@router.get("/mineru")
async def get_mineru_settings():
_require_settings_admin()
return _mineru_settings_payload()
@router.put("/mineru")
async def update_mineru_settings(payload: MinerUSettingsUpdate):
_require_settings_admin()
service = get_runtime_settings_service()
current = service.load_mineru(include_process_overrides=False)
# Tri-state token: None keeps the stored value, anything else replaces it.
token = current.get("api_token", "")
if payload.api_token is not None:
token = payload.api_token.strip()
service.save_mineru(
{
"mode": payload.mode,
"api_base_url": payload.api_base_url,
"api_token": token,
"local_cli_path": payload.local_cli_path,
"model_download_source": payload.model_download_source,
"model_download_endpoint": payload.model_download_endpoint,
"model_version": payload.model_version,
"language": payload.language,
"enable_formula": payload.enable_formula,
"enable_table": payload.enable_table,
"is_ocr": payload.is_ocr,
"allow_local_model_download": payload.allow_local_model_download,
}
)
return _mineru_settings_payload()
@router.get("/document-parsing")
async def get_document_parsing_settings():
_require_settings_admin()
return _document_parsing_payload()
@router.put("/document-parsing")
async def update_document_parsing_settings(payload: DocumentParsingUpdate):
_require_settings_admin()
service = get_runtime_settings_service()
full = service.load_document_parsing(include_process_overrides=False)
engines = {name: dict(slice_) for name, slice_ in full.get("engines", {}).items()}
for name, update in (payload.engines or {}).items():
if name not in engines:
continue
merged = dict(update or {})
# MinerU token tri-state: omitted / None keeps the stored token.
if name == "mineru" and merged.get("api_token") is None:
merged.pop("api_token", None)
engines[name].update(merged)
new_engine = payload.engine or full.get("engine")
service.save_document_parsing({"engine": new_engine, "engines": engines})
return _document_parsing_payload()
@router.post("/document-parsing/test")
async def test_document_parsing(payload: DocumentParsingTest):
"""Readiness test for an engine. For MinerU's deeper checks (live cloud
token / CLI ``--version``) the UI uses ``/mineru/test``; this generic test
covers engine availability + model readiness for all engines."""
_require_settings_admin()
from deeptutor.services.parsing.engines.factory import get_parser, is_engine_available
service = get_runtime_settings_service()
engine = payload.engine or service.load_document_parsing().get("engine") or ""
if not is_engine_available(engine):
return {"ok": False, "message": f"The '{engine}' parsing engine isn't installed."}
try:
parser = get_parser(engine)
report = parser.is_ready(parser.resolve_config())
except Exception as exc: # noqa: BLE001 - surface as a test result
return {"ok": False, "message": str(exc)}
return {
"ok": report.ready,
"message": report.message or ("Ready to parse." if report.ready else "Not ready."),
}
def _normalize_engine_name(name: str) -> str:
return (name or "").strip().lower().replace("-", "_").replace(" ", "_")
@router.post("/document-parsing/install")
async def start_document_parsing_install(payload: DocumentParsingInstall):
"""Kick off a one-click ``pip install`` of an optional engine's package(s).
Returns ``{ok, message}`` immediately; progress is polled via the shared job
status endpoint. Only one job runs at a time (process-wide singleton). The
engine must be in the install allow-list (``ENGINE_PIP_SPECS``)."""
_require_settings_admin()
from deeptutor.services.parsing.engines._install import (
ENGINE_PIP_SPECS,
get_background_job_manager,
)
engine = _normalize_engine_name(payload.engine)
specs = ENGINE_PIP_SPECS.get(engine)
if not specs:
return {"ok": False, "message": f"No installable package for engine '{engine}'."}
return get_background_job_manager().start_install(engine=engine, specs=specs)
@router.post("/document-parsing/models/download")
async def start_document_parsing_model_download(payload: DocumentParsingInstall):
"""Kick off a one-click model-weight download for an engine (e.g. Docling).
Runs the engine's downloader console script (``docling-tools models
download``) as a background subprocess; progress is polled via the shared job
status endpoint. The engine must be in ``ENGINE_MODEL_DOWNLOADERS`` and its
script reachable next to the server's python or on PATH."""
_require_settings_admin()
from deeptutor.services.parsing.engines._install import (
get_background_job_manager,
model_downloadable_engines,
resolve_model_downloader,
)
engine = _normalize_engine_name(payload.engine)
if engine not in model_downloadable_engines():
return {"ok": False, "message": f"No model download for engine '{engine}'."}
cmd = resolve_model_downloader(engine)
if not cmd:
return {
"ok": False,
"message": (
f"The {engine} model downloader wasn't found. Reinstall the engine "
f"(pip install deeptutor[parse-{engine}]) so its CLI is on PATH."
),
}
return get_background_job_manager().start_model_download(engine=engine, cmd=cmd)
@router.get("/document-parsing/job/status")
async def document_parsing_job_status(cursor: int = 0):
_require_settings_admin()
from deeptutor.services.parsing.engines._install import get_background_job_manager
return get_background_job_manager().status(cursor)
@router.post("/document-parsing/job/cancel")
async def cancel_document_parsing_job():
_require_settings_admin()
from deeptutor.services.parsing.engines._install import get_background_job_manager
return get_background_job_manager().cancel()
@router.post("/mineru/models/download")
async def start_mineru_models_download(payload: MinerUModelDownloadPayload):
"""Kick off a one-click model download via ``mineru-models-download``.
Returns ``{ok, message}`` immediately; progress is polled via the status
endpoint. Only one download runs at a time (process-wide singleton).
"""
_require_settings_admin()
from deeptutor.services.parsing.engines.mineru.models import (
get_model_download_manager,
resolve_models_downloader,
)
resolved = resolve_models_downloader(payload.local_cli_path)
if not resolved["found"]:
if resolved["path"]:
message = (
f"mineru-models-download not found next to the configured CLI "
f"(expected {resolved['path']}). The configured install may be "
"magic-pdf 1.x — upgrade to MinerU 2.x for one-click downloads."
)
else:
message = (
"mineru-models-download not found on the server PATH. Install "
'MinerU 2.x first (uv pip install -U "mineru[core]") or set the CLI path.'
)
return {"ok": False, "message": message}
return get_model_download_manager().start(
downloader=resolved["path"],
model_type=payload.model_type,
source=payload.source,
endpoint=payload.endpoint,
)
@router.get("/mineru/models/download/status")
async def mineru_models_download_status(cursor: int = 0):
_require_settings_admin()
from deeptutor.services.parsing.engines.mineru.models import get_model_download_manager
return get_model_download_manager().status(cursor)
@router.post("/mineru/models/download/cancel")
async def cancel_mineru_models_download():
_require_settings_admin()
from deeptutor.services.parsing.engines.mineru.models import get_model_download_manager
return get_model_download_manager().cancel()
@router.post("/mineru/test")
async def test_mineru_connection(payload: MinerUSettingsUpdate):
"""Validate the active backend. ``mode == "local"`` checks the CLI install
(PATH probe + ``--version``); cloud mode validates the token against the
live MinerU API (no parse quota consumed). Tests the draft form values so
the user can verify before saving; falls back to the stored token when the
secret field is untouched."""
_require_settings_admin()
from deeptutor.services.parsing.engines.mineru.cloud import verify_credentials
from deeptutor.services.parsing.engines.mineru.config import MinerUConfig, MinerUError
if payload.mode == "local":
from deeptutor.services.parsing.engines.mineru.backend import (
local_cli_probe,
local_cli_version,
)
probe = local_cli_probe(payload.local_cli_path)
if not probe["found"]:
if probe.get("source") == "configured":
return {
"ok": False,
"message": (
f"Configured CLI path is not an executable file: {probe['path']}. "
"Fix the path or clear it to auto-detect from PATH."
),
}
return {
"ok": False,
"message": (
"MinerU CLI not found on the server PATH. Install it "
'(uv pip install -U "mineru[core]"), set an explicit CLI path, '
"or switch to cloud mode."
),
}
# For a configured path, run --version against the path itself (the
# bare command name may not be on this process's PATH).
version_target = (
probe["path"] if probe.get("source") == "configured" else str(probe["command"])
)
version = await asyncio.to_thread(local_cli_version, version_target)
detail = version or f"at {probe['path']}"
return {
"ok": True,
"message": f"Local MinerU CLI detected: {probe['command']} ({detail})",
}
service = get_runtime_settings_service()
stored = service.load_mineru(include_process_overrides=False)
token = stored.get("api_token", "") if payload.api_token is None else payload.api_token.strip()
config = MinerUConfig(
mode="cloud",
api_base_url=(payload.api_base_url or "").strip().rstrip("/") or "https://mineru.net",
api_token=token,
model_version=payload.model_version,
language=payload.language or "auto",
enable_formula=payload.enable_formula,
enable_table=payload.enable_table,
is_ocr=payload.is_ocr,
)
try:
await asyncio.to_thread(verify_credentials, config)
except MinerUError as exc:
return {"ok": False, "message": str(exc)}
except Exception as exc: # noqa: BLE001 — report any provider error to the UI
logger.exception("MinerU connectivity test failed")
return {"ok": False, "message": f"Unexpected error: {exc}"}
return {"ok": True, "message": "MinerU API token is valid."}
@router.get("/llm-options")
async def get_llm_options():
if not get_current_user().is_admin:
return allowed_llm_options()
return list_llm_options(get_model_catalog_service().load())
@router.put("/catalog")
async def update_catalog(payload: CatalogPayload):
_require_settings_admin()
catalog = get_model_catalog_service().save(payload.catalog)
_invalidate_runtime_caches()
return {"catalog": catalog}
@router.post("/apply")
async def apply_catalog(payload: CatalogPayload | None = None):
_require_settings_admin()
catalog = payload.catalog if payload is not None else get_model_catalog_service().load()
applied = get_model_catalog_service().apply(catalog)
_invalidate_runtime_caches()
return {
"message": "Catalog applied to runtime settings.",
"catalog": get_model_catalog_service().load(),
"runtime": applied,
}
@router.post("/fetch-models")
async def fetch_models_from_provider(payload: FetchModelsPayload):
"""List the model IDs an OpenAI-compatible provider exposes.
Thin HTTP surface over ``factory.fetch_models`` so the settings UI can
populate a model picker from ``base_url`` + ``api_key`` instead of making
the user type model IDs by hand.
"""
_require_settings_admin()
from deeptutor.services.llm.factory import fetch_models as fetch_llm_models
base_url = (payload.base_url or "").strip()
binding = (payload.binding or "").strip().lower() or "openai"
if not base_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="base_url is required.",
)
try:
model_ids = await fetch_llm_models(binding, base_url, payload.api_key)
except Exception as exc: # noqa: BLE001 — surface any provider error as 502
logger.exception("Failed to fetch models from %s", base_url)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Provider request failed: {exc}",
) from exc
return {"models": [{"id": model_id, "name": model_id} for model_id in model_ids]}
@router.put("/theme")
async def update_theme(update: ThemeUpdate):
current_ui = load_ui_settings()
current_ui["theme"] = update.theme
save_ui_settings(current_ui)
return {"theme": update.theme}
@router.put("/language")
async def update_language(update: LanguageUpdate):
current_ui = load_ui_settings()
current_ui["language"] = update.language
save_ui_settings(current_ui)
return {"language": update.language}
@router.put("/voice-autoplay")
async def update_voice_autoplay(update: VoiceAutoplayUpdate):
"""Persist the global default for auto-playing chat replies via TTS.
A personal UI preference (any authenticated user); the chat surface layers
a per-session override on top of this value.
"""
current_ui = load_ui_settings()
current_ui["voice_autoplay"] = update.voice_autoplay
save_ui_settings(current_ui)
return {"voice_autoplay": update.voice_autoplay}
@router.put("/chat-response-timeout")
async def update_chat_response_timeout(update: ChatResponseTimeoutUpdate):
"""Persist how long the chat UI waits for a turn event before timing out.
A personal UI preference (any authenticated user). Slow tools like image /
video generation can take longer than the old 60s default, so this is
user-adjustable; the chat surface reads it client-side.
"""
current_ui = load_ui_settings()
current_ui["chat_response_timeout"] = update.chat_response_timeout
save_ui_settings(current_ui)
return {"chat_response_timeout": update.chat_response_timeout}
@router.put("/ui")
async def update_ui_settings(update: UISettings):
current_ui = load_ui_settings()
current_ui.update(update.model_dump(exclude_none=True))
save_ui_settings(current_ui)
return current_ui
@router.post("/reset")
async def reset_settings():
save_ui_settings(DEFAULT_UI_SETTINGS)
return DEFAULT_UI_SETTINGS
@router.get("/themes")
async def get_themes():
return {
"themes": [
{"id": "snow", "name": "Default"},
{"id": "light", "name": "Cream"},
{"id": "dark", "name": "Dark"},
{"id": "glass", "name": "Glass"},
]
}
@router.get("/sidebar")
async def get_sidebar_settings():
current_ui = load_ui_settings()
return {
"description": current_ui.get(
"sidebar_description", DEFAULT_UI_SETTINGS["sidebar_description"]
),
"nav_order": current_ui.get("sidebar_nav_order", DEFAULT_UI_SETTINGS["sidebar_nav_order"]),
}
@router.put("/sidebar/description")
async def update_sidebar_description(update: SidebarDescriptionUpdate):
current_ui = load_ui_settings()
current_ui["sidebar_description"] = update.description
save_ui_settings(current_ui)
return {"description": update.description}
@router.put("/sidebar/nav-order")
async def update_sidebar_nav_order(update: SidebarNavOrderUpdate):
current_ui = load_ui_settings()
current_ui["sidebar_nav_order"] = update.nav_order.model_dump()
save_ui_settings(current_ui)
return {"nav_order": update.nav_order.model_dump()}
@router.put("/enabled-tools")
async def update_enabled_tools(update: EnabledToolsUpdate):
sanitized = _sanitize_enabled_tools(update.enabled_tools)
current_ui = load_ui_settings()
current_ui["enabled_optional_tools"] = sanitized
save_ui_settings(current_ui)
return {"enabled_optional_tools": sanitized}
@router.post("/tests/{service}/start")
async def start_service_test(service: str, payload: CatalogPayload | None = None):
_require_settings_admin()
run = get_config_test_runner().start(service, payload.catalog if payload else None)
return {"run_id": run.id}
@router.get("/tests/{service}/{run_id}/events")
async def stream_service_test_events(service: str, run_id: str, request: Request):
_require_settings_admin()
runner = get_config_test_runner()
run = runner.get(run_id)
async def event_stream():
sent = 0
while True:
if await request.is_disconnected():
return
events = run.snapshot(sent)
if events:
for event in events:
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
sent += len(events)
if events[-1]["type"] in {"completed", "failed"}:
return
else:
yield "event: heartbeat\ndata: {}\n\n"
await asyncio.sleep(0.35)
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/tests/{service}/{run_id}/cancel")
async def cancel_service_test(service: str, run_id: str):
_require_settings_admin()
get_config_test_runner().cancel(run_id)
return {"message": "Cancelled"}
@router.get("/tour/status")
async def tour_status():
tour_cache = _tour_cache_file()
if tour_cache.exists():
try:
cache = json.loads(tour_cache.read_text(encoding="utf-8"))
return {
"active": True,
"status": cache.get("status", "unknown"),
"launch_at": cache.get("launch_at"),
"redirect_at": cache.get("redirect_at"),
}
except Exception:
pass
return {"active": False, "status": "none", "launch_at": None, "redirect_at": None}
class TourCompletePayload(BaseModel):
catalog: dict[str, Any] | None = None
test_results: dict[str, str] | None = None
@router.post("/tour/complete")
async def complete_tour(payload: TourCompletePayload | None = None):
_require_settings_admin()
catalog = payload.catalog if payload and payload.catalog else get_model_catalog_service().load()
applied = get_model_catalog_service().apply(catalog)
_invalidate_runtime_caches()
now = int(time.time())
launch_at = now + 3
redirect_at = now + 5
tour_cache = _tour_cache_file()
if tour_cache.exists():
try:
cache = json.loads(tour_cache.read_text(encoding="utf-8"))
except Exception:
cache = {}
cache["status"] = "completed"
cache["launch_at"] = launch_at
cache["redirect_at"] = redirect_at
if payload and payload.test_results:
cache["test_results"] = payload.test_results
tour_cache.write_text(json.dumps(cache, indent=2), encoding="utf-8")
return {
"status": "completed",
"message": "Configuration saved. DeepTutor will restart shortly.",
"launch_at": launch_at,
"redirect_at": redirect_at,
"runtime": applied,
}
@router.post("/tour/reopen")
async def reopen_tour():
return {
"message": "Run the terminal setup guide from the project root to re-open the guided setup.",
"command": "deeptutor init",
}