feat: add wheel package

This commit is contained in:
qixinbo
2026-03-31 21:06:13 +08:00
parent 01524aaff5
commit 7cdbf1d333
9 changed files with 628 additions and 41 deletions
+60 -19
View File
@@ -80,9 +80,63 @@ cp .env.example .env
> 5. 点击下方的“生成授权码”,使用手机 QQ 扫码或按提示发送短信 > 5. 点击下方的“生成授权码”,使用手机 QQ 扫码或按提示发送短信
> 6. 验证通过后将获得一串 **16位随机字母组合**,将其复制填入 `.env` 文件中的 `SMTP_PASSWORD` 字段 > 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 ```bash
cd backend cd backend
@@ -97,31 +151,18 @@ pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000 uvicorn app.main:app --reload --port 8000
``` ```
可选环境变量:
```bash
export DATA_ROOT=/absolute/path/to/data
```
若未设置,默认使用仓库根目录下的 `data/`
*提示:请确保* *`nanobot`* *核心库已根据项目工作区的要求正确链接或以可编辑模式 (editable mode) 安装。*
### 2. 前端服务启动 ⚛️
请确保你已安装 Node.js 18 或以上版本。
```bash ```bash
cd frontend cd frontend
# 安装依赖 # 安装依赖(仅开发模式需要 Node.js
npm install npm install
# 启动 Vite 开发服务器 # 启动 Vite 开发服务器
npm run dev npm run dev
``` ```
*提示:请确保* *`nanobot`* *核心库已根据项目工作区的要求正确链接或以可编辑模式 (editable mode) 安装。*
### 3. 语音识别服务(可选)🎙️ ### 5. 语音识别服务(可选)🎙️
若你希望使用聊天输入框中的语音输入能力,请单独启动 `whisper` 服务: 若你希望使用聊天输入框中的语音输入能力,请单独启动 `whisper` 服务:
@@ -142,7 +183,7 @@ python main.py
3. 填写服务地址(例如 `http://localhost:8001`); 3. 填写服务地址(例如 `http://localhost:8001`);
4. 点击「测试连接」通过后保存。 4. 点击「测试连接」通过后保存。
### 4. 初始账号配置 👤 ### 6. 初始账号配置 👤
系统首次注册的用户将自动成为管理员。您可以在登录页面直接点击“注册”按钮创建您的管理员账号(例如:用户名 `admin`,密码 `admin`),随后即可登录并管理项目、数据源和用户。 系统首次注册的用户将自动成为管理员。您可以在登录页面直接点击“注册”按钮创建您的管理员账号(例如:用户名 `admin`,密码 `admin`),随后即可登录并管理项目、数据源和用户。
*** ***
+59 -17
View File
@@ -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 > 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 > 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 ```bash
cd backend cd backend
@@ -97,20 +151,6 @@ pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000 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 ```bash
cd frontend cd frontend
# Install dependencies # Install dependencies
@@ -120,7 +160,9 @@ npm install
npm run dev 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: If you want to use voice input in chat, run the standalone `whisper` service:
+18
View File
@@ -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
```
+231
View File
@@ -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)
+55 -3
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import base64 import base64
import binascii import binascii
import importlib.resources as importlib_resources
from typing import Any, Dict, List, Optional, Literal, Tuple from typing import Any, Dict, List, Optional, Literal, Tuple
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
@@ -10,11 +11,12 @@ from dotenv import load_dotenv
env_path = Path(__file__).resolve().parent.parent / ".env" env_path = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(dotenv_path=env_path) 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.encoders import jsonable_encoder
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from pydantic import BaseModel from pydantic import BaseModel
import json import json
import re import re
@@ -98,6 +100,28 @@ PREVIEWABLE_TEXT_EXTENSIONS = {
".log", ".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") @app.on_event("startup")
async def startup_event(): async def startup_event():
try: try:
@@ -119,10 +143,34 @@ async def shutdown_event():
await nanobot_service.stop() await nanobot_service.stop()
trace_service.shutdown() trace_service.shutdown()
@app.get("/") async def read_root():
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"} 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") @app.get("/connect/postgres")
def test_postgres(): def test_postgres():
if postgres_connector.test_connection(): if postgres_connector.test_connection():
@@ -794,3 +842,7 @@ def update_session_context_file(session_id: str, payload: SessionFileContextUpda
session.updated_at = datetime.now() session.updated_at = datetime.now()
nanobot_service.agent.sessions.save(session) nanobot_service.agent.sessions.save(session)
return {"status": "success", "metadata": session.metadata} 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)
+24 -1
View File
@@ -1,5 +1,5 @@
[project] [project]
name = "backend" name = "dataclaw"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
@@ -56,5 +56,28 @@ dependencies = [
"python-pptx>=1.0.2", "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] [tool.uv.sources]
nanobot-ai = { path = "../nanobot", editable = true } nanobot-ai = { path = "../nanobot", editable = true }
+102
View File
@@ -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()
@@ -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("<html><body>dataclaw-webui</body></html>", 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"}
+1 -1
View File
@@ -217,7 +217,7 @@ wheels = [
[[package]] [[package]]
name = "backend" name = "backend"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "chardet" }, { name = "chardet" },