assistant/backend/api/routes.py

461 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import os
import time
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend import hub_sync
from backend.browser import runner
from backend.browser.llm_factory import get_llm_config
from backend.config import MODELS_BY_PROVIDER, settings
from backend.db import database as db
from backend.orchestrator.orchestrator import Orchestrator, business_name_for
from backend.orchestrator.progress_doc import progress_doc_path, task_doc_dir
from backend.security import encrypt_secret
router = APIRouter()
orchestrator = Orchestrator()
STARTED_AT = time.time()
APP_VERSION = "0.2.0"
class StartTaskRequest(BaseModel):
knowledge_id: str = "xuegong_demo"
message: str
student_id: str = "default"
class SubmitInputsRequest(BaseModel):
inputs: dict[str, Any] | None = None
message: str | None = None
class HumanResponseRequest(BaseModel):
answer: str = ""
class SettingsUpdateRequest(BaseModel):
llm_provider: str | None = None
llm_api_key: str | None = None
dashscope_api_key: str | None = None
llm_model: str | None = None
llm_base_url: str | None = None
class AccountUpsertRequest(BaseModel):
student_id: str = "default"
system_id: str
role: str = ""
username: str
password: str
class DebugBrowserRequest(BaseModel):
url: str = "https://school.miaopinai.com:29800/xuegong/"
task: str = "浏览页面并描述主要菜单和功能入口"
# ---------- 运维 ----------
@router.get("/health")
async def health():
return {"status": "ok", "pid": os.getpid()}
@router.get("/meta")
async def meta():
return {
"pid": os.getpid(),
"version": APP_VERSION,
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(STARTED_AT)),
"uptime_s": round(time.time() - STARTED_AT),
"db": f"postgresql://{settings.pg_host}:{settings.pg_port}/{settings.pg_database}",
"school_id": settings.school_id,
"operator_mode": bool(settings.operator_mode),
"learned_auto_verify_min_obs": settings.learned_auto_verify_min_obs,
"live_sessions": runner.live_session_ids(),
"dock_bounds": runner.get_dock_bounds(),
"tracking": {"enabled": bool(settings.tracking_enabled), "poll_seconds": settings.tracking_poll_seconds},
"embedded": runner.embedded_mode(),
"llm": _llm_status(),
"hub": hub_sync.status(),
}
@router.post("/hub/sync")
async def sync_hub():
"""手动触发一次中心同步(上报草稿 + 拉取审核结果)。"""
return await hub_sync.sync_once()
def _llm_status() -> dict:
from backend.browser import llm_keys
cfg = get_llm_config()
return {
"provider": cfg["provider"],
"model": cfg["model"],
"api_key_set": bool(cfg["api_key"]),
**llm_keys.status(cfg["provider"], cfg["primary_api_key"]),
}
async def _exit_soon() -> None:
await asyncio.sleep(0.3)
try:
await runner.close_all_sessions()
finally:
os._exit(0)
@router.post("/shutdown")
async def shutdown():
"""本地桌面端专用:Electron 启动时用它关掉残留的旧后端,确保跑的是最新代码。"""
asyncio.create_task(_exit_soon())
return {"ok": True, "pid": os.getpid()}
# ---------- 浏览器停靠 / 展开收起 ----------
class DockBoundsRequest(BaseModel):
left: int
top: int
width: int
height: int
@router.post("/browser/dock")
async def set_dock(body: DockBoundsRequest):
"""Electron 在窗口移动 / 缩放时上报:Chrome 应停靠的屏幕区域。新任务启动和「展开」时都按它摆放。"""
return {"dock_bounds": runner.set_dock_bounds(body.model_dump())}
@router.post("/tasks/{task_id}/browser/{action}")
async def browser_window(task_id: str, action: str):
if action not in ("show", "hide"):
raise HTTPException(400, "action 只能是 show 或 hide")
try:
return await runner.set_browser_window(task_id, action)
except RuntimeError as e:
raise HTTPException(409, str(e))
except Exception as e: # noqa: BLE001 CDP 调用失败
raise HTTPException(500, f"浏览器窗口控制失败:{e}")
# ---------- 知识 / 任务 ----------
@router.get("/knowledge")
async def list_knowledge():
return db.list_knowledge()
@router.get("/tasks")
async def list_tasks(limit: int = 30):
tasks = db.list_tasks(limit)
names: dict[tuple[str, str], str | None] = {}
out = []
for t in tasks:
key = (t["knowledge_id"], (t.get("inputs") or {}).get("business_id") or "")
if key not in names:
names[key] = business_name_for(t)
out.append(
{
"id": t["id"],
"status": t["status"],
"current_step": t["current_step"],
"goal": (t.get("inputs") or {}).get("goal", ""),
"business_name": names[key],
"created_at": t["created_at"],
"updated_at": t["updated_at"],
"next_check_at": t["next_check_at"],
}
)
return out
def _latest_screenshot(task_id: str) -> str | None:
steps_dir = task_doc_dir(task_id) / "steps"
if not steps_dir.exists():
return None
shots = sorted(steps_dir.glob("step_*.png"))
return str(shots[-1]) if shots else None
@router.get("/tasks/{task_id}")
async def get_task(task_id: str):
task = db.get_task(task_id)
if not task:
raise HTTPException(404, "任务不存在")
doc = progress_doc_path(task_id)
return {
**task,
"business_name": business_name_for(task),
"reply": (task.get("artifacts") or {}).get("summary"),
"progress_doc_path": str(doc) if doc.exists() else None,
"latest_screenshot": _latest_screenshot(task_id),
"live_session": runner.has_live_session(task_id),
"takeover": runner.is_taken_over(task_id),
}
@router.get("/tasks/{task_id}/progress-doc")
async def get_progress_doc(task_id: str):
path = progress_doc_path(task_id)
if not path.exists():
raise HTTPException(404, "进度文档尚未生成")
return {"content": path.read_text(encoding="utf-8")}
@router.post("/tasks/start")
async def start_task(body: StartTaskRequest):
try:
return await orchestrator.start_task(body.knowledge_id, body.message, body.student_id)
except ValueError as e:
raise HTTPException(400, str(e))
@router.post("/tasks/{task_id}/inputs")
async def submit_inputs(task_id: str, body: SubmitInputsRequest):
if not body.inputs and not (body.message or "").strip():
raise HTTPException(400, "inputs 或 message 至少提供一个")
try:
return await orchestrator.submit_inputs(task_id, body.inputs, (body.message or "").strip() or None)
except ValueError as e:
raise HTTPException(400, str(e))
@router.get("/businesses")
async def list_businesses(knowledge_id: str = "xuegong_demo"):
knowledge = db.get_knowledge(knowledge_id)
if not knowledge:
raise HTTPException(404, "业务包不存在")
return [
{
"id": b.get("id"),
"name": b.get("name"),
"role": b.get("role"),
"intent": b.get("intent"),
"required_inputs": b.get("required_inputs") or [],
"active_step": next((s.get("step_id") for s in b.get("workflow") or [] if s.get("enabled", True)), None),
"steps": [
{"step_id": s.get("step_id"), "system": s.get("system") or b.get("system")}
for s in b.get("workflow") or []
if s.get("enabled", True)
],
}
for b in knowledge.get("businesses") or []
]
@router.post("/tasks/{task_id}/resume")
async def resume_task(task_id: str):
try:
return await orchestrator.resume_task(task_id, "用户点击了继续执行,请继续完成原目标")
except ValueError as e:
raise HTTPException(400, str(e))
@router.post("/tasks/{task_id}/takeover")
async def takeover(task_id: str):
"""用户接管浏览器:Agent 在下一步前停住并把窗口展开给用户。"""
from backend.orchestrator.progress_doc import append_progress
try:
await runner.takeover(task_id)
except RuntimeError as e:
raise HTTPException(409, str(e))
try:
await runner.set_browser_window(task_id, "show")
except Exception: # noqa: BLE001
pass
append_progress(task_id, "用户接管", "running", "用户接管浏览器,Agent 暂停等待")
return {"ok": True, "takeover": True}
@router.post("/tasks/{task_id}/takeover-done")
async def takeover_done(task_id: str):
from backend.orchestrator.progress_doc import append_progress
await runner.takeover_done(task_id)
append_progress(task_id, "接管结束", "running", "用户交还控制,Agent 继续")
return {"ok": True, "takeover": False}
@router.post("/tasks/{task_id}/track-now")
async def track_now(task_id: str):
"""立即回查一次审批状态(不等调度)。"""
try:
return await orchestrator.run_tracking(task_id, force=True)
except ValueError as e:
raise HTTPException(400, str(e))
@router.post("/tasks/{task_id}/track-stop")
async def track_stop(task_id: str):
"""停止自动回查(任务仍保持 submitted,可随时「立即回查」)。"""
task = db.get_task(task_id)
if not task:
raise HTTPException(404, "任务不存在")
db.update_task(task_id, next_check_at=None)
from backend.orchestrator.progress_doc import append_progress
append_progress(task_id, "提交后跟踪", task["status"], "用户停止了自动回查")
return {**db.get_task(task_id), "reply": "已停止自动回查"}
@router.post("/tasks/{task_id}/human")
async def human_response(task_id: str, body: HumanResponseRequest):
try:
return await orchestrator.resume_task(task_id, body.answer.strip() or "已确认,请继续")
except ValueError as e:
raise HTTPException(400, str(e))
# ---------- 系统账号 ----------
@router.get("/accounts")
async def list_accounts(student_id: str | None = None):
return db.list_accounts(student_id)
@router.post("/accounts")
async def upsert_account(body: AccountUpsertRequest):
try:
acct = db.upsert_account(body.student_id, body.system_id, body.username, encrypt_secret(body.password), body.role)
except RuntimeError as e:
raise HTTPException(400, str(e))
return {k: acct[k] for k in ("id", "student_id", "system_id", "role", "username")}
@router.delete("/accounts/{account_id}")
async def delete_account(account_id: int):
db.delete_account(account_id)
return {"ok": True}
# ---------- learned(自动沉淀:草稿 -> 采纳 -> 注入) ----------
class LearnedValueRequest(BaseModel):
value: str
@router.get("/learned")
async def list_learned(
knowledge_id: str | None = None,
business_id: str | None = None,
status: str | None = None,
source_task_id: str | None = None,
):
return db.list_learned(knowledge_id, business_id, status, source_task_id)
@router.get("/learned/export")
async def export_learned(knowledge_id: str = "xuegong_demo"):
"""把本校审核过的 learned 导出成 YAML 片段,交付给其他部署时合并进 knowledge/*.yaml。"""
import yaml
rows = db.list_learned(knowledge_id, None, "verified")
grouped: dict[str, dict[str, str]] = {}
for r in rows:
grouped.setdefault(r["business_id"] or "_global", {})[r["key"]] = r["value"]
return {
"knowledge_id": knowledge_id,
"school_id": settings.school_id,
"count": len(rows),
"yaml": yaml.safe_dump(grouped, allow_unicode=True, sort_keys=False),
}
@router.post("/learned/consolidate")
async def consolidate_learned(knowledge_id: str = "xuegong_demo", business_id: str | None = None):
"""按内容归并草稿、累计观察次数、触发自动晋升。business_id 缺省则处理该业务包下所有业务。"""
knowledge = db.get_knowledge(knowledge_id)
if not knowledge:
raise HTTPException(404, "业务包不存在")
targets = [business_id] if business_id is not None else [""] + [b.get("id", "") for b in knowledge.get("businesses") or []]
result = {}
for bid in targets:
result[bid or "_global"] = db.consolidate_learned(knowledge_id, bid)
return result
@router.post("/learned/{entry_id}/verify")
async def verify_learned(entry_id: int):
row = db.set_learned_status(entry_id, "verified")
if not row:
raise HTTPException(404, "条目不存在")
return row
@router.post("/learned/{entry_id}/reject")
async def reject_learned(entry_id: int):
row = db.set_learned_status(entry_id, "rejected")
if not row:
raise HTTPException(404, "条目不存在")
return row
@router.put("/learned/{entry_id}")
async def update_learned(entry_id: int, body: LearnedValueRequest):
if not body.value.strip():
raise HTTPException(400, "value 不能为空")
row = db.update_learned_value(entry_id, body.value)
if not row:
raise HTTPException(404, "条目不存在")
return row
# ---------- 设置 ----------
@router.get("/settings")
async def get_settings():
cfg = get_llm_config()
return {
"llm_provider": cfg["provider"],
"llm_api_key_set": bool(cfg["primary_api_key"]),
"fallback_key_set": bool(cfg["fallback_api_key"]),
"using_fallback": bool(cfg["using_fallback"]),
"llm_model": cfg["model"],
"llm_base_url": cfg["base_url"],
"available_models": cfg["available_models"],
"providers": list(MODELS_BY_PROVIDER.keys()),
"models_by_provider": MODELS_BY_PROVIDER,
}
@router.put("/settings")
async def update_settings(body: SettingsUpdateRequest):
if body.llm_provider is not None:
db.set_setting("llm_provider", body.llm_provider)
models = MODELS_BY_PROVIDER.get(body.llm_provider, [])
if models and not db.get_setting("llm_model"):
db.set_setting("llm_model", models[0])
if body.llm_api_key is not None:
db.set_setting("llm_api_key", body.llm_api_key)
if body.dashscope_api_key is not None:
db.set_setting("dashscope_api_key", body.dashscope_api_key)
if body.llm_model is not None:
db.set_setting("llm_model", body.llm_model)
if body.llm_base_url is not None:
db.set_setting("llm_base_url", body.llm_base_url)
return await get_settings()
# ---------- 调试 ----------
@router.post("/debug/browser")
async def debug_browser(body: DebugBrowserRequest):
try:
return await runner.debug_browser(body.url, body.task)
except RuntimeError as e:
raise HTTPException(400, str(e))