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))