refactor(router): 重构项目结构,将主入口移至router/main.py

删除旧的main.py文件,将FastAPI应用创建逻辑集中在router/main.py中
添加直接运行router/main.py的支持
更新README.md以反映新的项目结构和使用方式
添加websocket连接测试脚本test_websocket.py
This commit is contained in:
Yuyao Huang (Sam) 2026-03-29 16:46:23 +08:00
parent 71e3f14788
commit 9173aa6094
4 changed files with 61 additions and 127 deletions

View File

@ -41,9 +41,8 @@ PhoneWork uses a **Router + Host Client** architecture that supports both single
| Module | Purpose |
|--------|---------|
| `standalone.py` | Single-process entry point: runs router + host client together |
| `main.py` | FastAPI entry point for router-only mode |
| `router/main.py` | FastAPI app factory, mounts `/ws/node` endpoint, can be run directly |
| `shared/protocol.py` | Wire protocol for router-host communication |
| `router/main.py` | FastAPI app factory, mounts `/ws/node` endpoint |
| `router/nodes.py` | Node registry, connection management, user-to-node mapping |
| `router/ws.py` | WebSocket endpoint for host clients, heartbeat, message routing |
| `router/rpc.py` | Request correlation with asyncio.Future, timeout handling |
@ -140,13 +139,13 @@ OPENAI_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL: glm-4.7
# Server configuration
# Only used in router mode (python main.py) or standalone mode (python standalone.py)
# Only used in router mode (python -m router.main) or standalone mode (python standalone.py)
# Default: 8000
PORT: 8000
# Root directory for all project sessions (absolute path)
# Only used in standalone mode (python standalone.py)
# In router mode (python main.py), this field is ignored
# In router mode (python -m router.main), this field is ignored
WORKING_DIR: C:/Users/yourname/projects
# Allowlist of Feishu open_ids that may use the bot.
@ -159,8 +158,7 @@ ALLOWED_OPEN_IDS:
METASO_API_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Optional: Multi-host mode configuration
# Set ROUTER_MODE to true to enable router mode (deploy on public VPS)
ROUTER_MODE: false
# Set ROUTER_SECRET for authentication between router and host clients
ROUTER_SECRET: your-shared-secret-for-router-host-auth
```
@ -252,8 +250,8 @@ python standalone.py
**Router** (public VPS or any reachable machine — runs the Feishu bot):
```bash
# keyring.yaml must have ROUTER_MODE: true and ROUTER_SECRET set
python main.py
# keyring.yaml must have ROUTER_SECRET set
python -m router.main
```
**Host client** (your dev machine behind NAT — runs Claude Code):
@ -389,8 +387,8 @@ Runs both router and host client in one process. Identical UX to pre-M3 setup.
**Router Mode (Public VPS):**
```bash
# Set ROUTER_MODE: true in keyring.yaml
python main.py
# Set ROUTER_SECRET in keyring.yaml
python -m router.main
```
Runs only the router: Feishu handler + routing LLM + node registry.

117
main.py
View File

@ -1,117 +0,0 @@
"""PhoneWork entry point: FastAPI app + Feishu long-connection client."""
from __future__ import annotations
import asyncio
import logging
import time
import uvicorn
from fastapi import FastAPI
from rich.logging import RichHandler
from agent.manager import manager
from bot.handler import start_websocket_client, get_ws_status
logging.basicConfig(
level=logging.DEBUG,
format="%(name)-20s %(message)s",
datefmt="[%X]",
handlers=[RichHandler(
rich_tracebacks=True,
markup=True,
show_path=False,
omit_repeated_times=False,
)],
)
for _noisy in ("httpcore", "httpx", "openai._base_client", "urllib3", "lark_oapi", "websockets"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
app = FastAPI(title="PhoneWork", version="0.1.0")
START_TIME = time.time()
@app.get("/health")
async def health() -> dict:
sessions = manager.list_sessions()
ws_status = get_ws_status()
uptime = time.time() - START_TIME
result = {
"status": "ok",
"uptime_seconds": round(uptime, 1),
"active_sessions": len(sessions),
"websocket": ws_status,
}
if ws_status.get("connected"):
result["status"] = "ok"
else:
result["status"] = "degraded"
return result
@app.get("/health/claude")
async def health_claude() -> dict:
"""Smoke test: run a simple claude -p command."""
from agent.pty_process import run_claude
import tempfile
import os
start = time.time()
try:
with tempfile.TemporaryDirectory() as tmpdir:
output = await run_claude(
"Say 'pong' and nothing else",
cwd=tmpdir,
timeout=30.0,
)
elapsed = time.time() - start
return {
"status": "ok",
"elapsed_seconds": round(elapsed, 2),
"output_preview": output[:100] if output else None,
}
except asyncio.TimeoutError:
return {"status": "timeout", "elapsed_seconds": 30.0}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.get("/sessions")
async def list_sessions() -> list:
return manager.list_sessions()
@app.on_event("startup")
async def startup_event() -> None:
await manager.start()
from agent.scheduler import scheduler
await scheduler.start()
loop = asyncio.get_running_loop()
start_websocket_client(loop)
logger.info("PhoneWork started")
@app.on_event("shutdown")
async def shutdown_event() -> None:
await manager.stop()
from agent.scheduler import scheduler
await scheduler.stop()
logger.info("PhoneWork shut down")
if __name__ == "__main__":
from config import PORT
uvicorn.run(
"main:app",
host="0.0.0.0",
port=PORT,
reload=False,
log_level="info",
ws_ping_interval=20,
ws_ping_timeout=60,
)

View File

@ -74,3 +74,18 @@ def create_app(router_secret: Optional[str] = None) -> FastAPI:
logger.info("Router shut down")
return app
if __name__ == "__main__":
from config import PORT, ROUTER_SECRET
import uvicorn
app = create_app(router_secret=ROUTER_SECRET)
uvicorn.run(
"router.main:app",
host="0.0.0.0",
port=PORT,
reload=False,
log_level="info",
ws_ping_interval=20,
ws_ping_timeout=60,
)

38
test_websocket.py Normal file
View File

@ -0,0 +1,38 @@
# test_websocket.py
import asyncio
import websockets
async def test_connection():
uri = "ws://47.101.162.164:9600/ws/node"
headers = {
"Authorization": "Bearer family-member-x9"
}
try:
print(f"Connecting to {uri}...")
async with websockets.connect(uri, extra_headers=headers) as ws:
print("✅ Connection successful!")
print("WebSocket connection established.")
# 发送注册消息
import json
register_msg = {
"type": "register",
"node_id": "test-node",
"display_name": "Test Node",
"serves_users": ["test-user"],
"working_dir": "/tmp",
"capabilities": ["claude_code", "shell", "file_ops"]
}
await ws.send(json.dumps(register_msg))
print("Sent registration message")
# 等待响应
response = await ws.recv()
print(f"Received response: {response}")
except Exception as e:
print(f"❌ Connection failed: {e}")
if __name__ == "__main__":
asyncio.run(test_connection())