461 lines
15 KiB
Python
461 lines
15 KiB
Python
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))
|