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 扫码或按提示发送短信
> 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`),随后即可登录并管理项目、数据源和用户。
***
+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
> 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:
+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 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)
+24 -1
View File
@@ -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 }
+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]]
name = "backend"
version = "0.1.0"
source = { virtual = "." }
source = { editable = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "chardet" },