feat: initial XCClaw基础架构
- 基于 FastAPI 的 Web API 服务 - OpenCode API 客户端封装 - 会话管理器(同步/异步任务执行) - APScheduler 定时任务调度 - 完整的 REST API 端点
This commit is contained in:
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.api.routes import router
|
||||
72
app/api/routes.py
Normal file
72
app/api/routes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.models.session import CreateSessionRequest, SessionType
|
||||
from app.services.session_manager import session_manager
|
||||
from app.services.scheduler import scheduler_service, ScheduleTask
|
||||
from app.services.opencode_client import opencode_client
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/xcclaw", tags=["xcclaw"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
try:
|
||||
opencode_status = await opencode_client.health_check()
|
||||
return {"status": "ok", "opencode": opencode_status}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/task")
|
||||
async def create_task(request: CreateSessionRequest):
|
||||
task = await session_manager.create_task(request)
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/task/{task_id}/execute")
|
||||
async def execute_task(task_id: str):
|
||||
result = await session_manager.execute_task(task_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/task/{task_id}/execute_async")
|
||||
async def execute_task_async(task_id: str):
|
||||
await session_manager.execute_task_async(task_id)
|
||||
return {"status": "started", "task_id": task_id}
|
||||
|
||||
|
||||
@router.post("/task/{task_id}/abort")
|
||||
async def abort_task(task_id: str):
|
||||
result = await session_manager.abort_task(task_id)
|
||||
return {"aborted": result}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}")
|
||||
async def get_task(task_id: str):
|
||||
task = await session_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/task")
|
||||
async def list_tasks():
|
||||
return await session_manager.list_tasks()
|
||||
|
||||
|
||||
@router.post("/schedule")
|
||||
async def create_schedule(task: ScheduleTask):
|
||||
return await scheduler_service.add_schedule(task)
|
||||
|
||||
|
||||
@router.get("/schedule")
|
||||
async def list_schedules():
|
||||
return await scheduler_service.list_schedules()
|
||||
|
||||
|
||||
@router.delete("/schedule/{schedule_id}")
|
||||
async def delete_schedule(schedule_id: str):
|
||||
result = await scheduler_service.remove_schedule(schedule_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
return {"deleted": True}
|
||||
4
app/core/__init__.py
Normal file
4
app/core/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
|
||||
__all__ = ["settings", "logger"]
|
||||
19
app/core/config.py
Normal file
19
app/core/config.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
opencode_host: str = "127.0.0.1"
|
||||
opencode_port: int = 4096
|
||||
opencode_password: str = ""
|
||||
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 3005
|
||||
|
||||
data_dir: Path = Path.home() / "Documents" / "XCDesktop" / "xcclaw"
|
||||
|
||||
class Config:
|
||||
env_prefix = "XCCLAW_"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
14
app/core/logging.py
Normal file
14
app/core/logging.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
return logging.getLogger("xcclaw")
|
||||
|
||||
|
||||
logger = setup_logging()
|
||||
37
app/main.py
Normal file
37
app/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from app.api.routes import router
|
||||
from app.services.scheduler import scheduler_service
|
||||
from app.services.opencode_client import opencode_client
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting XCClaw server...")
|
||||
scheduler_service.start()
|
||||
yield
|
||||
logger.info("Shutting down XCClaw server...")
|
||||
scheduler_service.shutdown()
|
||||
await opencode_client.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="XCClaw",
|
||||
description="基于 OpenCode Agent 的任务调度系统",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.app_host,
|
||||
port=settings.app_port,
|
||||
reload=True,
|
||||
)
|
||||
1
app/models/__init__.py
Normal file
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.models.session import SessionType, TaskStatus, CreateSessionRequest, Task
|
||||
30
app/models/session.py
Normal file
30
app/models/session.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionType(str, Enum):
|
||||
EPHEMERAL = "ephemeral"
|
||||
PERSISTENT = "persistent"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
type: SessionType = SessionType.EPHEMERAL
|
||||
title: str | None = None
|
||||
prompt: str | None = None
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
id: str
|
||||
type: SessionType
|
||||
prompt: str
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
session_id: str | None = None
|
||||
schedule: str | None = None
|
||||
3
app/services/__init__.py
Normal file
3
app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.services.opencode_client import opencode_client
|
||||
from app.services.session_manager import session_manager
|
||||
from app.services.scheduler import scheduler_service
|
||||
88
app/services/opencode_client.py
Normal file
88
app/services/opencode_client.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from typing import Any
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class OpenCodeClient:
|
||||
def __init__(self):
|
||||
self.base_url = f"http://{settings.opencode_host}:{settings.opencode_port}"
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
headers = {}
|
||||
if settings.opencode_password:
|
||||
headers["Authorization"] = f"Bearer {settings.opencode_password}"
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=headers,
|
||||
timeout=300.0,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
client = await self._get_client()
|
||||
resp = await client.get("/global/health")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def create_session(self, title: str | None = None, parent_id: str | None = None) -> dict[str, Any]:
|
||||
client = await self._get_client()
|
||||
payload = {}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if parent_id:
|
||||
payload["parentID"] = parent_id
|
||||
resp = await client.post("/session", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_session(self, session_id: str) -> dict[str, Any]:
|
||||
client = await self._get_client()
|
||||
resp = await client.get(f"/session/{session_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
client = await self._get_client()
|
||||
resp = await client.delete(f"/session/{session_id}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def send_message(self, session_id: str, text: str) -> dict[str, Any]:
|
||||
client = await self._get_client()
|
||||
payload = {
|
||||
"parts": [{"type": "text", "text": text}]
|
||||
}
|
||||
resp = await client.post(f"/session/{session_id}/message", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def send_message_async(self, session_id: str, text: str) -> None:
|
||||
client = await self._get_client()
|
||||
payload = {
|
||||
"parts": [{"type": "text", "text": text}]
|
||||
}
|
||||
resp = await client.post(f"/session/{session_id}/prompt_async", json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def abort_session(self, session_id: str) -> bool:
|
||||
client = await self._get_client()
|
||||
resp = await client.post(f"/session/{session_id}/abort")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_session_status(self) -> dict[str, Any]:
|
||||
client = await self._get_client()
|
||||
resp = await client.get("/session/status")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
opencode_client = OpenCodeClient()
|
||||
79
app/services/scheduler.py
Normal file
79
app/services/scheduler.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
from pydantic import BaseModel
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.services.session_manager import session_manager
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class ScheduleTask(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
cron: str
|
||||
prompt: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.scheduled_tasks: dict[str, ScheduleTask] = {}
|
||||
|
||||
def start(self):
|
||||
self.scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
def shutdown(self):
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Scheduler shutdown")
|
||||
|
||||
async def add_schedule(self, task: ScheduleTask) -> ScheduleTask:
|
||||
self.scheduled_tasks[task.id] = task
|
||||
|
||||
if task.enabled:
|
||||
trigger = CronTrigger.from_crontab(task.cron)
|
||||
self.scheduler.add_job(
|
||||
self._run_scheduled_task,
|
||||
trigger,
|
||||
args=[task.id],
|
||||
id=task.id,
|
||||
)
|
||||
logger.info(f"Added schedule: {task.name} ({task.cron})")
|
||||
|
||||
return task
|
||||
|
||||
async def remove_schedule(self, task_id: str) -> bool:
|
||||
task = self.scheduled_tasks.pop(task_id, None)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
self.scheduler.remove_job(task_id)
|
||||
logger.info(f"Removed schedule: {task.name}")
|
||||
return True
|
||||
|
||||
async def list_schedules(self) -> list[ScheduleTask]:
|
||||
return list(self.scheduled_tasks.values())
|
||||
|
||||
async def _run_scheduled_task(self, task_id: str):
|
||||
task = self.scheduled_tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
logger.info(f"Running scheduled task: {task.name}")
|
||||
|
||||
from app.models.session import CreateSessionRequest, SessionType
|
||||
|
||||
request = CreateSessionRequest(
|
||||
type=SessionType.SCHEDULED,
|
||||
prompt=task.prompt,
|
||||
)
|
||||
task_obj = await session_manager.create_task(request)
|
||||
|
||||
try:
|
||||
await session_manager.execute_task_async(task_obj.id)
|
||||
logger.info(f"Scheduled task {task.name} started")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled task {task.name} failed to start: {e}")
|
||||
|
||||
|
||||
scheduler_service = SchedulerService()
|
||||
77
app/services/session_manager.py
Normal file
77
app/services/session_manager.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
from app.models.session import SessionType, Task, TaskStatus, CreateSessionRequest
|
||||
from app.services.opencode_client import opencode_client
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self):
|
||||
self.tasks: dict[str, Task] = {}
|
||||
|
||||
async def create_task(self, request: CreateSessionRequest) -> Task:
|
||||
task_id = str(uuid.uuid4())
|
||||
task = Task(
|
||||
id=task_id,
|
||||
type=request.type,
|
||||
prompt=request.prompt or "",
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
logger.info(f"Created task: {task_id}")
|
||||
return task
|
||||
|
||||
async def execute_task(self, task_id: str) -> dict:
|
||||
task = self.tasks.get(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
task.status = TaskStatus.RUNNING
|
||||
session = await opencode_client.create_session(
|
||||
title=task.prompt[:50] if task.prompt else None
|
||||
)
|
||||
session_id = session["id"]
|
||||
task.session_id = session_id
|
||||
|
||||
logger.info(f"Executing task {task_id} with session {session_id}")
|
||||
|
||||
try:
|
||||
result = await opencode_client.send_message(session_id, task.prompt)
|
||||
task.status = TaskStatus.COMPLETED
|
||||
return result
|
||||
except Exception as e:
|
||||
task.status = TaskStatus.FAILED
|
||||
logger.error(f"Task {task_id} failed: {e}")
|
||||
raise
|
||||
|
||||
async def execute_task_async(self, task_id: str) -> None:
|
||||
task = self.tasks.get(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
task.status = TaskStatus.RUNNING
|
||||
session = await opencode_client.create_session(
|
||||
title=task.prompt[:50] if task.prompt else None
|
||||
)
|
||||
session_id = session["id"]
|
||||
task.session_id = session_id
|
||||
|
||||
logger.info(f"Executing async task {task_id} with session {session_id}")
|
||||
|
||||
await opencode_client.send_message_async(session_id, task.prompt)
|
||||
|
||||
async def abort_task(self, task_id: str) -> bool:
|
||||
task = self.tasks.get(task_id)
|
||||
if not task or not task.session_id:
|
||||
return False
|
||||
|
||||
result = await opencode_client.abort_session(task.session_id)
|
||||
task.status = TaskStatus.FAILED
|
||||
return result
|
||||
|
||||
async def get_task(self, task_id: str) -> Task | None:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
async def list_tasks(self) -> list[Task]:
|
||||
return list(self.tasks.values())
|
||||
|
||||
|
||||
session_manager = SessionManager()
|
||||
Reference in New Issue
Block a user