diff --git a/README.md b/README.md index 0640ded..713149e 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,63 @@ cp .env.example .env > 5. 点击下方的“生成授权码”,使用手机 QQ 扫码或按提示发送短信 > 6. 验证通过后将获得一串 **16位随机字母组合**,将其复制填入 `.env` 文件中的 `SMTP_PASSWORD` 字段 -### 2. 后端服务启动 🐍 +### 2. 打包 wheel(产物输出到根目录 `dist/`)📦 -请确保你已安装 Python 3.10 或以上版本。 +如需自行打包发布,请按以下顺序执行:先构建前端,再构建后端 wheel,并将产物输出到项目根目录 `dist/`(与 `backend/` 同级)。 + +```bash +# 1) 构建前端静态资源 +cd frontend +npm install +npm run build + +# 2) 构建后端 wheel,并输出到根目录 dist/ +cd ../backend +uv build --out-dir ../dist +``` + +构建完成后,wheel 位于项目根目录 `dist/`,例如 `dist/dataclaw-0.1.0-py3-none-any.whl`。 + +### 3. 标准部署(推荐,无需 Node.js)📦 + +请确保你已安装 Python 3.11 或以上版本。发布包已内置前端静态资源,生产部署无需安装 Node.js。 + +```bash +# 建议先创建虚拟环境 +python -m venv .venv +source .venv/bin/activate + +# 安装 DataClaw(示例:安装根目录 dist 下的 wheel) +pip install ./dist/dataclaw-*.whl + +# 启动服务(默认 http://127.0.0.1:8000) +dataclaw start +``` + +常用服务控制命令: + +```bash +# 查看运行状态 +dataclaw status + +# 自定义监听地址/端口 +dataclaw start --host 0.0.0.0 --port 8000 + +# 停止服务 +dataclaw stop +``` + +可选环境变量: + +```bash +export DATA_ROOT=/absolute/path/to/data +``` + +若未设置,默认使用仓库根目录下的 `data/`。服务状态文件与日志默认位于 `DATA_ROOT/run/`。 + +### 4. 开发模式(需要 Node.js)🧪 + +如果你要调试前端代码或重新构建前端产物,请使用前后端分离开发模式: ```bash cd backend @@ -97,31 +151,18 @@ pip install -r requirements.txt uvicorn app.main:app --reload --port 8000 ``` -可选环境变量: - -```bash -export DATA_ROOT=/absolute/path/to/data -``` - -若未设置,默认使用仓库根目录下的 `data/`。 - -*提示:请确保* *`nanobot`* *核心库已根据项目工作区的要求正确链接或以可编辑模式 (editable mode) 安装。* - -### 2. 前端服务启动 ⚛️ - -请确保你已安装 Node.js 18 或以上版本。 - ```bash cd frontend -# 安装依赖 +# 安装依赖(仅开发模式需要 Node.js) npm install # 启动 Vite 开发服务器 npm run dev ``` +*提示:请确保* *`nanobot`* *核心库已根据项目工作区的要求正确链接或以可编辑模式 (editable mode) 安装。* -### 3. 语音识别服务(可选)🎙️ +### 5. 语音识别服务(可选)🎙️ 若你希望使用聊天输入框中的语音输入能力,请单独启动 `whisper` 服务: @@ -142,7 +183,7 @@ python main.py 3. 填写服务地址(例如 `http://localhost:8001`); 4. 点击「测试连接」通过后保存。 -### 4. 初始账号配置 👤 +### 6. 初始账号配置 👤 系统首次注册的用户将自动成为管理员。您可以在登录页面直接点击“注册”按钮创建您的管理员账号(例如:用户名 `admin`,密码 `admin`),随后即可登录并管理项目、数据源和用户。 *** diff --git a/README_en.md b/README_en.md index a31da56..f029774 100644 --- a/README_en.md +++ b/README_en.md @@ -80,9 +80,63 @@ Please edit the `.env` file in the root directory and fill in your actual config > 5. Click "Generate Authorization Code" (生成授权码) below it, scan the QR code with mobile QQ or send an SMS as prompted > 6. After verification, you will get a **16-digit random letter combination**. Copy and paste it into the `SMTP_PASSWORD` field in your `.env` file -### 2. Backend Setup 🐍 +### 2. Standard Deployment (Recommended, No Node.js Required) 📦 -Ensure you have Python 3.10+ installed. +Ensure you have Python 3.11+ installed. The pre-built React frontend is bundled in the Python wheel, so you don't need Node.js for production deployment. + +#### Build the wheel (output to `dist/`) + +```bash +# First, build the frontend +cd frontend +npm install +npm run build + +# Then, build the backend wheel +cd ../backend +uv build --out-dir ../dist +``` + +Once built, the wheel is located in the project root `dist/` directory, e.g., `dist/dataclaw-0.1.0-py3-none-any.whl`. + +#### Install and Run + +```bash +# We recommend creating a virtual environment first +python -m venv .venv +source .venv/bin/activate + +# Install DataClaw +pip install ./dist/dataclaw-*.whl + +# Start the service (defaults to http://127.0.0.1:8000) +dataclaw start +``` + +Common service control commands: + +```bash +# Check running status +dataclaw status + +# Custom host/port +dataclaw start --host 0.0.0.0 --port 8000 + +# Stop the service +dataclaw stop +``` + +Optional environment variable: + +```bash +export DATA_ROOT=/absolute/path/to/data +``` + +If not set, DataClaw uses the repository-level `data/` directory by default. Service state files and logs are located in `DATA_ROOT/run/`. + +### 3. Development Mode (Requires Node.js) 🧪 + +If you want to debug the frontend code or rebuild the frontend artifacts, use the separate development mode: ```bash cd backend @@ -97,20 +151,6 @@ pip install -r requirements.txt uvicorn app.main:app --reload --port 8000 ``` -Optional environment variable: - -```bash -export DATA_ROOT=/absolute/path/to/data -``` - -If not set, DataClaw uses the repository-level `data/` directory by default. - -*Note: Ensure your* *`nanobot`* *is properly linked or installed in editable mode as per the project workspace.* - -### 2. Frontend Setup ⚛️ - -Ensure you have Node.js 18+ installed. - ```bash cd frontend # Install dependencies @@ -120,7 +160,9 @@ npm install npm run dev ``` -### 3. Optional Voice Service 🎙️ +*Note: Ensure your* *`nanobot`* *is properly linked or installed in editable mode as per the project workspace.* + +### 4. Optional Voice Service 🎙️ If you want to use voice input in chat, run the standalone `whisper` service: diff --git a/backend/README.md b/backend/README.md index e69de29..cc99899 100644 --- a/backend/README.md +++ b/backend/README.md @@ -0,0 +1,18 @@ +# Backend 打包说明 + +## Wheel 内置前端产物 + +- 前端构建目录固定为 `frontend/dist` +- wheel 构建时通过 `backend/pyproject.toml` 中的 `tool.hatch.build.targets.wheel.force-include` 映射到包内 `app/webui` +- 安装后可通过 `importlib.resources.files("app").joinpath("webui/index.html")` 读取前端入口文件 + +## 构建顺序 + +```bash +cd frontend +npm install +npm run build + +cd ../backend +uv build +``` diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..736244b --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,231 @@ +import json +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +import typer +from rich.console import Console + +from app.core.data_root import get_data_root + +app = typer.Typer( + name="dataclaw", + context_settings={"help_option_names": ["-h", "--help"]}, + help="DataClaw WebUI 服务控制命令", + no_args_is_help=True, +) +console = Console() + + +def _default_pid_file() -> Path: + return get_data_root() / "run" / "dataclaw-webui.json" + + +def _default_log_file() -> Path: + return get_data_root() / "run" / "dataclaw-webui.log" + + +def _resolve_path(value: str | None, fallback: Path) -> Path: + if value: + return Path(value).expanduser().resolve() + return fallback + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _read_state(pid_file: Path) -> dict[str, Any] | None: + if not pid_file.exists(): + return None + try: + return json.loads(pid_file.read_text(encoding="utf-8")) + except Exception: + return None + + +def _write_state(pid_file: Path, state: dict[str, Any]) -> None: + _ensure_parent(pid_file) + pid_file.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _remove_state(pid_file: Path) -> None: + try: + pid_file.unlink() + except FileNotFoundError: + return + + +def _is_process_running(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _wait_for_server_ready(host: str, port: int, timeout: float) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection((host, port), timeout=0.5): + return True + except OSError: + time.sleep(0.2) + return False + + +def _build_uvicorn_command(host: str, port: int, reload: bool, log_level: str, app_target: str) -> list[str]: + command = [ + sys.executable, + "-m", + "uvicorn", + app_target, + "--host", + host, + "--port", + str(port), + "--log-level", + log_level, + ] + if reload: + command.append("--reload") + return command + + +def _stop_pid(pid: int, timeout: float) -> bool: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return True + deadline = time.time() + timeout + while time.time() < deadline: + if not _is_process_running(pid): + return True + time.sleep(0.2) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + return True + return not _is_process_running(pid) + + +@app.command() +def start( + host: str = typer.Option("127.0.0.1", "--host", help="服务监听地址"), + port: int = typer.Option(8000, "--port", "-p", help="服务端口"), + reload: bool = typer.Option(False, "--reload", "-r", help="开启自动重载(开发模式)"), + log_level: str = typer.Option("info", "--log-level", help="日志级别"), + app_target: str = typer.Option("main:app", "--app", help="ASGI 应用导入路径"), + ready_timeout: float = typer.Option(12.0, "--ready-timeout", help="就绪等待时长(秒)"), + pid_file: str | None = typer.Option(None, "--pid-file", help="PID 状态文件路径"), + log_file: str | None = typer.Option(None, "--log-file", help="服务日志文件路径"), +) -> None: + pid_path = _resolve_path(pid_file, _default_pid_file()) + log_path = _resolve_path(log_file, _default_log_file()) + + state = _read_state(pid_path) + if state: + pid = int(state.get("pid", 0)) + if _is_process_running(pid): + existing_host = state.get("host", host) + existing_port = state.get("port", port) + console.print(f"[yellow]⚠[/yellow] dataclaw 已在运行: pid={pid}, url=http://{existing_host}:{existing_port}") + raise typer.Exit(1) + _remove_state(pid_path) + console.print("[yellow]⚠[/yellow] 检测到过期状态文件,已自动清理") + + _ensure_parent(log_path) + command = _build_uvicorn_command(host, port, reload, log_level, app_target) + log_handle = log_path.open("a", encoding="utf-8") + process = subprocess.Popen( + command, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + log_handle.close() + + service_state = { + "pid": process.pid, + "host": host, + "port": port, + "app": app_target, + "log_file": str(log_path), + "started_at": int(time.time()), + } + _write_state(pid_path, service_state) + + ready = _wait_for_server_ready(host, port, ready_timeout) + if ready: + console.print(f"[green]✓[/green] dataclaw 已启动: pid={process.pid}") + console.print(f"[green]✓[/green] WebUI 地址: http://{host}:{port}") + console.print(f"[green]✓[/green] 日志文件: {log_path}") + return + + code = process.poll() + if code is not None: + _remove_state(pid_path) + console.print(f"[red]✗[/red] dataclaw 启动失败,进程已退出 (code={code})") + console.print(f"[yellow]日志文件[/yellow]: {log_path}") + raise typer.Exit(1) + + console.print(f"[yellow]⚠[/yellow] 服务已拉起但未在 {ready_timeout:.1f}s 内确认就绪") + console.print(f"[yellow]请检查日志[/yellow]: {log_path}") + + +@app.command() +def status( + pid_file: str | None = typer.Option(None, "--pid-file", help="PID 状态文件路径"), +) -> None: + pid_path = _resolve_path(pid_file, _default_pid_file()) + state = _read_state(pid_path) + if not state: + console.print("[yellow]●[/yellow] dataclaw 状态: stopped") + return + + pid = int(state.get("pid", 0)) + if _is_process_running(pid): + host = state.get("host", "127.0.0.1") + port = state.get("port", 8000) + console.print("[green]●[/green] dataclaw 状态: running") + console.print(f"[green]pid[/green]: {pid}") + console.print(f"[green]url[/green]: http://{host}:{port}") + return + + _remove_state(pid_path) + console.print("[yellow]●[/yellow] dataclaw 状态: stopped (已清理过期状态文件)") + + +@app.command() +def stop( + timeout: float = typer.Option(8.0, "--timeout", help="停止等待时长(秒)"), + pid_file: str | None = typer.Option(None, "--pid-file", help="PID 状态文件路径"), +) -> None: + pid_path = _resolve_path(pid_file, _default_pid_file()) + state = _read_state(pid_path) + if not state: + console.print("[yellow]⚠[/yellow] dataclaw 未运行") + return + + pid = int(state.get("pid", 0)) + if not _is_process_running(pid): + _remove_state(pid_path) + console.print("[yellow]⚠[/yellow] dataclaw 进程不存在,已清理状态文件") + return + + stopped = _stop_pid(pid, timeout) + if stopped: + _remove_state(pid_path) + console.print(f"[green]✓[/green] dataclaw 已停止: pid={pid}") + return + + console.print(f"[red]✗[/red] dataclaw 停止失败: pid={pid}") + raise typer.Exit(1) diff --git a/backend/main.py b/backend/main.py index 64630b4..1b0d995 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ import asyncio import base64 import binascii +import importlib.resources as importlib_resources from typing import Any, Dict, List, Optional, Literal, Tuple import mimetypes from pathlib import Path @@ -10,11 +11,12 @@ from dotenv import load_dotenv env_path = Path(__file__).resolve().parent.parent / ".env" load_dotenv(dotenv_path=env_path) -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Request from fastapi.encoders import jsonable_encoder from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException from pydantic import BaseModel import json import re @@ -98,6 +100,28 @@ PREVIEWABLE_TEXT_EXTENSIONS = { ".log", } + +def _resolve_webui_directory() -> Optional[Path]: + try: + package_webui = importlib_resources.files("app").joinpath("webui") + package_webui_path = Path(str(package_webui)) + if package_webui_path.is_dir(): + return package_webui_path + except Exception: + pass + source_webui = Path(__file__).resolve().parent / "app" / "webui" + if source_webui.is_dir(): + return source_webui + source_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist" + if source_dist.is_dir(): + return source_dist + return None + + +_WEBUI_DIR = _resolve_webui_directory() +_WEBUI_INDEX = _WEBUI_DIR / "index.html" if _WEBUI_DIR else None +_WEBUI_STATIC = StaticFiles(directory=str(_WEBUI_DIR), html=False) if _WEBUI_DIR else None + @app.on_event("startup") async def startup_event(): try: @@ -119,10 +143,34 @@ async def shutdown_event(): await nanobot_service.stop() trace_service.shutdown() -@app.get("/") -def read_root(): +async def read_root(): + if _WEBUI_INDEX and _WEBUI_INDEX.is_file(): + return FileResponse(path=str(_WEBUI_INDEX), media_type="text/html") return {"Hello": "DataClaw Backend"} + +async def serve_webui_path(full_path: str, request: Request): + reserved_prefixes = ("api/", "reports/", "nanobot/", "connect/", "docs", "redoc", "openapi.json") + if full_path.startswith(reserved_prefixes): + raise HTTPException(status_code=404, detail="Not Found") + if not _WEBUI_STATIC: + raise HTTPException(status_code=404, detail="Not Found") + try: + response = await _WEBUI_STATIC.get_response(full_path, request.scope) + except StarletteHTTPException as exc: + if exc.status_code != 404: + raise + response = None + if response and response.status_code != 404: + return response + if Path(full_path).suffix: + if response: + return response + raise HTTPException(status_code=404, detail="Not Found") + if _WEBUI_INDEX and _WEBUI_INDEX.is_file(): + return FileResponse(path=str(_WEBUI_INDEX), media_type="text/html") + raise HTTPException(status_code=404, detail="Not Found") + @app.get("/connect/postgres") def test_postgres(): if postgres_connector.test_connection(): @@ -794,3 +842,7 @@ def update_session_context_file(session_id: str, payload: SessionFileContextUpda session.updated_at = datetime.now() nanobot_service.agent.sessions.save(session) return {"status": "success", "metadata": session.metadata} + + +app.add_api_route("/", read_root, methods=["GET"], include_in_schema=False) +app.add_api_route("/{full_path:path}", serve_webui_path, methods=["GET"], include_in_schema=False) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5f2166c..2b60990 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "backend" +name = "dataclaw" version = "0.1.0" description = "Add your description here" readme = "README.md" @@ -56,5 +56,28 @@ dependencies = [ "python-pptx>=1.0.2", ] +[project.scripts] +dataclaw = "app.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.hatch.build.targets.wheel.sources] +"app" = "app" + +[tool.hatch.build.targets.wheel.force-include] +"frontend/dist" = "app/webui" +"main.py" = "main.py" + +[tool.hatch.build.targets.sdist.force-include] +"../frontend/dist" = "frontend/dist" + [tool.uv.sources] nanobot-ai = { path = "../nanobot", editable = true } diff --git a/backend/tests/test_dataclaw_cli.py b/backend/tests/test_dataclaw_cli.py new file mode 100644 index 0000000..96b1371 --- /dev/null +++ b/backend/tests/test_dataclaw_cli.py @@ -0,0 +1,102 @@ +import json +import sys +from importlib import import_module +from pathlib import Path + +from typer.testing import CliRunner + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = BACKEND_ROOT.parent +NANOBOT_ROOT = REPO_ROOT / "nanobot" +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) +if str(NANOBOT_ROOT) not in sys.path: + sys.path.insert(0, str(NANOBOT_ROOT)) + +app = import_module("app.cli").app + +runner = CliRunner() + + +class _FakeProcess: + def __init__(self, pid: int = 9527, exit_code: int | None = None) -> None: + self.pid = pid + self._exit_code = exit_code + + def poll(self): + return self._exit_code + + +def test_start_command_writes_state(monkeypatch, tmp_path) -> None: + pid_file = tmp_path / "run" / "state.json" + log_file = tmp_path / "run" / "service.log" + + monkeypatch.setattr("app.cli.subprocess.Popen", lambda *args, **kwargs: _FakeProcess()) + monkeypatch.setattr("app.cli._wait_for_server_ready", lambda *_args, **_kwargs: True) + + result = runner.invoke( + app, + [ + "start", + "--host", + "127.0.0.1", + "--port", + "18999", + "--pid-file", + str(pid_file), + "--log-file", + str(log_file), + ], + ) + + assert result.exit_code == 0 + assert "已启动" in result.stdout + assert pid_file.exists() + state = json.loads(pid_file.read_text(encoding="utf-8")) + assert state["pid"] == 9527 + assert state["host"] == "127.0.0.1" + assert state["port"] == 18999 + + +def test_status_command_reports_running(monkeypatch, tmp_path) -> None: + pid_file = tmp_path / "run" / "state.json" + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text( + json.dumps({"pid": 9527, "host": "127.0.0.1", "port": 18080}, ensure_ascii=False), + encoding="utf-8", + ) + + monkeypatch.setattr("app.cli._is_process_running", lambda pid: pid == 9527) + result = runner.invoke(app, ["status", "--pid-file", str(pid_file)]) + + assert result.exit_code == 0 + assert "running" in result.stdout + assert "127.0.0.1:18080" in result.stdout + + +def test_stop_command_cleans_state(monkeypatch, tmp_path) -> None: + pid_file = tmp_path / "run" / "state.json" + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(json.dumps({"pid": 9527}, ensure_ascii=False), encoding="utf-8") + + monkeypatch.setattr("app.cli._is_process_running", lambda pid: pid == 9527) + monkeypatch.setattr("app.cli._stop_pid", lambda pid, timeout: pid == 9527) + + result = runner.invoke(app, ["stop", "--pid-file", str(pid_file)]) + + assert result.exit_code == 0 + assert "已停止" in result.stdout + assert not pid_file.exists() + + +def test_status_command_cleans_stale_state(monkeypatch, tmp_path) -> None: + pid_file = tmp_path / "run" / "state.json" + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(json.dumps({"pid": 9527}, ensure_ascii=False), encoding="utf-8") + + monkeypatch.setattr("app.cli._is_process_running", lambda _pid: False) + result = runner.invoke(app, ["status", "--pid-file", str(pid_file)]) + + assert result.exit_code == 0 + assert "stopped" in result.stdout + assert not pid_file.exists() diff --git a/backend/tests/test_webui_static_hosting.py b/backend/tests/test_webui_static_hosting.py new file mode 100644 index 0000000..fa8ebdb --- /dev/null +++ b/backend/tests/test_webui_static_hosting.py @@ -0,0 +1,78 @@ +from pathlib import Path +import sys + +from fastapi.staticfiles import StaticFiles +from fastapi.testclient import TestClient + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = BACKEND_ROOT.parent +NANOBOT_ROOT = REPO_ROOT / "nanobot" +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) +if str(NANOBOT_ROOT) not in sys.path: + sys.path.insert(0, str(NANOBOT_ROOT)) + +import main + + +def _prepare_webui(monkeypatch, tmp_path: Path) -> None: + webui_dir = tmp_path / "webui" + assets_dir = webui_dir / "assets" + assets_dir.mkdir(parents=True, exist_ok=True) + (webui_dir / "index.html").write_text("dataclaw-webui", encoding="utf-8") + (assets_dir / "app.js").write_text("window.__TASK2__=true;", encoding="utf-8") + monkeypatch.setattr(main, "_WEBUI_DIR", webui_dir) + monkeypatch.setattr(main, "_WEBUI_INDEX", webui_dir / "index.html") + monkeypatch.setattr(main, "_WEBUI_STATIC", StaticFiles(directory=str(webui_dir), html=False)) + + +def _prepare_lifecycle(monkeypatch) -> None: + async def fake_start(): + return None + + async def fake_stop(): + return None + + monkeypatch.setattr(main.nanobot_service, "start", fake_start) + monkeypatch.setattr(main.nanobot_service, "stop", fake_stop) + + +def test_webui_static_assets_served_from_backend(monkeypatch, tmp_path) -> None: + _prepare_webui(monkeypatch, tmp_path) + _prepare_lifecycle(monkeypatch) + client = TestClient(main.app) + + index_resp = client.get("/") + assert index_resp.status_code == 200 + assert "dataclaw-webui" in index_resp.text + + asset_resp = client.get("/assets/app.js") + assert asset_resp.status_code == 200 + assert "window.__TASK2__=true;" in asset_resp.text + + +def test_spa_route_fallback_to_index_html(monkeypatch, tmp_path) -> None: + _prepare_webui(monkeypatch, tmp_path) + _prepare_lifecycle(monkeypatch) + client = TestClient(main.app) + + spa_resp = client.get("/settings/users") + assert spa_resp.status_code == 200 + assert "dataclaw-webui" in spa_resp.text + + missing_asset_resp = client.get("/assets/missing.js") + assert missing_asset_resp.status_code == 404 + + +def test_backend_accessible_without_frontend_dev_server(monkeypatch, tmp_path) -> None: + _prepare_webui(monkeypatch, tmp_path) + _prepare_lifecycle(monkeypatch) + client = TestClient(main.app) + + ui_resp = client.get("/") + assert ui_resp.status_code == 200 + assert "dataclaw-webui" in ui_resp.text + + api_resp = client.get("/nanobot/status") + assert api_resp.status_code == 200 + assert api_resp.json()["status"] in {"running", "stopped"} diff --git a/backend/uv.lock b/backend/uv.lock index e4d0a8c..26e7c4c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -217,7 +217,7 @@ wheels = [ [[package]] name = "backend" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "bcrypt" }, { name = "chardet" },