diff --git a/.gitignore b/.gitignore index 9bd7bf12c..7baefefb1 100644 --- a/.gitignore +++ b/.gitignore @@ -211,6 +211,8 @@ htmlcov/ nosetests.xml coverage.xml develop/ +web/playwright-report/ +web/test-results/ # ============================================ # Jupyter Notebook diff --git a/deeptutor/api/routers/attachments.py b/deeptutor/api/routers/attachments.py index 130cf7689..262b77009 100644 --- a/deeptutor/api/routers/attachments.py +++ b/deeptutor/api/routers/attachments.py @@ -46,9 +46,7 @@ def _content_disposition(filename: str, *, disposition: str = "inline") -> str: # Quotes / backslashes break the simple-quoted-string form; collapse them. ascii_fallback = ascii_fallback.replace('"', "_").replace("\\", "_") encoded = quote(filename, safe="") - return ( - f'{disposition}; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded}' - ) + return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}" @router.get("/{session_id}/{attachment_id}/{filename:path}") diff --git a/deeptutor/api/routers/knowledge.py b/deeptutor/api/routers/knowledge.py index 533187536..b83414b65 100644 --- a/deeptutor/api/routers/knowledge.py +++ b/deeptutor/api/routers/knowledge.py @@ -168,9 +168,7 @@ def _save_uploaded_files( except OSError: pass - error_message = ( - f"Validation failed for file '{original_filename}': {format_exception_message(e)}" - ) + error_message = f"Validation failed for file '{original_filename}': {format_exception_message(e)}" logger.error(error_message, exc_info=True) raise HTTPException(status_code=400, detail=error_message) from e except Exception: diff --git a/deeptutor/api/routers/notebook.py b/deeptutor/api/routers/notebook.py index 1ec00ca0b..ae0098b68 100644 --- a/deeptutor/api/routers/notebook.py +++ b/deeptutor/api/routers/notebook.py @@ -41,9 +41,7 @@ class AddRecordRequest(BaseModel): """Add record request""" notebook_ids: list[str] - record_type: Literal[ - "solve", "question", "research", "chat", "co_writer", "tutorbot" - ] + record_type: Literal["solve", "question", "research", "chat", "co_writer", "tutorbot"] title: str summary: str = "" user_query: str diff --git a/deeptutor/api/routers/question.py b/deeptutor/api/routers/question.py index f78b1e185..3cee4ed55 100644 --- a/deeptutor/api/routers/question.py +++ b/deeptutor/api/routers/question.py @@ -486,12 +486,14 @@ async def log_pusher(): # Log additional context if available try: - if "result" in locals(): + local_vars = locals() + if "result" in local_vars: + result_value = local_vars["result"] logger.error( - f"Result type: {type(result)}, result keys: {result.keys() if isinstance(result, dict) else 'N/A'}" + f"Result type: {type(result_value)}, result keys: {result_value.keys() if isinstance(result_value, dict) else 'N/A'}" ) - if isinstance(result, dict) and "validation" in result: - validation = result["validation"] + if isinstance(result_value, dict) and "validation" in result_value: + validation = result_value["validation"] logger.error(f"Validation type: {type(validation)}") if isinstance(validation, dict): logger.error(f"Validation keys: {validation.keys()}") diff --git a/deeptutor/api/routers/solve.py b/deeptutor/api/routers/solve.py index 0a87a39b1..ec931fa37 100644 --- a/deeptutor/api/routers/solve.py +++ b/deeptutor/api/routers/solve.py @@ -6,6 +6,7 @@ """ import asyncio +from pathlib import Path import re from typing import Any diff --git a/deeptutor/knowledge/manager.py b/deeptutor/knowledge/manager.py index 8a5c55c6b..633a4d1ab 100644 --- a/deeptutor/knowledge/manager.py +++ b/deeptutor/knowledge/manager.py @@ -253,7 +253,9 @@ def update_kb_status( # permanently carrying a "completed" progress banner. kb_config.pop("progress", None) if progress is not None: - kb_config["last_completed_at"] = progress.get("timestamp") or datetime.now().isoformat() + kb_config["last_completed_at"] = ( + progress.get("timestamp") or datetime.now().isoformat() + ) elif progress is not None: kb_config["progress"] = progress diff --git a/deeptutor/knowledge/progress_tracker.py b/deeptutor/knowledge/progress_tracker.py index ee710f878..311a5f52e 100644 --- a/deeptutor/knowledge/progress_tracker.py +++ b/deeptutor/knowledge/progress_tracker.py @@ -134,7 +134,9 @@ def _save_progress(self, progress: dict): f.flush() temp_progress_file.replace(self.progress_file) except Exception as e: - _get_logger().warning("Failed to persist progress snapshot for '%s': %s", self.kb_name, e) + _get_logger().warning( + "Failed to persist progress snapshot for '%s': %s", self.kb_name, e + ) def update( self, diff --git a/deeptutor/services/config/context_window_detection.py b/deeptutor/services/config/context_window_detection.py index dff255efc..e6ffd5431 100644 --- a/deeptutor/services/config/context_window_detection.py +++ b/deeptutor/services/config/context_window_detection.py @@ -145,7 +145,9 @@ async def _detect_from_models_endpoint( async with session.get(url, headers=headers) as response: if response.status != 200: if on_log is not None: - on_log(f"`GET {url}` returned HTTP {response.status}; skipping metadata detection.") + on_log( + f"`GET {url}` returned HTTP {response.status}; skipping metadata detection." + ) return None payload = await response.json() except Exception as exc: diff --git a/deeptutor/services/config/test_runner.py b/deeptutor/services/config/test_runner.py index 322de5dcc..13ae9f182 100644 --- a/deeptutor/services/config/test_runner.py +++ b/deeptutor/services/config/test_runner.py @@ -215,10 +215,7 @@ async def _test_llm(self, run: TestRun, catalog: dict[str, Any]) -> None: ) run.emit( "context_window", - ( - f"Context window set to {detection.context_window} tokens " - f"({detection.source})." - ), + (f"Context window set to {detection.context_window} tokens ({detection.source})."), context_window=detection.context_window, source=detection.source, detail=detection.detail, diff --git a/deeptutor/services/llm/factory.py b/deeptutor/services/llm/factory.py index 4fe8a97d1..52beeeaba 100644 --- a/deeptutor/services/llm/factory.py +++ b/deeptutor/services/llm/factory.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio -import contextlib from collections.abc import AsyncGenerator, Mapping +import contextlib from types import SimpleNamespace from typing import Any, TypedDict @@ -184,12 +184,16 @@ def _resolve_call_config( def _capability_binding(config: LLMConfig, provider_spec: Any) -> str: - backend = getattr(provider_spec, "backend", "openai_compat") if provider_spec else "openai_compat" + backend = ( + getattr(provider_spec, "backend", "openai_compat") if provider_spec else "openai_compat" + ) if backend == "anthropic": return "anthropic" if backend == "azure_openai": return "azure_openai" - return getattr(provider_spec, "name", None) or config.provider_name or config.binding or "openai" + return ( + getattr(provider_spec, "name", None) or config.provider_name or config.binding or "openai" + ) def _build_messages( @@ -312,7 +316,9 @@ async def complete( image_data=image_data, ) retry_delays = _build_retry_delays(max_retries, retry_delay, exponential_backoff) - extra_kwargs = _sanitize_call_kwargs(binding=capability_binding, model=config.model, kwargs=kwargs) + extra_kwargs = _sanitize_call_kwargs( + binding=capability_binding, model=config.model, kwargs=kwargs + ) try: response = await provider.chat_with_retry( @@ -325,7 +331,9 @@ async def complete( raise map_error(exc, provider=config.provider_name) from exc if response.finish_reason == "error": - raise map_error(RuntimeError(response.content or "LLM request failed"), provider=config.provider_name) + raise map_error( + RuntimeError(response.content or "LLM request failed"), provider=config.provider_name + ) return response.content or "" @@ -366,7 +374,9 @@ async def stream( image_data=image_data, ) retry_delays = _build_retry_delays(max_retries, retry_delay, exponential_backoff) - extra_kwargs = _sanitize_call_kwargs(binding=capability_binding, model=config.model, kwargs=kwargs) + extra_kwargs = _sanitize_call_kwargs( + binding=capability_binding, model=config.model, kwargs=kwargs + ) queue: asyncio.Queue[str | BaseException | None] = asyncio.Queue() saw_output = False diff --git a/deeptutor/services/llm/provider_core/anthropic_provider.py b/deeptutor/services/llm/provider_core/anthropic_provider.py index ef80d1c0c..f12af9fa2 100644 --- a/deeptutor/services/llm/provider_core/anthropic_provider.py +++ b/deeptutor/services/llm/provider_core/anthropic_provider.py @@ -41,6 +41,7 @@ def __init__( self._supports_prompt_caching = supports_prompt_caching from anthropic import AsyncAnthropic + from deeptutor.services.llm.utils import sanitize_url client_kw: dict[str, Any] = {"max_retries": 0} diff --git a/deeptutor/services/llm/provider_core/azure_openai_provider.py b/deeptutor/services/llm/provider_core/azure_openai_provider.py index ed190001a..098cd33cd 100644 --- a/deeptutor/services/llm/provider_core/azure_openai_provider.py +++ b/deeptutor/services/llm/provider_core/azure_openai_provider.py @@ -160,7 +160,9 @@ async def _timed_stream(): stream_iter = stream.__aiter__() while True: try: - yield await asyncio.wait_for(stream_iter.__anext__(), timeout=idle_timeout_s) + yield await asyncio.wait_for( + stream_iter.__anext__(), timeout=idle_timeout_s + ) except StopAsyncIteration: break diff --git a/deeptutor/services/llm/provider_core/base.py b/deeptutor/services/llm/provider_core/base.py index 6a948c524..e88adc194 100644 --- a/deeptutor/services/llm/provider_core/base.py +++ b/deeptutor/services/llm/provider_core/base.py @@ -4,10 +4,10 @@ from abc import ABC, abstractmethod import asyncio +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field import json from typing import Any -from collections.abc import Awaitable, Callable, Sequence from loguru import logger diff --git a/deeptutor/services/llm/provider_core/github_copilot_provider.py b/deeptutor/services/llm/provider_core/github_copilot_provider.py index 7bee1d7fa..dea9fbd94 100644 --- a/deeptutor/services/llm/provider_core/github_copilot_provider.py +++ b/deeptutor/services/llm/provider_core/github_copilot_provider.py @@ -2,8 +2,8 @@ from __future__ import annotations -import time from collections.abc import Awaitable, Callable +import time from typing import Any import httpx @@ -66,7 +66,9 @@ async def _exchange_token(self) -> str: ) timeout = httpx.Timeout(20.0, connect=20.0) - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client: + async with httpx.AsyncClient( + timeout=timeout, follow_redirects=True, trust_env=True + ) as client: response = await client.get( DEFAULT_COPILOT_TOKEN_URL, headers={ diff --git a/deeptutor/services/llm/provider_core/openai_codex_provider.py b/deeptutor/services/llm/provider_core/openai_codex_provider.py index 77fc94a8e..15a973bf6 100644 --- a/deeptutor/services/llm/provider_core/openai_codex_provider.py +++ b/deeptutor/services/llm/provider_core/openai_codex_provider.py @@ -3,16 +3,20 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable import hashlib import json -from collections.abc import Awaitable, Callable from typing import Any import httpx from loguru import logger from deeptutor.services.llm.provider_core.base import LLMProvider, LLMResponse, ToolCallRequest -from deeptutor.services.llm.provider_core.openai_responses import consume_sse, convert_messages, convert_tools +from deeptutor.services.llm.provider_core.openai_responses import ( + consume_sse, + convert_messages, + convert_tools, +) DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_ORIGINATOR = "DeepTutor" @@ -138,7 +142,9 @@ def _strip_model_prefix(model: str) -> str: def _build_headers(account_id: Any, token: Any) -> dict[str, str]: if not token: - raise RuntimeError("OpenAI Codex is not logged in. Run `deeptutor provider login openai-codex`.") + raise RuntimeError( + "OpenAI Codex is not logged in. Run `deeptutor provider login openai-codex`." + ) headers = { "Authorization": f"Bearer {token}", "OpenAI-Beta": "responses=experimental", @@ -163,7 +169,9 @@ async def _request_codex( async with client.stream("POST", url, headers=headers, json=body) as response: if response.status_code != 200: text = await response.aread() - raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore"))) + raise RuntimeError( + _friendly_error(response.status_code, text.decode("utf-8", "ignore")) + ) return await consume_sse(response, on_content_delta) diff --git a/deeptutor/services/llm/provider_core/openai_compat_provider.py b/deeptutor/services/llm/provider_core/openai_compat_provider.py index 7d8e3b914..376d3ec46 100644 --- a/deeptutor/services/llm/provider_core/openai_compat_provider.py +++ b/deeptutor/services/llm/provider_core/openai_compat_provider.py @@ -9,9 +9,9 @@ import asyncio from collections.abc import Awaitable, Callable import hashlib -import time import secrets import string +import time from typing import TYPE_CHECKING, Any import uuid @@ -50,9 +50,7 @@ _RESPONSES_FAILURE_THRESHOLD = 2 _RESPONSES_PROBE_INTERVAL_S = 300.0 _THINKING_STYLE_MAP = { - "thinking_type": lambda enabled: { - "thinking": {"type": "enabled" if enabled else "disabled"} - }, + "thinking_type": lambda enabled: {"thinking": {"type": "enabled" if enabled else "disabled"}}, "enable_thinking": lambda enabled: {"enable_thinking": enabled}, "reasoning_split": lambda enabled: {"reasoning_split": enabled}, } @@ -713,9 +711,9 @@ async def chat( try: return self._parse(await self._client.chat.completions.create(**request_kwargs)) except Exception as exc: - if request_kwargs.get("response_format") is not None and self._is_response_format_error( - exc - ): + if request_kwargs.get( + "response_format" + ) is not None and self._is_response_format_error(exc): binding = self._provider_name or (self._spec.name if self._spec else "openai") disable_response_format_at_runtime(binding, request_kwargs.get("model")) retry_kwargs = dict(request_kwargs) @@ -786,9 +784,13 @@ async def _timed_stream(): except StopAsyncIteration: break - content, tool_calls, finish_reason, usage, reasoning_content = ( - await consume_sdk_stream(_timed_stream(), on_content_delta) - ) + ( + content, + tool_calls, + finish_reason, + usage, + reasoning_content, + ) = await consume_sdk_stream(_timed_stream(), on_content_delta) self._record_responses_success(model, reasoning_effort) return LLMResponse( content=content or None, @@ -809,9 +811,9 @@ async def _timed_stream(): try: stream = await self._client.chat.completions.create(**request_kwargs) except Exception as exc: - if request_kwargs.get("response_format") is not None and self._is_response_format_error( - exc - ): + if request_kwargs.get( + "response_format" + ) is not None and self._is_response_format_error(exc): binding = self._provider_name or (self._spec.name if self._spec else "openai") disable_response_format_at_runtime(binding, request_kwargs.get("model")) retry_kwargs = dict(request_kwargs) diff --git a/deeptutor/services/llm/provider_core/openai_responses/converters.py b/deeptutor/services/llm/provider_core/openai_responses/converters.py index aad414c32..059c88639 100644 --- a/deeptutor/services/llm/provider_core/openai_responses/converters.py +++ b/deeptutor/services/llm/provider_core/openai_responses/converters.py @@ -74,9 +74,7 @@ def convert_user_message(content: Any) -> dict[str, Any]: elif item.get("type") == "image_url": url = (item.get("image_url") or {}).get("url") if url: - converted.append( - {"type": "input_image", "image_url": url, "detail": "auto"} - ) + converted.append({"type": "input_image", "image_url": url, "detail": "auto"}) if converted: return {"role": "user", "content": converted} return {"role": "user", "content": [{"type": "input_text", "text": ""}]} diff --git a/deeptutor/services/llm/provider_core/openai_responses/parsing.py b/deeptutor/services/llm/provider_core/openai_responses/parsing.py index 497fef940..09ff4a337 100644 --- a/deeptutor/services/llm/provider_core/openai_responses/parsing.py +++ b/deeptutor/services/llm/provider_core/openai_responses/parsing.py @@ -2,8 +2,8 @@ from __future__ import annotations -import json from collections.abc import Awaitable, Callable +import json from typing import Any, AsyncGenerator import httpx diff --git a/deeptutor/services/rag/service.py b/deeptutor/services/rag/service.py index a551a87f1..410e530a3 100644 --- a/deeptutor/services/rag/service.py +++ b/deeptutor/services/rag/service.py @@ -223,7 +223,9 @@ async def _generate_queries(self, context: str, n: int) -> list[str]: ) raw = await complete(prompt, system_prompt="You are a search query generator.") lines = [ - l.strip().lstrip("0123456789.-) ") for l in raw.strip().split("\n") if l.strip() + line.strip().lstrip("0123456789.-) ") + for line in raw.strip().split("\n") + if line.strip() ] return lines[:n] if lines else [context[:200]] except Exception: diff --git a/deeptutor/services/session/turn_runtime.py b/deeptutor/services/session/turn_runtime.py index 1e3980d89..cac73c5b2 100644 --- a/deeptutor/services/session/turn_runtime.py +++ b/deeptutor/services/session/turn_runtime.py @@ -600,9 +600,7 @@ async def _run_turn(self, execution: _TurnExecution) -> None: from deeptutor.utils.document_extractor import extract_documents_from_records - document_texts, attachment_records = extract_documents_from_records( - attachment_records - ) + document_texts, attachment_records = extract_documents_from_records(attachment_records) attachments = [ Attachment( type=r.get("type", "file"), diff --git a/deeptutor/services/storage/attachment_store.py b/deeptutor/services/storage/attachment_store.py index e718a37cd..56ac0a19d 100644 --- a/deeptutor/services/storage/attachment_store.py +++ b/deeptutor/services/storage/attachment_store.py @@ -88,9 +88,7 @@ async def put( async def delete_session(self, session_id: str) -> None: """Best-effort cleanup of all attachments for *session_id*.""" - def resolve_path( - self, *, session_id: str, attachment_id: str, filename: str - ) -> Path | None: + def resolve_path(self, *, session_id: str, attachment_id: str, filename: str) -> Path | None: """Return the absolute path on disk for an attachment, or ``None`` if it does not exist or escapes the storage root. @@ -113,9 +111,7 @@ def __init__(self, root: Path | None = None) -> None: if override: root = Path(override).expanduser().resolve() else: - root = ( - get_path_service().get_user_root().joinpath(*_DEFAULT_SUBPATH) - ).resolve() + root = (get_path_service().get_user_root().joinpath(*_DEFAULT_SUBPATH)).resolve() self._root = root @property @@ -156,9 +152,7 @@ async def put( stored = self._stored_filename(attachment_id, filename) target = self._safe_join(session_id, stored) if target is None: - raise ValueError( - f"refusing to write attachment outside storage root: {stored!r}" - ) + raise ValueError(f"refusing to write attachment outside storage root: {stored!r}") loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._write_sync, target, data) @@ -205,9 +199,7 @@ def _rmtree_sync(path: Path) -> None: except OSError as exc: logger.warning("failed to clean up attachment dir %s: %s", path, exc) - def resolve_path( - self, *, session_id: str, attachment_id: str, filename: str - ) -> Path | None: + def resolve_path(self, *, session_id: str, attachment_id: str, filename: str) -> Path | None: stored = self._stored_filename(attachment_id, filename) target = self._safe_join(session_id, stored) if target is None or not target.is_file(): diff --git a/deeptutor/tools/question/question_extractor.py b/deeptutor/tools/question/question_extractor.py index 4f481e4d1..876088765 100644 --- a/deeptutor/tools/question/question_extractor.py +++ b/deeptutor/tools/question/question_extractor.py @@ -16,6 +16,7 @@ from datetime import datetime import json from pathlib import Path +import sys from typing import Any from deeptutor.services.config import get_agent_params diff --git a/deeptutor/tutorbot/agent/tools/filesystem.py b/deeptutor/tutorbot/agent/tools/filesystem.py index 145233576..56a0825da 100644 --- a/deeptutor/tutorbot/agent/tools/filesystem.py +++ b/deeptutor/tutorbot/agent/tools/filesystem.py @@ -178,13 +178,13 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]: old_lines = old_text.splitlines() if not old_lines: return None, 0 - stripped_old = [l.strip() for l in old_lines] + stripped_old = [line.strip() for line in old_lines] content_lines = content.splitlines() candidates = [] for i in range(len(content_lines) - len(stripped_old) + 1): window = content_lines[i : i + len(stripped_old)] - if [l.strip() for l in window] == stripped_old: + if [line.strip() for line in window] == stripped_old: candidates.append("\n".join(window)) if candidates: diff --git a/deeptutor/tutorbot/channels/matrix.py b/deeptutor/tutorbot/channels/matrix.py index 37e4de3a1..cb6a7508b 100644 --- a/deeptutor/tutorbot/channels/matrix.py +++ b/deeptutor/tutorbot/channels/matrix.py @@ -15,7 +15,6 @@ from nio import ( AsyncClient, AsyncClientConfig, - ContentRepositoryConfigError, DownloadError, InviteEvent, JoinError, diff --git a/deeptutor/tutorbot/providers/deeptutor_adapter.py b/deeptutor/tutorbot/providers/deeptutor_adapter.py index f83be5193..0fe3af0ca 100644 --- a/deeptutor/tutorbot/providers/deeptutor_adapter.py +++ b/deeptutor/tutorbot/providers/deeptutor_adapter.py @@ -7,11 +7,81 @@ from __future__ import annotations -from deeptutor.services.llm.provider_core.base import LLMProvider +from typing import Any +from deeptutor.services.llm.provider_core.base import ( + LLMProvider as CoreLLMProvider, +) +from deeptutor.services.llm.provider_core.base import ( + LLMResponse as CoreLLMResponse, +) +from deeptutor.tutorbot.providers.base import ( + GenerationSettings, + LLMProvider, + LLMResponse, + ToolCallRequest, +) -def create_deeptutor_provider() -> LLMProvider: + +class DeepTutorProviderAdapter(LLMProvider): + """Adapt DeepTutor's unified provider to TutorBot's provider protocol.""" + + def __init__(self, provider: CoreLLMProvider): + super().__init__(api_key=provider.api_key, api_base=provider.api_base) + self._provider = provider + self.generation = GenerationSettings( + temperature=provider.generation.temperature, + max_tokens=provider.generation.max_tokens, + reasoning_effort=provider.generation.reasoning_effort, + ) + + @staticmethod + def _convert_response(response: CoreLLMResponse) -> LLMResponse: + return LLMResponse( + content=response.content, + tool_calls=[ + ToolCallRequest( + id=tool_call.id, + name=tool_call.name, + arguments=tool_call.arguments, + provider_specific_fields=tool_call.provider_specific_fields, + function_provider_specific_fields=(tool_call.function_provider_specific_fields), + ) + for tool_call in response.tool_calls + ], + finish_reason=response.finish_reason, + usage=response.usage, + reasoning_content=response.reasoning_content, + thinking_blocks=response.thinking_blocks, + ) + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> LLMResponse: + response = await self._provider.chat( + messages=messages, + tools=tools, + model=model, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + tool_choice=tool_choice, + ) + return self._convert_response(response) + + def get_default_model(self) -> str: + return self._provider.get_default_model() + + +def create_deeptutor_provider() -> DeepTutorProviderAdapter: """Build a provider pre-configured from DeepTutor's LLMConfig.""" from deeptutor.services.llm.provider_factory import get_runtime_provider - return get_runtime_provider() + return DeepTutorProviderAdapter(get_runtime_provider()) diff --git a/deeptutor/utils/document_extractor.py b/deeptutor/utils/document_extractor.py index bb0209cc9..0a76690a6 100644 --- a/deeptutor/utils/document_extractor.py +++ b/deeptutor/utils/document_extractor.py @@ -177,7 +177,9 @@ def _extract_pdf(data: bytes, filename: str) -> str: raise CorruptDocumentError( f"{filename} is encrypted and cannot be read", filename=filename ) - pages = [f"--- Page {i} ---\n{page.get_text() or ''}" for i, page in enumerate(doc, 1)] + pages = [ + f"--- Page {i} ---\n{page.get_text() or ''}" for i, page in enumerate(doc, 1) + ] return "\n\n".join(pages) except CorruptDocumentError: raise @@ -195,7 +197,10 @@ def _extract_pdf(data: bytes, filename: str) -> str: raise CorruptDocumentError( f"{filename} is encrypted and cannot be read", filename=filename ) - pages = [f"--- Page {i} ---\n{page.extract_text() or ''}" for i, page in enumerate(reader.pages, 1)] + pages = [ + f"--- Page {i} ---\n{page.extract_text() or ''}" + for i, page in enumerate(reader.pages, 1) + ] return "\n\n".join(pages) except CorruptDocumentError: raise @@ -204,14 +209,14 @@ def _extract_pdf(data: bytes, filename: str) -> str: f"{filename} is encrypted and cannot be read", filename=filename ) from exc except Exception as exc: - raise CorruptDocumentError(f"{filename}: failed to read PDF ({exc})", filename=filename) from exc + raise CorruptDocumentError( + f"{filename}: failed to read PDF ({exc})", filename=filename + ) from exc def _extract_docx(data: bytes, filename: str) -> str: if DocxDocument is None: - raise CorruptDocumentError( - f"{filename}: python-docx not installed", filename=filename - ) + raise CorruptDocumentError(f"{filename}: python-docx not installed", filename=filename) try: doc = DocxDocument(io.BytesIO(data)) except Exception as exc: @@ -224,9 +229,7 @@ def _extract_docx(data: bytes, filename: str) -> str: def _extract_xlsx(data: bytes, filename: str) -> str: if load_workbook is None: - raise CorruptDocumentError( - f"{filename}: openpyxl not installed", filename=filename - ) + raise CorruptDocumentError(f"{filename}: openpyxl not installed", filename=filename) try: wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True) except Exception as exc: @@ -251,9 +254,7 @@ def _extract_xlsx(data: bytes, filename: str) -> str: def _extract_pptx(data: bytes, filename: str) -> str: if PptxPresentation is None: - raise CorruptDocumentError( - f"{filename}: python-pptx not installed", filename=filename - ) + raise CorruptDocumentError(f"{filename}: python-pptx not installed", filename=filename) try: prs = PptxPresentation(io.BytesIO(data)) except Exception as exc: @@ -348,9 +349,7 @@ def extract_documents_from_records( continue if over_quota: - doc_texts.append( - f"[File: {filename} — skipped: total attachment quota exceeded]" - ) + doc_texts.append(f"[File: {filename} — skipped: total attachment quota exceeded]") record["base64"] = "" record["extracted_chars"] = 0 updated.append(record) @@ -367,9 +366,7 @@ def extract_documents_from_records( if total_bytes + len(data) > MAX_TOTAL_DOC_BYTES: over_quota = True - doc_texts.append( - f"[File: {filename} — skipped: total attachment quota exceeded]" - ) + doc_texts.append(f"[File: {filename} — skipped: total attachment quota exceeded]") record["base64"] = "" record["extracted_chars"] = 0 updated.append(record) @@ -389,9 +386,7 @@ def extract_documents_from_records( remaining_budget = MAX_EXTRACTED_CHARS_TOTAL - total_chars if remaining_budget <= 0: - doc_texts.append( - f"[File: {filename} — skipped: total extracted-text quota exceeded]" - ) + doc_texts.append(f"[File: {filename} — skipped: total extracted-text quota exceeded]") record["base64"] = "" record["extracted_chars"] = 0 updated.append(record) diff --git a/deeptutor_cli/notebook.py b/deeptutor_cli/notebook.py index 35b426082..4c86f3ed5 100644 --- a/deeptutor_cli/notebook.py +++ b/deeptutor_cli/notebook.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path import typer @@ -10,8 +11,6 @@ from .common import console, print_notebook_table -from pathlib import Path - def register(app: typer.Typer) -> None: @app.command("list") diff --git a/pyproject.toml b/pyproject.toml index d9899d536..9e63647a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -263,13 +263,10 @@ ignore_errors = true # Some tools may have dynamic imports # ============================================ [tool.bandit] exclude_dirs = ["tests", "scripts"] -# B101=assert, B311=random, B403/B404=pickle/subprocess imports +# B101=assert # B110=try_except_pass (intentional for non-critical error handling) -# B104=hardcoded_bind_all_interfaces (required for server binding) # B112=try_except_continue (intentional for iteration) -# B105=hardcoded_password_string (false positive on empty string initialization) -# B301=pickle (used for internal embedding cache, not untrusted data) # B501=request_with_no_cert_validation (opt-in via env var for dev/testing) # B603=subprocess_without_shell_equals_true (controlled execution) # B202=tarfile_unsafe_members (already using safe_members filter) -skips = ["B101", "B311", "B403", "B404", "B110", "B104", "B112", "B105", "B301", "B501", "B603", "B202"] +skips = ["B101", "B110", "B112", "B202", "B501", "B603"] diff --git a/scripts/start_tour.py b/scripts/start_tour.py index 637706cbb..b65eccd9d 100644 --- a/scripts/start_tour.py +++ b/scripts/start_tour.py @@ -74,7 +74,6 @@ def _can_import(name: str) -> bool: return False - def _bootstrap() -> None: missing = [pip for imp, pip in _BOOTSTRAP_PACKAGES if not _can_import(imp)] if not missing: @@ -100,7 +99,6 @@ def _bootstrap() -> None: _bootstrap() - def _load_runtime_deps(): from _cli_kit import ( accent, @@ -457,7 +455,6 @@ def _node_strategy() -> str: return "manual" - def _get_npm_command() -> str: if platform.system().lower() == "windows": return "npm.cmd" @@ -467,7 +464,6 @@ def _get_npm_command() -> str: return "npm" - def _install_commands( profile: str, catalog: dict[str, Any], @@ -489,15 +485,12 @@ def _install_commands( PROJECT_ROOT, ) ) - cmds.append( - ([*_PIP_CMD, "install", "-e", ".", "--no-deps", *_PIP_PYTHON_ARGS], PROJECT_ROOT) - ) + cmds.append(([*_PIP_CMD, "install", "-e", ".", "--no-deps", *_PIP_PYTHON_ARGS], PROJECT_ROOT)) if profile.startswith("web"): cmds.append(([_get_npm_command(), "install"], PROJECT_ROOT / "web")) return cmds - def _run_cmd(cmd: list[str], cwd: Path) -> None: log_info(f"{dim(str(cwd))} {' '.join(cmd)}") use_shell = platform.system().lower() == "windows" @@ -506,7 +499,6 @@ def _run_cmd(cmd: list[str], cwd: Path) -> None: raise RuntimeError(f"Command failed (exit {result.returncode}): {' '.join(cmd)}") - def _stream_text_kwargs() -> dict[str, object]: """Best-effort text decoding for subprocess output.""" encoding = locale.getpreferredencoding(False) or "utf-8" @@ -530,13 +522,11 @@ def _set_language(language: str) -> None: _LANG = "zh" if str(language).strip().lower().startswith("zh") else "en" - def _t(key: str, **kwargs: Any) -> str: template = MESSAGES[_LANG].get(key, MESSAGES["en"].get(key, key)) return template.format(**kwargs) - def _secret_mask(value: str) -> str: if not value: return "-" @@ -545,7 +535,6 @@ def _secret_mask(value: str) -> str: return f"{value[:4]}...{value[-4:]}" - def _save_ui_language(language: str, path: Path = INTERFACE_SETTINGS_PATH) -> None: payload: dict[str, Any] = {"theme": "light", "language": language} if path.exists(): @@ -558,7 +547,6 @@ def _save_ui_language(language: str, path: Path = INTERFACE_SETTINGS_PATH) -> No path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - def _ensure_env_file(env_path: Path = ENV_PATH, template_path: Path = ENV_EXAMPLE_PATH) -> bool: if env_path.exists(): return False @@ -570,7 +558,6 @@ def _ensure_env_file(env_path: Path = ENV_PATH, template_path: Path = ENV_EXAMPL return True - def _cleanup_legacy_tour_cache(path: Path = LEGACY_TOUR_CACHE_PATH) -> bool: if not path.exists(): return False @@ -578,7 +565,6 @@ def _cleanup_legacy_tour_cache(path: Path = LEGACY_TOUR_CACHE_PATH) -> bool: return True - def _prompt_int(prompt: str, default: int) -> int: while True: value = text_input(prompt, str(default)).strip() @@ -588,15 +574,15 @@ def _prompt_int(prompt: str, default: int) -> int: log_warn(f"{prompt}: {value!r} is not a valid integer.") - def _prompt_secret(prompt: str, default: str) -> str: if default: log_info(dim(_t("keep_secret"))) return text_input(prompt, default, secret=True) - -def _enum_options(options: list[tuple[str, str, str]], current: str | None = None) -> list[tuple[str, str, str]]: +def _enum_options( + options: list[tuple[str, str, str]], current: str | None = None +) -> list[tuple[str, str, str]]: normalized_current = str(current or "").strip() if not normalized_current: return options @@ -608,7 +594,6 @@ def _enum_options(options: list[tuple[str, str, str]], current: str | None = Non return [(normalized_current, current_label, current_desc)] + options - def _load_provider_metadata(): from deeptutor.services.config.provider_runtime import EMBEDDING_PROVIDERS from deeptutor.services.provider_registry import PROVIDERS, find_by_name @@ -616,7 +601,6 @@ def _load_provider_metadata(): return EMBEDDING_PROVIDERS, find_by_name, PROVIDERS - # Order in which provider modes are listed in the wizard. _LLM_MODE_ORDER = { "standard": 0, @@ -678,7 +662,6 @@ def sort_key(spec) -> tuple[int, int, str]: return _enum_options(options, current) - def _embedding_provider_options(current: str | None) -> list[tuple[str, str, str]]: embedding_providers, _, _ = _load_provider_metadata() common = ["openai", "jina", "cohere", "ollama", "vllm", "azure_openai", "custom"] @@ -696,7 +679,6 @@ def _embedding_provider_options(current: str | None) -> list[tuple[str, str, str return _enum_options(options, current) - def _search_provider_options(current: str | None) -> list[tuple[str, str, str]]: options = [ (value, label, _t("search_none_desc") if value == "none" else desc) @@ -705,8 +687,9 @@ def _search_provider_options(current: str | None) -> list[tuple[str, str, str]]: return _enum_options(options, current) - -def _default_base_url(binding: str, current_binding: str, current_value: str, fallback: str = "") -> str: +def _default_base_url( + binding: str, current_binding: str, current_value: str, fallback: str = "" +) -> str: if current_value and binding == current_binding: return current_value embedding_providers, find_by_name, _ = _load_provider_metadata() @@ -718,14 +701,12 @@ def _default_base_url(binding: str, current_binding: str, current_value: str, fa return fallback - def _default_llm_model(binding: str, current_binding: str, current_model: str) -> str: if current_model and binding == current_binding: return current_model return LLM_MODEL_SUGGESTIONS.get(binding, current_model) - def _default_embedding_model(binding: str, current_binding: str, current_model: str) -> str: if current_model and binding == current_binding: return current_model @@ -736,7 +717,6 @@ def _default_embedding_model(binding: str, current_binding: str, current_model: return EMBEDDING_MODEL_SUGGESTIONS.get(binding, current_model) - def _default_embedding_dimension(binding: str, current_binding: str, current_value: str) -> str: if current_value and binding == current_binding: return current_value @@ -747,7 +727,6 @@ def _default_embedding_dimension(binding: str, current_binding: str, current_val return current_value or "3072" - def _send_dimensions_choice(current_value: str) -> str: normalized = str(current_value or "").strip().lower() if normalized in {"true", "1", "yes", "on"}: @@ -756,14 +735,17 @@ def _send_dimensions_choice(current_value: str) -> str: default = "false" else: default = "auto" - return select( - _t("send_dimensions"), - [ - ("auto", _t("send_dimensions_auto"), ""), - ("true", _t("send_dimensions_yes"), ""), - ("false", _t("send_dimensions_no"), ""), - ], - ) or default + return ( + select( + _t("send_dimensions"), + [ + ("auto", _t("send_dimensions_auto"), ""), + ("true", _t("send_dimensions_yes"), ""), + ("false", _t("send_dimensions_no"), ""), + ], + ) + or default + ) # --------------------------------------------------------------------------- @@ -1017,7 +999,6 @@ def _choose_language() -> str: return language - def _configure_ports() -> dict[str, str]: step(3, _TOTAL_STEPS, _t("ports_step")) summary = get_env_store().as_summary() @@ -1030,7 +1011,6 @@ def _configure_ports() -> dict[str, str]: } - def _configure_llm() -> dict[str, str]: step(4, _TOTAL_STEPS, _t("llm_step")) summary = get_env_store().as_summary() @@ -1061,7 +1041,6 @@ def _configure_llm() -> dict[str, str]: } - def _configure_embedding() -> dict[str, str]: step(5, _TOTAL_STEPS, _t("embedding_step")) summary = get_env_store().as_summary() @@ -1099,7 +1078,6 @@ def _configure_embedding() -> dict[str, str]: } - def _configure_search() -> dict[str, str]: step(6, _TOTAL_STEPS, _t("search_step")) summary = get_env_store().as_summary() @@ -1143,7 +1121,6 @@ def _configure_search() -> dict[str, str]: } - def _print_review(values: dict[str, str]) -> None: step(7, _TOTAL_STEPS, _t("review_step")) log_info( @@ -1174,12 +1151,10 @@ def _print_review(values: dict[str, str]) -> None: print() - def _write_env(values: dict[str, str]) -> None: get_env_store().write(values) - def _tour_banner() -> None: banner( "DeepTutor Setup Tour / DeepTutor 配置向导", @@ -1190,7 +1165,6 @@ def _tour_banner() -> None: ) - def run_tour() -> None: _tour_banner() @@ -1234,7 +1208,6 @@ def run_tour() -> None: print() - def main() -> None: try: run_tour() diff --git a/scripts/update.py b/scripts/update.py index 3760c07cc..280b6cc88 100644 --- a/scripts/update.py +++ b/scripts/update.py @@ -237,7 +237,9 @@ def analyze_gap(git: Git, target: BranchTarget) -> BranchGap: diff_stat = "" if behind: - diff_stat = git.run(["diff", "--stat", "--compact-summary", f"HEAD..{target.remote_ref}"]).stdout + diff_stat = git.run( + ["diff", "--stat", "--compact-summary", f"HEAD..{target.remote_ref}"] + ).stdout return BranchGap( local_sha=local_sha, @@ -329,11 +331,10 @@ def ensure_safe_to_update(gap: BranchGap) -> None: def dependency_hints(changed_files: list[str]) -> list[str]: hints: list[str] = [] - if any( - path == "pyproject.toml" or path.startswith("requirements/") - for path in changed_files - ): - hints.append('Backend dependencies changed: consider running python -m pip install -e ".[server]"') + if any(path == "pyproject.toml" or path.startswith("requirements/") for path in changed_files): + hints.append( + 'Backend dependencies changed: consider running python -m pip install -e ".[server]"' + ) if any( path in {"web/package.json", "web/package-lock.json", "web/pnpm-lock.yaml"} or path == "web/yarn.lock" diff --git a/tests/api/test_knowledge_router.py b/tests/api/test_knowledge_router.py index 9d931a397..44e40e585 100644 --- a/tests/api/test_knowledge_router.py +++ b/tests/api/test_knowledge_router.py @@ -165,9 +165,7 @@ async def _noop_init_task(*_args, **_kwargs): assert manager.config["knowledge_bases"]["kb-legacy"]["rag_provider"] == "llamaindex" -def test_create_rejects_invalid_files_before_registering_kb( - monkeypatch, tmp_path: Path -) -> None: +def test_create_rejects_invalid_files_before_registering_kb(monkeypatch, tmp_path: Path) -> None: manager = _FakeKBManager(tmp_path / "knowledge_bases") monkeypatch.setattr(knowledge_router_module, "get_kb_manager", lambda: manager) monkeypatch.setattr(knowledge_router_module, "_kb_base_dir", tmp_path / "knowledge_bases") diff --git a/tests/cli/test_notebook_cli.py b/tests/cli/test_notebook_cli.py index 9153bea92..ffed05244 100644 --- a/tests/cli/test_notebook_cli.py +++ b/tests/cli/test_notebook_cli.py @@ -18,7 +18,8 @@ def __init__(self) -> None: self.notebooks: dict[str, dict] = {} def create_notebook(self, name: str, description: str = "") -> dict: - import time, uuid + import time + import uuid nb = { "id": str(uuid.uuid4())[:8], @@ -45,7 +46,8 @@ def add_record( metadata: dict | None = None, kb_name: str | None = None, ) -> dict: - import time, uuid + import time + import uuid record = { "id": str(uuid.uuid4())[:8], @@ -153,10 +155,14 @@ def test_notebook_add_md_custom_title_and_type(monkeypatch, tmp_path: Path) -> N result = runner.invoke( app, [ - "notebook", "add-md", - notebook["id"], str(md_file), - "--title", "My Custom Title", - "--type", "research", + "notebook", + "add-md", + notebook["id"], + str(md_file), + "--title", + "My Custom Title", + "--type", + "research", ], ) diff --git a/tests/services/config/test_context_window_detection.py b/tests/services/config/test_context_window_detection.py index c996fcaff..dfcf18fe4 100644 --- a/tests/services/config/test_context_window_detection.py +++ b/tests/services/config/test_context_window_detection.py @@ -52,9 +52,7 @@ def test_detect_context_window_uses_runtime_default_when_metadata_missing( "deeptutor.services.config.context_window_detection._detect_from_models_endpoint", _metadata_none, ) - result = asyncio.run( - detect_context_window(_config(model="unknown-model", max_tokens=5000)) - ) + result = asyncio.run(detect_context_window(_config(model="unknown-model", max_tokens=5000))) assert result.context_window == 20000 assert result.source == "default" diff --git a/tests/services/embedding/test_client_runtime.py b/tests/services/embedding/test_client_runtime.py index 1c8be7b5f..88cc87aff 100644 --- a/tests/services/embedding/test_client_runtime.py +++ b/tests/services/embedding/test_client_runtime.py @@ -31,9 +31,7 @@ async def embed(self, request): )() -def _build_config( - binding: str, *, send_dimensions: bool | None = None -) -> EmbeddingConfig: +def _build_config(binding: str, *, send_dimensions: bool | None = None) -> EmbeddingConfig: return EmbeddingConfig( model="text-embedding-3-small", api_key="sk-test", diff --git a/tests/services/embedding/test_send_dimensions.py b/tests/services/embedding/test_send_dimensions.py index a5691891a..eebf4c9d0 100644 --- a/tests/services/embedding/test_send_dimensions.py +++ b/tests/services/embedding/test_send_dimensions.py @@ -26,7 +26,6 @@ OpenAICompatibleEmbeddingAdapter, ) - # --------------------------------------------------------------------------- # _should_send_dimensions — pure tri-state logic # --------------------------------------------------------------------------- diff --git a/tests/services/llm/test_factory_provider_exec.py b/tests/services/llm/test_factory_provider_exec.py index 8f2aa95d8..426eb5a83 100644 --- a/tests/services/llm/test_factory_provider_exec.py +++ b/tests/services/llm/test_factory_provider_exec.py @@ -64,7 +64,9 @@ def _fake_get_runtime_provider(config: LLMConfig): captured_config["config"] = config return provider - monkeypatch.setattr("deeptutor.services.llm.factory.get_runtime_provider", _fake_get_runtime_provider) + monkeypatch.setattr( + "deeptutor.services.llm.factory.get_runtime_provider", _fake_get_runtime_provider + ) result = await complete("hello", extra_headers={"X-Caller": "from-caller"}) @@ -85,7 +87,9 @@ def _fake_get_runtime_provider(config: LLMConfig): captured_config["config"] = config return provider - monkeypatch.setattr("deeptutor.services.llm.factory.get_runtime_provider", _fake_get_runtime_provider) + monkeypatch.setattr( + "deeptutor.services.llm.factory.get_runtime_provider", _fake_get_runtime_provider + ) chunks = [] async for chunk in stream("hello", extra_headers={"X-Caller": "clr"}): diff --git a/tests/services/rag/test_pipeline_integration.py b/tests/services/rag/test_pipeline_integration.py index 4d24687da..dd3777390 100644 --- a/tests/services/rag/test_pipeline_integration.py +++ b/tests/services/rag/test_pipeline_integration.py @@ -22,6 +22,7 @@ import os from pathlib import Path import shutil +import sys import tempfile from dotenv import load_dotenv diff --git a/tests/utils/test_document_extractor.py b/tests/utils/test_document_extractor.py index 9973d0a33..38550d5c5 100644 --- a/tests/utils/test_document_extractor.py +++ b/tests/utils/test_document_extractor.py @@ -12,18 +12,17 @@ import pytest from deeptutor.utils.document_extractor import ( + MAX_DOC_BYTES, + MAX_EXTRACTED_CHARS_PER_DOC, CorruptDocumentError, DocumentTooLargeError, EmptyDocumentError, - MAX_DOC_BYTES, - MAX_EXTRACTED_CHARS_PER_DOC, UnsupportedDocumentError, extract_documents_from_records, extract_text_from_bytes, is_document_extension, ) - # --------------------------------------------------------------------------- # Fixtures — generate office docs on the fly # --------------------------------------------------------------------------- @@ -175,7 +174,7 @@ def test_svg(self) -> None: b'' b'' b'Hello' - b'' + b"" ) text = extract_text_from_bytes("logo.svg", svg) assert " None: image_b64 = base64.b64encode(b"\x89PNG\r\n\x1a\n").decode() records = [ - {"type": "image", "filename": "pic.png", "base64": image_b64, "mime_type": "image/png", "url": ""}, - {"type": "file", "filename": "note.docx", "base64": docx_b64, "mime_type": "", "url": ""}, + { + "type": "image", + "filename": "pic.png", + "base64": image_b64, + "mime_type": "image/png", + "url": "", + }, + { + "type": "file", + "filename": "note.docx", + "base64": docx_b64, + "mime_type": "", + "url": "", + }, ] doc_texts, updated = extract_documents_from_records(records) @@ -284,13 +295,23 @@ def test_mixed_image_and_doc(self) -> None: assert updated[1]["extracted_chars"] > 0 def test_unsupported_record_is_passthrough(self) -> None: - records = [{"type": "file", "filename": "foo.zip", "base64": "AAAA", "mime_type": "", "url": ""}] + records = [ + {"type": "file", "filename": "foo.zip", "base64": "AAAA", "mime_type": "", "url": ""} + ] doc_texts, updated = extract_documents_from_records(records) assert doc_texts == [] assert updated[0]["base64"] == "AAAA" # untouched — not a doc extension def test_failed_extraction_emits_error_marker(self) -> None: - records = [{"type": "file", "filename": "bad.pdf", "base64": base64.b64encode(b"not a pdf").decode(), "mime_type": "", "url": ""}] + records = [ + { + "type": "file", + "filename": "bad.pdf", + "base64": base64.b64encode(b"not a pdf").decode(), + "mime_type": "", + "url": "", + } + ] doc_texts, updated = extract_documents_from_records(records) assert len(doc_texts) == 1 assert "bad.pdf" in doc_texts[0] @@ -298,7 +319,15 @@ def test_failed_extraction_emits_error_marker(self) -> None: assert updated[0]["base64"] == "" # stripped even on failure def test_invalid_base64_emits_error_marker(self) -> None: - records = [{"type": "file", "filename": "bad.docx", "base64": "!!!not base64!!!", "mime_type": "", "url": ""}] + records = [ + { + "type": "file", + "filename": "bad.docx", + "base64": "!!!not base64!!!", + "mime_type": "", + "url": "", + } + ] doc_texts, updated = extract_documents_from_records(records) # invalid base64 with validate=False may silently decode or emit error — both # paths end up as an error marker since resulting bytes won't pass magic check diff --git a/web/app/(utility)/knowledge/page.tsx b/web/app/(utility)/knowledge/page.tsx index 368328f60..0a0872213 100644 --- a/web/app/(utility)/knowledge/page.tsx +++ b/web/app/(utility)/knowledge/page.tsx @@ -213,7 +213,8 @@ const DEFAULT_UPLOAD_POLICY: KnowledgeUploadPolicy = { }; const formatFileSize = (bytes: number): string => { - if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + if (bytes >= 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${bytes} B`; @@ -289,8 +290,9 @@ function KnowledgePageContent() { const [knowledgeBases, setKnowledgeBases] = useState([]); const [notebooks, setNotebooks] = useState([]); const [providers, setProviders] = useState([]); - const [uploadPolicy, setUploadPolicy] = - useState(DEFAULT_UPLOAD_POLICY); + const [uploadPolicy, setUploadPolicy] = useState( + DEFAULT_UPLOAD_POLICY, + ); const [loading, setLoading] = useState(true); const [pageError, setPageError] = useState(null); const [creating, setCreating] = useState(false); @@ -325,10 +327,12 @@ function KnowledgePageContent() { const uploadFileRef = useRef(null); const createDropDepthRef = useRef(0); const uploadDropDepthRef = useRef(0); - const [createDropZone, setCreateDropZone] = - useState(EMPTY_DROP_ZONE_STATE); - const [uploadDropZone, setUploadDropZone] = - useState(EMPTY_DROP_ZONE_STATE); + const [createDropZone, setCreateDropZone] = useState( + EMPTY_DROP_ZONE_STATE, + ); + const [uploadDropZone, setUploadDropZone] = useState( + EMPTY_DROP_ZONE_STATE, + ); const validateFileSelection = useCallback( (files: File[]): ValidatedFileSelection => { @@ -426,7 +430,8 @@ function KnowledgePageContent() { const depthRef = kind === "create" ? createDropDepthRef : uploadDropDepthRef; - const setDropZone = kind === "create" ? setCreateDropZone : setUploadDropZone; + const setDropZone = + kind === "create" ? setCreateDropZone : setUploadDropZone; depthRef.current += 1; const previewFiles = Array.from(event.dataTransfer.items) @@ -852,7 +857,9 @@ function KnowledgePageContent() { source.addEventListener("progress", (event) => { if (!kbName) return; try { - const payload = JSON.parse((event as MessageEvent).data) as ProgressInfo; + const payload = JSON.parse( + (event as MessageEvent).data, + ) as ProgressInfo; setProgressMap((prev) => ({ ...prev, [kbName]: payload })); } catch { // Ignore malformed progress events. @@ -904,7 +911,10 @@ function KnowledgePageContent() { }; }; - const loadAll = async (options?: { force?: boolean; showSpinner?: boolean }) => { + const loadAll = async (options?: { + force?: boolean; + showSpinner?: boolean; + }) => { const showSpinner = options?.showSpinner ?? true; if (showSpinner) setLoading(true); setPageError(null); @@ -1154,7 +1164,12 @@ function KnowledgePageContent() { const data = (await res.json()) as KnowledgeTaskResponse; invalidateKnowledgeCaches(); if (data.task_id) { - openTaskLogStream("upload", data.task_id, `Upload to ${targetKb}`, targetKb); + openTaskLogStream( + "upload", + data.task_id, + `Upload to ${targetKb}`, + targetKb, + ); subscribeProgress(targetKb, data.task_id); setProgressMap((prev) => ({ ...prev, @@ -1509,10 +1524,10 @@ function KnowledgePageContent() { return (
-
+
{/* Header */} -
-
+
+

{t("Knowledge")}

@@ -1521,7 +1536,7 @@ function KnowledgePageContent() {

-
+
{[ { key: "knowledge", label: t("Knowledge Bases"), icon: Database }, { key: "notebooks", label: t("Notebooks"), icon: NotebookPen }, @@ -1533,16 +1548,18 @@ function KnowledgePageContent() { { key: "skills", label: t("Skills"), icon: Wand2 }, ].map((item) => ( ))}
@@ -1596,8 +1613,12 @@ function KnowledgePageContent() {

{createDropZone.active @@ -1633,8 +1654,8 @@ function KnowledgePageContent() { }) : t("Release to attach the files") : newKbFiles.length - ? formatFileSize(newKbSelection.totalBytes) - : t("Click to browse supported documents")} + ? formatFileSize(newKbSelection.totalBytes) + : t("Click to browse supported documents")}

@@ -1660,10 +1681,15 @@ function KnowledgePageContent() { }} /> - {renderSelectionSummary(newKbSelection, removeNewKbFile, () => { - setNewKbFiles([]); - if (createFileRef.current) createFileRef.current.value = ""; - })} + {renderSelectionSummary( + newKbSelection, + removeNewKbFile, + () => { + setNewKbFiles([]); + if (createFileRef.current) + createFileRef.current.value = ""; + }, + )}

{uploadDropZone.active @@ -1803,8 +1833,8 @@ function KnowledgePageContent() { }) : t("Release to attach the files") : uploadFiles.length - ? formatFileSize(uploadSelection.totalBytes) - : t("Click to browse supported documents")} + ? formatFileSize(uploadSelection.totalBytes) + : t("Click to browse supported documents")}

@@ -1826,14 +1856,21 @@ function KnowledgePageContent() { onChange={(event) => { const picked = Array.from(event.target.files || []); event.target.value = ""; - setUploadFiles((prev) => mergeSelectedFiles(prev, picked)); + setUploadFiles((prev) => + mergeSelectedFiles(prev, picked), + ); }} /> - {renderSelectionSummary(uploadSelection, removeUploadFile, () => { - setUploadFiles([]); - if (uploadFileRef.current) uploadFileRef.current.value = ""; - })} + {renderSelectionSummary( + uploadSelection, + removeUploadFile, + () => { + setUploadFiles([]); + if (uploadFileRef.current) + uploadFileRef.current.value = ""; + }, + )}
@@ -2008,15 +2048,23 @@ function KnowledgePageContent() { {documentsCount} {" "} - {documentsCount === 1 ? t("document") : t("documents")} + {documentsCount === 1 + ? t("document") + : t("documents")} - + {kb.statistics?.rag_initialized ? t("Vector index ready") : t("Index not ready")} - + {t("Updated")} {updatedLabel} @@ -2027,56 +2075,57 @@ function KnowledgePageContent() { )} - {(isLive || isError || needsReindex) && activityMessage && ( -
-
-
- {isError - ? t("Latest activity") - : isLive - ? t("Live pipeline") - : t("Action required")} -
- {isLive && percent > 0 && ( -
- {percent}% -
- )} -
-
- {activityMessage} -
- {isLive && ( -
-
-
+ {(isLive || isError || needsReindex) && + activityMessage && ( +
+
+
+ {isError + ? t("Latest activity") + : isLive + ? t("Live pipeline") + : t("Action required")}
+ {isLive && percent > 0 && ( +
+ {percent}% +
+ )}
- )} - {isLive && - progress?.current !== undefined && - progress?.total !== undefined && - progress.total > 0 && ( -
- {t("Step {{current}} of {{total}}", { - current: progress.current, - total: progress.total, - })} +
+ {activityMessage} +
+ {isLive && ( +
+
+
+
)} -
- )} + {isLive && + progress?.current !== undefined && + progress?.total !== undefined && + progress.total > 0 && ( +
+ {t("Step {{current}} of {{total}}", { + current: progress.current, + total: progress.total, + })} +
+ )} +
+ )}
); })} diff --git a/web/app/(utility)/layout.tsx b/web/app/(utility)/layout.tsx index 1f55e111d..bcbf92c2a 100644 --- a/web/app/(utility)/layout.tsx +++ b/web/app/(utility)/layout.tsx @@ -6,9 +6,9 @@ export default function UtilityLayout({ children: React.ReactNode; }>) { return ( -
+
-
+
{children}
diff --git a/web/app/(utility)/memory/page.tsx b/web/app/(utility)/memory/page.tsx index 3716a3831..5197bc644 100644 --- a/web/app/(utility)/memory/page.tsx +++ b/web/app/(utility)/memory/page.tsx @@ -183,7 +183,7 @@ export default function MemoryPage() { return (
-
+
{/* Header */}
diff --git a/web/app/(utility)/notebook/page.tsx b/web/app/(utility)/notebook/page.tsx index f8499c95d..e9f6eb21b 100644 --- a/web/app/(utility)/notebook/page.tsx +++ b/web/app/(utility)/notebook/page.tsx @@ -192,7 +192,7 @@ export default function NotebookPage() { return (
-
+
{/* Header */}
diff --git a/web/app/(utility)/settings/page.tsx b/web/app/(utility)/settings/page.tsx index 0eeaea4c4..83056342c 100644 --- a/web/app/(utility)/settings/page.tsx +++ b/web/app/(utility)/settings/page.tsx @@ -262,12 +262,15 @@ function SpotlightOverlay({ const guideStep = TOUR_GUIDE_STEPS[stepIndex]; useEffect(() => { - if (!guideStep) return; - const el = document.querySelector(`[data-tour="${guideStep.target}"]`); - if (el) { - const r = el.getBoundingClientRect(); - setRect(r); - } + const frame = window.requestAnimationFrame(() => { + if (!guideStep) { + setRect(null); + return; + } + const el = document.querySelector(`[data-tour="${guideStep.target}"]`); + setRect(el ? el.getBoundingClientRect() : null); + }); + return () => window.cancelAnimationFrame(frame); }, [guideStep]); if (!guideStep || !rect) return null; @@ -585,10 +588,7 @@ function SettingsPageContent() { }); }; - const updateModelBoolField = ( - field: keyof CatalogModel, - value: boolean, - ) => { + const updateModelBoolField = (field: keyof CatalogModel, value: boolean) => { if (activeService === "search") return; mutateCatalog((next) => { const model = getActiveModel(next, activeService); @@ -714,7 +714,7 @@ function SettingsPageContent() { return (
-
+
{/* ── Header ── */}
@@ -820,7 +820,7 @@ function SettingsPageContent() {
-
+
-
-
+
+
{(["llm", "embedding", "search"] as const).map((service) => ( ))}
-
+
- + {bot?.name ?? botId} {bot?.running && ( )} -
+
@@ -266,7 +275,7 @@ export default function BotChatPage() { {/* Messages */}
{messages.length === 0 && !streaming && ( @@ -289,7 +298,7 @@ export default function BotChatPage() { className={msg.role === "user" ? "flex justify-end" : ""} > {msg.role === "user" ? ( -
+
{msg.content}
) : ( @@ -346,7 +355,7 @@ export default function BotChatPage() {
{/* Input */} -
+
-
+
{/* Header */}

@@ -129,7 +129,7 @@ export default function AgentsPage() {

{/* Tabs */} -
+
{[ { key: "bots" as Tab, label: t("Bots"), icon: Bot }, { key: "profiles" as Tab, label: t("Profiles"), icon: FileText }, @@ -142,7 +142,7 @@ export default function AgentsPage() {
-
+
+
+ + setQuery(e.target.value)} + placeholder="Search books" + className="h-9 w-full rounded-md border border-[var(--border)] bg-[var(--secondary)]/30 pl-7 pr-2.5 text-[16px] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]/60 focus:border-[var(--primary)]/40 focus:outline-none sm:text-xs" + /> +
{/* Stats row */}
+