From 30e476c068ad0576ba36851e68240e4206d5b8f3 Mon Sep 17 00:00:00 2001 From: Yang He Date: Sun, 26 Apr 2026 13:30:51 +0800 Subject: [PATCH 1/4] feat: improve mobile responsive layout --- web/app/(utility)/knowledge/page.tsx | 2 +- web/app/(utility)/layout.tsx | 4 +- web/app/(utility)/memory/page.tsx | 2 +- web/app/(utility)/notebook/page.tsx | 2 +- web/app/(utility)/settings/page.tsx | 14 +- .../(workspace)/agents/[botId]/chat/page.tsx | 27 +-- web/app/(workspace)/agents/page.tsx | 6 +- .../book/components/BookChatPanel.tsx | 4 +- .../book/components/BookLibrary.tsx | 19 +- .../book/components/BookSidebar.tsx | 8 +- .../book/components/PageReader.tsx | 10 +- web/app/(workspace)/book/page.tsx | 6 +- .../chat/[[...sessionId]]/page.tsx | 37 ++-- web/app/(workspace)/co-writer/page.tsx | 14 +- web/app/(workspace)/layout.tsx | 4 +- web/app/(workspace)/playground/page.tsx | 8 +- web/app/globals.css | 9 + web/components/chat/HistorySessionPicker.tsx | 14 +- web/components/chat/QuestionBankPicker.tsx | 14 +- web/components/chat/home/ChatComposer.tsx | 52 ++--- web/components/chat/home/ChatMessages.tsx | 12 +- web/components/chat/home/ComposerInput.tsx | 4 +- .../chat/home/SimpleComposerInput.tsx | 2 +- .../chat/preview/FilePreviewDrawer.tsx | 2 +- web/components/common/Modal.tsx | 12 +- .../notebook/NotebookRecordPicker.tsx | 8 +- .../notebook/SaveToNotebookModal.tsx | 8 +- web/components/sidebar/SidebarShell.tsx | 186 +++++++++++------- web/tests/e2e/compliance-and-ux.audit.ts | 17 ++ 29 files changed, 309 insertions(+), 198 deletions(-) diff --git a/web/app/(utility)/knowledge/page.tsx b/web/app/(utility)/knowledge/page.tsx index 368328f60..436532124 100644 --- a/web/app/(utility)/knowledge/page.tsx +++ b/web/app/(utility)/knowledge/page.tsx @@ -1509,7 +1509,7 @@ function KnowledgePageContent() { return (
-
+
{/* Header */}
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..1a000f4f2 100644 --- a/web/app/(utility)/settings/page.tsx +++ b/web/app/(utility)/settings/page.tsx @@ -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 +271,7 @@ export default function BotChatPage() { {/* Messages */}
{messages.length === 0 && !streaming && ( @@ -289,7 +294,7 @@ export default function BotChatPage() { className={msg.role === "user" ? "flex justify-end" : ""} > {msg.role === "user" ? ( -
+
{msg.content}
) : ( @@ -346,7 +351,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 */}
+ + {mobileNav} + ); } /* ---- Expanded state ---- */ return ( -
+ + {mobileNav} + ); } diff --git a/web/tests/e2e/compliance-and-ux.audit.ts b/web/tests/e2e/compliance-and-ux.audit.ts index 45dfccba5..e8266b90a 100644 --- a/web/tests/e2e/compliance-and-ux.audit.ts +++ b/web/tests/e2e/compliance-and-ux.audit.ts @@ -71,6 +71,23 @@ test.describe("Compliance :: Accessibility & Semantics", () => { const hasViewport = await page.$('meta[name="viewport"]'); expect(!!hasViewport, "Missing viewport meta tag").toBe(true); }); + + test("workspace shell exposes mobile navigation without horizontal overflow", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${BASE_URL}/chat`); + + await expect(page.locator('[data-mobile-nav="true"]')).toBeVisible(); + await expect(page.locator('aside[data-sidebar-shell="true"]')).toBeHidden(); + + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ); + expect(hasHorizontalOverflow, "Mobile viewport should not overflow on X").toBe( + false, + ); + }); }); test.describe("Compliance :: Error Handling & UX Signals", () => { From c421f8ce5b5e34a7723914ff01ae9462e1555afa Mon Sep 17 00:00:00 2001 From: Yang He Date: Sun, 26 Apr 2026 13:38:15 +0800 Subject: [PATCH 2/4] fix: improve knowledge mobile tabs --- web/app/(utility)/knowledge/page.tsx | 14 ++++---- web/tests/e2e/compliance-and-ux.audit.ts | 44 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/web/app/(utility)/knowledge/page.tsx b/web/app/(utility)/knowledge/page.tsx index 436532124..3f30f8289 100644 --- a/web/app/(utility)/knowledge/page.tsx +++ b/web/app/(utility)/knowledge/page.tsx @@ -1511,8 +1511,8 @@ function KnowledgePageContent() {
{/* Header */} -
-
+
+

{t("Knowledge")}

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

-
+
{[ { key: "knowledge", label: t("Knowledge Bases"), icon: Database }, { key: "notebooks", label: t("Notebooks"), icon: NotebookPen }, @@ -1533,16 +1533,18 @@ function KnowledgePageContent() { { key: "skills", label: t("Skills"), icon: Wand2 }, ].map((item) => ( ))}
diff --git a/web/tests/e2e/compliance-and-ux.audit.ts b/web/tests/e2e/compliance-and-ux.audit.ts index e8266b90a..98bde0a3f 100644 --- a/web/tests/e2e/compliance-and-ux.audit.ts +++ b/web/tests/e2e/compliance-and-ux.audit.ts @@ -88,6 +88,50 @@ test.describe("Compliance :: Accessibility & Semantics", () => { false, ); }); + + test("knowledge page keeps its mobile tabs inside the viewport", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${BASE_URL}/knowledge`); + + const heading = page.getByRole("heading", { name: "Knowledge" }); + await expect(heading).toBeVisible(); + + const tabLabels = ["Knowledge Bases", "Notebooks", "Question Bank", "Skills"]; + const tabBoxes = []; + for (const label of tabLabels) { + const tab = page.getByRole("button", { name: label }); + await expect(tab).toBeVisible(); + const box = await tab.boundingBox(); + expect(box, `${label} tab should have a layout box`).not.toBeNull(); + tabBoxes.push({ label, box: box! }); + } + + const headingBox = await heading.boundingBox(); + expect(headingBox, "Knowledge heading should have a layout box").not.toBeNull(); + + for (const { label, box } of tabBoxes) { + expect(box.x, `${label} tab should not start off-screen`).toBeGreaterThanOrEqual( + 0, + ); + expect( + box.x + box.width, + `${label} tab should not extend past the mobile viewport`, + ).toBeLessThanOrEqual(390); + expect( + box.y, + `${label} tab group should sit below the mobile heading`, + ).toBeGreaterThan(headingBox!.y + headingBox!.height); + } + + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ); + expect(hasHorizontalOverflow, "Knowledge mobile page should not overflow on X").toBe( + false, + ); + }); }); test.describe("Compliance :: Error Handling & UX Signals", () => { From 98ffbc699cb62e6ddcb2b3901dafb2dff6b90b58 Mon Sep 17 00:00:00 2001 From: Yang He Date: Sun, 26 Apr 2026 14:00:13 +0800 Subject: [PATCH 3/4] chore: ignore playwright reports --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 63963d2955eee82534b75c5f1a830d7999752d80 Mon Sep 17 00:00:00 2001 From: Yang He Date: Sun, 26 Apr 2026 14:55:23 +0800 Subject: [PATCH 4/4] fix: improve mobile layout and quality checks --- deeptutor/api/routers/attachments.py | 4 +- deeptutor/api/routers/knowledge.py | 4 +- deeptutor/api/routers/notebook.py | 4 +- deeptutor/api/routers/question.py | 10 +- deeptutor/api/routers/solve.py | 1 + deeptutor/knowledge/manager.py | 4 +- deeptutor/knowledge/progress_tracker.py | 4 +- .../config/context_window_detection.py | 4 +- deeptutor/services/config/test_runner.py | 5 +- deeptutor/services/llm/factory.py | 22 +- .../llm/provider_core/anthropic_provider.py | 1 + .../provider_core/azure_openai_provider.py | 4 +- deeptutor/services/llm/provider_core/base.py | 2 +- .../provider_core/github_copilot_provider.py | 6 +- .../provider_core/openai_codex_provider.py | 16 +- .../provider_core/openai_compat_provider.py | 28 +- .../openai_responses/converters.py | 4 +- .../provider_core/openai_responses/parsing.py | 2 +- deeptutor/services/rag/service.py | 4 +- deeptutor/services/session/turn_runtime.py | 4 +- .../services/storage/attachment_store.py | 16 +- .../tools/question/question_extractor.py | 1 + deeptutor/tutorbot/agent/tools/filesystem.py | 4 +- deeptutor/tutorbot/channels/matrix.py | 1 - .../tutorbot/providers/deeptutor_adapter.py | 76 ++++- deeptutor/utils/document_extractor.py | 37 +-- deeptutor_cli/notebook.py | 3 +- pyproject.toml | 7 +- scripts/start_tour.py | 63 ++-- scripts/update.py | 13 +- tests/api/test_knowledge_router.py | 4 +- tests/cli/test_notebook_cli.py | 18 +- .../config/test_context_window_detection.py | 4 +- .../services/embedding/test_client_runtime.py | 4 +- .../embedding/test_send_dimensions.py | 1 - .../llm/test_factory_provider_exec.py | 8 +- .../services/rag/test_pipeline_integration.py | 1 + tests/utils/test_document_extractor.py | 47 ++- web/app/(utility)/knowledge/page.tsx | 245 +++++++++------ web/app/(utility)/settings/page.tsx | 52 ++-- .../(workspace)/agents/[botId]/chat/page.tsx | 26 +- web/app/(workspace)/agents/page.tsx | 222 +++++++------- .../book/components/BookChatPanel.tsx | 22 +- .../book/components/PageOutlineNav.tsx | 19 +- .../book/components/PageReader.tsx | 4 +- .../book/components/blocks/QuizBlock.tsx | 34 ++- .../chat/[[...sessionId]]/page.tsx | 29 +- web/app/(workspace)/co-writer/page.tsx | 2 +- web/app/api/version/route.ts | 31 +- web/components/chat/home/ChatComposer.tsx | 9 +- web/components/chat/home/ChatMessages.tsx | 10 +- .../chat/preview/FilePreviewDrawer.tsx | 9 +- web/components/chat/preview/previewerFor.ts | 5 +- web/components/sidebar/CoWriterRecent.tsx | 13 +- web/components/sidebar/SidebarShell.tsx | 286 +++++++++--------- .../visualize/VisualizationViewer.tsx | 14 +- web/context/AppShellContext.tsx | 7 +- web/lib/chat-export.ts | 5 +- web/lib/code-languages.ts | 4 +- web/lib/doc-attachments.ts | 240 ++++++++++++--- web/lib/quiz-question-type.ts | 7 +- web/lib/version.ts | 12 +- web/scripts/run-node-tests.mjs | 11 +- web/tests/doc-attachments.test.ts | 12 +- web/tests/e2e/compliance-and-ux.audit.ts | 33 +- web/tests/version.test.ts | 5 +- 66 files changed, 1090 insertions(+), 719 deletions(-) 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 3f30f8289..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, @@ -1598,8 +1613,12 @@ function KnowledgePageContent() {

{createDropZone.active @@ -1635,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")}

@@ -1662,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 @@ -1805,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")}

@@ -1828,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 = ""; + }, + )}
@@ -2010,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} @@ -2029,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)/settings/page.tsx b/web/app/(utility)/settings/page.tsx index 1a000f4f2..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); @@ -1255,7 +1255,9 @@ function SettingsPageContent() { updateModelBoolField( "send_dimensions", @@ -1375,21 +1377,19 @@ function SettingsPageContent() {
{/* ── Spotlight overlay (tour onboarding) ── */} - {tourGuideStep >= 0 && - tourGuideStep < TOUR_GUIDE_STEPS.length && - ( - { - if (tourGuideStep < TOUR_GUIDE_STEPS.length - 1) { - setTourGuideStep((s) => s + 1); - } else { - setTourGuideStep(-1); - } - }} - onSkip={() => setTourGuideStep(-1)} - /> - )} + {tourGuideStep >= 0 && tourGuideStep < TOUR_GUIDE_STEPS.length && ( + { + if (tourGuideStep < TOUR_GUIDE_STEPS.length - 1) { + setTourGuideStep((s) => s + 1); + } else { + setTourGuideStep(-1); + } + }} + onSkip={() => setTourGuideStep(-1)} + /> + )}
); } diff --git a/web/app/(workspace)/agents/[botId]/chat/page.tsx b/web/app/(workspace)/agents/[botId]/chat/page.tsx index 48b343f6c..2c9210e9b 100644 --- a/web/app/(workspace)/agents/[botId]/chat/page.tsx +++ b/web/app/(workspace)/agents/[botId]/chat/page.tsx @@ -4,7 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useParams, useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { ArrowLeft, BookOpen, Bot, Download, Loader2, Send } from "lucide-react"; +import { + ArrowLeft, + BookOpen, + Bot, + Download, + Loader2, + Send, +} from "lucide-react"; import { apiUrl, wsUrl } from "@/lib/api"; import { firstParam } from "@/lib/route-params"; import AssistantResponse from "@/components/common/AssistantResponse"; @@ -102,17 +109,14 @@ export default function BotChatPage() { const handleCloseSaveModal = useCallback(() => setShowSaveModal(false), []); - const scrollToBottom = useCallback( - (behavior: ScrollBehavior = "smooth") => { - requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior, - }); + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior, }); - }, - [], - ); + }); + }, []); useEffect(() => { if (!botId) { diff --git a/web/app/(workspace)/agents/page.tsx b/web/app/(workspace)/agents/page.tsx index eb2834da1..8e46f69fb 100644 --- a/web/app/(workspace)/agents/page.tsx +++ b/web/app/(workspace)/agents/page.tsx @@ -1312,124 +1312,132 @@ function ProfilesTab({ [applySoulSelection, hasChanges], ); - const saveFile = useCallback(async ( - mode: "file_only" | "update_template" | "new_template", - createTemplateName?: string, - ) => { - if (!selectedBot) return false; - setSaving(true); - try { - if (activeFile === "SOUL.md") { - const content = editor.trim(); - if (!content) { - onToast(t("SOUL.md is empty")); - return false; - } - if (mode === "update_template") { - if (!sourceSoulTemplate) { - onToast(t("No template selected to update")); + const saveFile = useCallback( + async ( + mode: "file_only" | "update_template" | "new_template", + createTemplateName?: string, + ) => { + if (!selectedBot) return false; + setSaving(true); + try { + if (activeFile === "SOUL.md") { + const content = editor.trim(); + if (!content) { + onToast(t("SOUL.md is empty")); return false; } - const tplRes = await fetch( - apiUrl(`/api/v1/tutorbot/souls/${sourceSoulTemplate.id}`), - { - method: "PUT", + if (mode === "update_template") { + if (!sourceSoulTemplate) { + onToast(t("No template selected to update")); + return false; + } + const tplRes = await fetch( + apiUrl(`/api/v1/tutorbot/souls/${sourceSoulTemplate.id}`), + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: sourceSoulTemplate.name, + content: editor, + }), + }, + ); + if (!tplRes.ok) { + onToast(t("Failed to update soul template")); + return false; + } + await onReloadSouls(); + setSelectedSoulId(sourceSoulTemplate.id); + setSourceSoulId(sourceSoulTemplate.id); + } else if (mode === "new_template") { + const rawName = (createTemplateName ?? "").trim(); + if (!rawName) { + onToast(t("Template name is required")); + return false; + } + const baseId = rawName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + if (!baseId) { + onToast(t("Please choose a name with letters or numbers")); + return false; + } + const existing = new Set(souls.map((s) => s.id)); + let soulId = baseId; + let n = 2; + while (existing.has(soulId)) { + soulId = `${baseId}-${n}`; + n += 1; + } + const tplRes = await fetch(apiUrl("/api/v1/tutorbot/souls"), { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: sourceSoulTemplate.name, + id: soulId, + name: rawName, content: editor, }), - }, - ); - if (!tplRes.ok) { - onToast(t("Failed to update soul template")); - return false; - } - await onReloadSouls(); - setSelectedSoulId(sourceSoulTemplate.id); - setSourceSoulId(sourceSoulTemplate.id); - } else if (mode === "new_template") { - const rawName = (createTemplateName ?? "").trim(); - if (!rawName) { - onToast(t("Template name is required")); - return false; - } - const baseId = rawName - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - if (!baseId) { - onToast(t("Please choose a name with letters or numbers")); - return false; - } - const existing = new Set(souls.map((s) => s.id)); - let soulId = baseId; - let n = 2; - while (existing.has(soulId)) { - soulId = `${baseId}-${n}`; - n += 1; - } - const tplRes = await fetch(apiUrl("/api/v1/tutorbot/souls"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: soulId, - name: rawName, - content: editor, - }), - }); - if (tplRes.status === 409) { - onToast(t("A soul with this id already exists, try another name")); - return false; - } - if (!tplRes.ok) { - onToast(t("Failed to save soul template")); - return false; + }); + if (tplRes.status === 409) { + onToast( + t("A soul with this id already exists, try another name"), + ); + return false; + } + if (!tplRes.ok) { + onToast(t("Failed to save soul template")); + return false; + } + await onReloadSouls(); + setSelectedSoulId(soulId); + setSourceSoulId(soulId); } - await onReloadSouls(); - setSelectedSoulId(soulId); - setSourceSoulId(soulId); } - } - const res = await fetch( - apiUrl(`/api/v1/tutorbot/${selectedBot}/files/${activeFile}`), - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: editor }), - }, - ); - if (res.ok) { - setFiles((prev) => ({ ...prev, [activeFile]: editor })); - if (activeFile === "SOUL.md") { - const personaRes = await fetch(apiUrl(`/api/v1/tutorbot/${selectedBot}`), { - method: "PATCH", + const res = await fetch( + apiUrl(`/api/v1/tutorbot/${selectedBot}/files/${activeFile}`), + { + method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ persona: editor }), - }); - if (!personaRes.ok) { - onToast(t("SOUL.md saved, but persona sync failed")); - return false; + body: JSON.stringify({ content: editor }), + }, + ); + if (res.ok) { + setFiles((prev) => ({ ...prev, [activeFile]: editor })); + if (activeFile === "SOUL.md") { + const personaRes = await fetch( + apiUrl(`/api/v1/tutorbot/${selectedBot}`), + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ persona: editor }), + }, + ); + if (!personaRes.ok) { + onToast(t("SOUL.md saved, but persona sync failed")); + return false; + } } + onToast(`${activeFile} saved`); + return true; } - onToast(`${activeFile} saved`); - return true; + return false; + } finally { + setSaving(false); } - return false; - } finally { - setSaving(false); - } - }, [ - selectedBot, - activeFile, - editor, - onToast, - onReloadSouls, - sourceSoulTemplate, - souls, - t, - ]); + }, + [ + selectedBot, + activeFile, + editor, + onToast, + onReloadSouls, + sourceSoulTemplate, + souls, + t, + ], + ); const handleSaveClick = useCallback(() => { if (activeFile !== "SOUL.md") { @@ -1550,7 +1558,9 @@ function ProfilesTab({ {activeSoulTemplate && ( {hasChanges - ? t('Editing template "{{name}}"', { name: activeSoulTemplate.name }) + ? t('Editing template "{{name}}"', { + name: activeSoulTemplate.name, + }) : t('Using "{{name}}"', { name: activeSoulTemplate.name })} )} diff --git a/web/app/(workspace)/book/components/BookChatPanel.tsx b/web/app/(workspace)/book/components/BookChatPanel.tsx index a0ffa15ae..c0e90f546 100644 --- a/web/app/(workspace)/book/components/BookChatPanel.tsx +++ b/web/app/(workspace)/book/components/BookChatPanel.tsx @@ -24,6 +24,23 @@ export default function BookChatPanel({ page, open, onClose, +}: BookChatPanelProps) { + return ( + + ); +} + +function BookChatPanelSession({ + book, + page, + open, + onClose, }: BookChatPanelProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -39,11 +56,6 @@ export default function BookChatPanel({ }; }, []); - useEffect(() => { - sessionIdRef.current = null; - setMessages([]); - }, [book?.id]); - useEffect(() => { if (scrollerRef.current) { scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight; diff --git a/web/app/(workspace)/book/components/PageOutlineNav.tsx b/web/app/(workspace)/book/components/PageOutlineNav.tsx index db730c72f..ff0f0ec1d 100644 --- a/web/app/(workspace)/book/components/PageOutlineNav.tsx +++ b/web/app/(workspace)/book/components/PageOutlineNav.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AlertCircle, AlignLeft, @@ -129,11 +129,18 @@ export default function PageOutlineNav({ const collapseTip = isZh ? COLLAPSE_TIP_ZH : COLLAPSE_TIP_EN; const expandTip = isZh ? EXPAND_TIP_ZH : EXPAND_TIP_EN; - // Default: expanded. Reset whenever the page changes. - const [collapsed, setCollapsed] = useState(false); - useEffect(() => { - setCollapsed(false); - }, [resetKey]); + const [collapsedState, setCollapsedState] = useState({ + resetKey, + collapsed: false, + }); + const collapsed = + collapsedState.resetKey === resetKey ? collapsedState.collapsed : false; + const setCollapsed = useCallback( + (nextCollapsed: boolean) => { + setCollapsedState({ resetKey, collapsed: nextCollapsed }); + }, + [resetKey], + ); // Track which block is currently in view for active highlight. const [activeId, setActiveId] = useState(null); diff --git a/web/app/(workspace)/book/components/PageReader.tsx b/web/app/(workspace)/book/components/PageReader.tsx index cc71c40a9..24a0eda24 100644 --- a/web/app/(workspace)/book/components/PageReader.tsx +++ b/web/app/(workspace)/book/components/PageReader.tsx @@ -131,7 +131,9 @@ export default function PageReader({

diff --git a/web/app/(workspace)/book/components/blocks/QuizBlock.tsx b/web/app/(workspace)/book/components/blocks/QuizBlock.tsx index 5cf403b87..8e28d6ce2 100644 --- a/web/app/(workspace)/book/components/blocks/QuizBlock.tsx +++ b/web/app/(workspace)/book/components/blocks/QuizBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CheckCircle2, Eye, EyeOff, XCircle } from "lucide-react"; import MarkdownRenderer from "@/components/common/MarkdownRenderer"; @@ -81,23 +81,27 @@ function QuizQuestionCard({ const correct = String(question.correct_answer || "").trim(); const correctChoiceKey = resolveChoiceAnswerKey(correct, options); - useEffect(() => { - if (revealed && selected && !reported && onAttempt) { + const reportAttempt = (answer: string) => { + if (!reported && onAttempt) { onAttempt({ questionId: question.question_id, - userAnswer: selected, - isCorrect: selected.toUpperCase() === correctChoiceKey, + userAnswer: answer, + isCorrect: answer.toUpperCase() === correctChoiceKey, }); setReported(true); } - }, [ - revealed, - selected, - reported, - onAttempt, - question.question_id, - correctChoiceKey, - ]); + }; + + const handleSelect = (answer: string) => { + setSelected(answer); + if (revealed) reportAttempt(answer); + }; + + const handleRevealToggle = () => { + const nextRevealed = !revealed; + setRevealed(nextRevealed); + if (nextRevealed && selected) reportAttempt(selected); + }; return (
@@ -132,7 +136,7 @@ function QuizQuestionCard({ return (
- {sizeLabel ? `${spec.label} · ${sizeLabel}` : spec.label} + {sizeLabel + ? `${spec.label} · ${sizeLabel}` + : spec.label}

diff --git a/web/components/chat/home/ChatMessages.tsx b/web/components/chat/home/ChatMessages.tsx index 16798837a..8342a8474 100644 --- a/web/components/chat/home/ChatMessages.tsx +++ b/web/components/chat/home/ChatMessages.tsx @@ -306,9 +306,7 @@ const UserMessage = memo(function UserMessage({ {msg.attachments .filter((a) => a.type === "image" && (a.base64 || a.url)) .map((a, ai) => { - const src = a.url - ? a.url - : `data:image/png;base64,${a.base64}`; + const src = a.url ? a.url : `data:image/png;base64,${a.base64}`; return (
- {/* New chat — visually distinct circular button */} + {/* New chat — visually distinct circular button */} - {/* Subtle divider */} + {/* Subtle divider */}
- {/* Primary nav */} + {/* Primary nav */}
- {/* Secondary nav + footer */} + {/* Secondary nav + footer */}
-
- {SECONDARY_NAV.map((item) => { - const active = pathname.startsWith(item.href); - return ( - - {active && ( - - )} - - - ); - })} - {footerSlot} - - - +
+ {SECONDARY_NAV.map((item) => { + const active = pathname.startsWith(item.href); + return ( + + {active && ( + + )} + + + ); + })} + {footerSlot} + + +
@@ -268,102 +268,102 @@ export function SidebarShell({
- {/* Primary nav */} + {/* Primary nav */} - {/* Spacer */} + {/* Spacer */}
- {/* Secondary nav + footer */} + {/* Secondary nav + footer */}
- {SECONDARY_NAV.map((item) => { - const active = pathname.startsWith(item.href); - return ( - { + const active = pathname.startsWith(item.href); + return ( + + + {t(item.label)} + + ); + })} + {footerSlot} +
{mobileNav} diff --git a/web/components/visualize/VisualizationViewer.tsx b/web/components/visualize/VisualizationViewer.tsx index 12d89521c..511416be7 100644 --- a/web/components/visualize/VisualizationViewer.tsx +++ b/web/components/visualize/VisualizationViewer.tsx @@ -125,17 +125,13 @@ function HtmlRenderer({ html }: { html: string }) { function SvgRenderer({ svg }: { svg: string }) { const { t } = useTranslation(); const containerRef = useRef(null); - const [error, setError] = useState(null); const sanitizedSvg = useMemo(() => { - const trimmed = svg.trim(); - if (!trimmed.startsWith("(false); useEffect(() => { - setLanguageState(readStoredLanguage()); - setSidebarCollapsedState(readStoredSidebarCollapsed()); + const timer = window.setTimeout(() => { + setLanguageState(readStoredLanguage()); + setSidebarCollapsedState(readStoredSidebarCollapsed()); + }, 0); + return () => window.clearTimeout(timer); }, []); useEffect(() => { diff --git a/web/lib/chat-export.ts b/web/lib/chat-export.ts index 17dd028c8..98b62b550 100644 --- a/web/lib/chat-export.ts +++ b/web/lib/chat-export.ts @@ -1,4 +1,7 @@ -import type { MessageItem, MessageAttachment } from "@/context/UnifiedChatContext"; +import type { + MessageItem, + MessageAttachment, +} from "@/context/UnifiedChatContext"; function roleHeading(role: MessageItem["role"]): string { if (role === "user") return "User"; diff --git a/web/lib/code-languages.ts b/web/lib/code-languages.ts index c25c7f101..68bbc2394 100644 --- a/web/lib/code-languages.ts +++ b/web/lib/code-languages.ts @@ -168,7 +168,9 @@ export const CODE_EXTS: ReadonlySet = new Set( export function langForFilename(filename: string): string | null { if (!filename) return null; const lastSlash = filename.lastIndexOf("/"); - const base = (lastSlash >= 0 ? filename.slice(lastSlash + 1) : filename).toLowerCase(); + const base = ( + lastSlash >= 0 ? filename.slice(lastSlash + 1) : filename + ).toLowerCase(); if (NAMED_FILES[base]) return NAMED_FILES[base]; const dotIdx = base.lastIndexOf("."); diff --git a/web/lib/doc-attachments.ts b/web/lib/doc-attachments.ts index 171c78516..06146b998 100644 --- a/web/lib/doc-attachments.ts +++ b/web/lib/doc-attachments.ts @@ -34,50 +34,128 @@ export const OFFICE_EXTS = [".pdf", ".docx", ".xlsx", ".pptx"] as const; */ export const TEXT_LIKE_EXTS = [ // Plain text & markup - ".txt", ".text", ".log", - ".md", ".markdown", ".rst", ".asciidoc", - ".html", ".htm", ".xml", + ".txt", + ".text", + ".log", + ".md", + ".markdown", + ".rst", + ".asciidoc", + ".html", + ".htm", + ".xml", ".svg", // vector image, treated as XML source; rendered via client-side // Data & config - ".json", ".jsonc", ".json5", - ".yaml", ".yml", ".toml", ".csv", ".tsv", - ".ini", ".cfg", ".conf", ".env", ".properties", + ".json", + ".jsonc", + ".json5", + ".yaml", + ".yml", + ".toml", + ".csv", + ".tsv", + ".ini", + ".cfg", + ".conf", + ".env", + ".properties", // Typesetting - ".tex", ".latex", ".bib", + ".tex", + ".latex", + ".bib", // Stylesheets - ".css", ".scss", ".sass", ".less", + ".css", + ".scss", + ".sass", + ".less", // JavaScript / TypeScript family - ".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx", + ".js", + ".mjs", + ".cjs", + ".ts", + ".mts", + ".cts", + ".jsx", + ".tsx", // Web frameworks - ".vue", ".svelte", + ".vue", + ".svelte", // Python ".py", // JVM languages - ".java", ".kt", ".kts", ".scala", ".groovy", ".gradle", + ".java", + ".kt", + ".kts", + ".scala", + ".groovy", + ".gradle", // Systems - ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", - ".cs", ".go", ".rs", ".zig", ".nim", + ".c", + ".h", + ".cpp", + ".cc", + ".cxx", + ".hpp", + ".hh", + ".hxx", + ".cs", + ".go", + ".rs", + ".zig", + ".nim", // Apple platforms - ".swift", ".m", ".mm", + ".swift", + ".m", + ".mm", // Scripting - ".rb", ".php", ".pl", ".pm", ".lua", ".r", ".jl", ".dart", + ".rb", + ".php", + ".pl", + ".pm", + ".lua", + ".r", + ".jl", + ".dart", // Functional - ".hs", ".clj", ".cljs", ".cljc", ".ex", ".exs", ".erl", - ".ml", ".mli", ".fs", ".fsx", ".lisp", ".lsp", ".scm", ".rkt", + ".hs", + ".clj", + ".cljs", + ".cljc", + ".ex", + ".exs", + ".erl", + ".ml", + ".mli", + ".fs", + ".fsx", + ".lisp", + ".lsp", + ".scm", + ".rkt", // Smart contracts ".sol", // Shells / editors - ".sh", ".bash", ".zsh", ".fish", ".ps1", ".vim", + ".sh", + ".bash", + ".zsh", + ".fish", + ".ps1", + ".vim", // Query / IDL - ".sql", ".graphql", ".gql", ".proto", + ".sql", + ".graphql", + ".gql", + ".proto", // Build / infra - ".cmake", ".mk", ".tf", ".hcl", ".nginxconf", ".dockerfile", + ".cmake", + ".mk", + ".tf", + ".hcl", + ".nginxconf", + ".dockerfile", ] as const; -export const SUPPORTED_DOC_EXTS = [ - ...OFFICE_EXTS, - ...TEXT_LIKE_EXTS, -] as const; +export const SUPPORTED_DOC_EXTS = [...OFFICE_EXTS, ...TEXT_LIKE_EXTS] as const; export const SUPPORTED_DOC_MIMES = new Set([ // Office @@ -154,7 +232,8 @@ export function classifyFile(file: File): FileKind | null { if (ext === ".svg" || file.type === "image/svg+xml") return "doc"; if (file.type && file.type.startsWith("image/")) return "image"; if (file.type && SUPPORTED_DOC_MIMES.has(file.type)) return "doc"; - if (ext && (SUPPORTED_DOC_EXTS as readonly string[]).includes(ext)) return "doc"; + if (ext && (SUPPORTED_DOC_EXTS as readonly string[]).includes(ext)) + return "doc"; return null; } @@ -183,38 +262,111 @@ export interface DocIconSpec { // without carrying 50 distinct icons. const CODE_EXTS = new Set([ // JS/TS - ".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx", - ".vue", ".svelte", + ".js", + ".mjs", + ".cjs", + ".ts", + ".mts", + ".cts", + ".jsx", + ".tsx", + ".vue", + ".svelte", // Python ".py", // JVM - ".java", ".kt", ".kts", ".scala", ".groovy", ".gradle", + ".java", + ".kt", + ".kts", + ".scala", + ".groovy", + ".gradle", // Systems - ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", - ".cs", ".go", ".rs", ".zig", ".nim", + ".c", + ".h", + ".cpp", + ".cc", + ".cxx", + ".hpp", + ".hh", + ".hxx", + ".cs", + ".go", + ".rs", + ".zig", + ".nim", // Apple - ".swift", ".m", ".mm", + ".swift", + ".m", + ".mm", // Scripting - ".rb", ".php", ".pl", ".pm", ".lua", ".r", ".jl", ".dart", + ".rb", + ".php", + ".pl", + ".pm", + ".lua", + ".r", + ".jl", + ".dart", // Functional - ".hs", ".clj", ".cljs", ".cljc", ".ex", ".exs", ".erl", - ".ml", ".mli", ".fs", ".fsx", ".lisp", ".lsp", ".scm", ".rkt", + ".hs", + ".clj", + ".cljs", + ".cljc", + ".ex", + ".exs", + ".erl", + ".ml", + ".mli", + ".fs", + ".fsx", + ".lisp", + ".lsp", + ".scm", + ".rkt", // Smart contracts ".sol", ]); const SHELL_EXTS = new Set([ - ".sh", ".bash", ".zsh", ".fish", ".ps1", ".vim", ".sql", + ".sh", + ".bash", + ".zsh", + ".fish", + ".ps1", + ".vim", + ".sql", ]); const CONFIG_EXTS = new Set([ - ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env", ".properties", - ".tf", ".hcl", ".nginxconf", ".cmake", ".mk", ".dockerfile", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".env", + ".properties", + ".tf", + ".hcl", + ".nginxconf", + ".cmake", + ".mk", + ".dockerfile", ]); const JSON_EXTS = new Set([".json", ".jsonc", ".json5"]); const MARKUP_EXTS = new Set([ - ".md", ".markdown", ".rst", ".asciidoc", - ".html", ".htm", ".xml", - ".tex", ".latex", ".bib", - ".graphql", ".gql", ".proto", + ".md", + ".markdown", + ".rst", + ".asciidoc", + ".html", + ".htm", + ".xml", + ".tex", + ".latex", + ".bib", + ".graphql", + ".gql", + ".proto", ]); const DATA_EXTS = new Set([".csv", ".tsv"]); const STYLE_EXTS = new Set([".css", ".scss", ".sass", ".less"]); @@ -229,7 +381,11 @@ export function docIconFor(filename: string): DocIconSpec { case ".docx": return { Icon: FileText, tint: "text-blue-500/80", label: "DOCX" }; case ".xlsx": - return { Icon: FileSpreadsheet, tint: "text-emerald-500/80", label: "XLSX" }; + return { + Icon: FileSpreadsheet, + tint: "text-emerald-500/80", + label: "XLSX", + }; case ".pptx": return { Icon: Presentation, tint: "text-orange-500/80", label: "PPTX" }; case ".svg": diff --git a/web/lib/quiz-question-type.ts b/web/lib/quiz-question-type.ts index d2b04f4c1..3b066e99d 100644 --- a/web/lib/quiz-question-type.ts +++ b/web/lib/quiz-question-type.ts @@ -48,7 +48,12 @@ export function resolveChoiceAnswerKey( const normalizedAnswer = correct.toLowerCase(); for (const [key, label] of Object.entries(options)) { - if (normalizedAnswer === String(label || "").trim().toLowerCase()) { + if ( + normalizedAnswer === + String(label || "") + .trim() + .toLowerCase() + ) { return key.toUpperCase(); } } diff --git a/web/lib/version.ts b/web/lib/version.ts index 6f46ecaa0..17b657b2b 100644 --- a/web/lib/version.ts +++ b/web/lib/version.ts @@ -21,14 +21,18 @@ const SEMVER_TAG = String.raw`(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)`; const DIRTY_SUFFIX = "-dev"; const DIRTY_DISPLAY = "\u00b7dev"; -export function parseBuild(rawValue: string | null | undefined): ParsedBuild | null { +export function parseBuild( + rawValue: string | null | undefined, +): ParsedBuild | null { const raw = rawValue?.trim() ?? ""; if (!raw) return null; const isDirty = raw.endsWith(DIRTY_SUFFIX); const stripped = isDirty ? raw.slice(0, -DIRTY_SUFFIX.length) : raw; - const ahead = stripped.match(new RegExp(`^v?${SEMVER_TAG}-(\\d+)-g([0-9a-f]+)$`)); + const ahead = stripped.match( + new RegExp(`^v?${SEMVER_TAG}-(\\d+)-g([0-9a-f]+)$`), + ); if (ahead) { const tag = `v${ahead[1]}`; const commitsAhead = Number.parseInt(ahead[2], 10); @@ -69,7 +73,9 @@ export function parseBuild(rawValue: string | null | undefined): ParsedBuild | n }; } -export function normalizeVersionTag(raw: string | null | undefined): string | null { +export function normalizeVersionTag( + raw: string | null | undefined, +): string | null { const parsed = parseBuild(raw); return parsed && !parsed.isDev ? parsed.tag : null; } diff --git a/web/scripts/run-node-tests.mjs b/web/scripts/run-node-tests.mjs index d553fd36f..45e9eca28 100644 --- a/web/scripts/run-node-tests.mjs +++ b/web/scripts/run-node-tests.mjs @@ -40,7 +40,16 @@ function collectTests(dir) { rmSync(distRoot, { recursive: true, force: true }); -run(path.join(webRoot, "node_modules", ".bin", "tsc"), [ +const tscBin = path.join( + webRoot, + "node_modules", + "typescript", + "bin", + "tsc", +); + +run(process.execPath, [ + tscBin, "-p", "tsconfig.node-tests.json", ]); diff --git a/web/tests/doc-attachments.test.ts b/web/tests/doc-attachments.test.ts index d31aa0d58..46c96a714 100644 --- a/web/tests/doc-attachments.test.ts +++ b/web/tests/doc-attachments.test.ts @@ -23,7 +23,12 @@ test("classifyFile: image via MIME", () => { test("classifyFile: doc via MIME", () => { assert.equal( - classifyFile(makeFile("a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")), + classifyFile( + makeFile( + "a.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ), "doc", ); assert.equal(classifyFile(makeFile("b.pdf", "application/pdf")), "doc"); @@ -65,7 +70,10 @@ test("docIconFor: SVG gets its own label", () => { test("classifyFile: rejects unsupported", () => { assert.equal(classifyFile(makeFile("a.zip", "application/zip")), null); - assert.equal(classifyFile(makeFile("a.exe", "application/x-msdownload")), null); + assert.equal( + classifyFile(makeFile("a.exe", "application/x-msdownload")), + null, + ); assert.equal(classifyFile(makeFile("noext")), null); }); diff --git a/web/tests/e2e/compliance-and-ux.audit.ts b/web/tests/e2e/compliance-and-ux.audit.ts index 98bde0a3f..bfc2cafea 100644 --- a/web/tests/e2e/compliance-and-ux.audit.ts +++ b/web/tests/e2e/compliance-and-ux.audit.ts @@ -84,9 +84,10 @@ test.describe("Compliance :: Accessibility & Semantics", () => { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > window.innerWidth, ); - expect(hasHorizontalOverflow, "Mobile viewport should not overflow on X").toBe( - false, - ); + expect( + hasHorizontalOverflow, + "Mobile viewport should not overflow on X", + ).toBe(false); }); test("knowledge page keeps its mobile tabs inside the viewport", async ({ @@ -98,7 +99,12 @@ test.describe("Compliance :: Accessibility & Semantics", () => { const heading = page.getByRole("heading", { name: "Knowledge" }); await expect(heading).toBeVisible(); - const tabLabels = ["Knowledge Bases", "Notebooks", "Question Bank", "Skills"]; + const tabLabels = [ + "Knowledge Bases", + "Notebooks", + "Question Bank", + "Skills", + ]; const tabBoxes = []; for (const label of tabLabels) { const tab = page.getByRole("button", { name: label }); @@ -109,12 +115,16 @@ test.describe("Compliance :: Accessibility & Semantics", () => { } const headingBox = await heading.boundingBox(); - expect(headingBox, "Knowledge heading should have a layout box").not.toBeNull(); + expect( + headingBox, + "Knowledge heading should have a layout box", + ).not.toBeNull(); for (const { label, box } of tabBoxes) { - expect(box.x, `${label} tab should not start off-screen`).toBeGreaterThanOrEqual( - 0, - ); + expect( + box.x, + `${label} tab should not start off-screen`, + ).toBeGreaterThanOrEqual(0); expect( box.x + box.width, `${label} tab should not extend past the mobile viewport`, @@ -128,9 +138,10 @@ test.describe("Compliance :: Accessibility & Semantics", () => { const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > window.innerWidth, ); - expect(hasHorizontalOverflow, "Knowledge mobile page should not overflow on X").toBe( - false, - ); + expect( + hasHorizontalOverflow, + "Knowledge mobile page should not overflow on X", + ).toBe(false); }); }); diff --git a/web/tests/version.test.ts b/web/tests/version.test.ts index c0d5f3a4d..f85bb7894 100644 --- a/web/tests/version.test.ts +++ b/web/tests/version.test.ts @@ -40,7 +40,10 @@ test("parseBuild preserves dirty worktree state", () => { test("parseBuild supports prerelease tags", () => { assert.equal(parseBuild("v1.0.0-beta.4")?.tag, "v1.0.0-beta.4"); - assert.equal(parseBuild("v1.0.0-beta.4-2-gabc1234")?.display, "v1.0.0-beta.4+2"); + assert.equal( + parseBuild("v1.0.0-beta.4-2-gabc1234")?.display, + "v1.0.0-beta.4+2", + ); }); test("normalizeVersionTag only returns exact version tags", () => {