616 lines
27 KiB
Python
616 lines
27 KiB
Python
"""当前桌面端 API;不挂载学校填报、账号准入和审批回查接口。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
import time
|
||
from typing import Any, Literal
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from fastapi.responses import FileResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
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.progress_doc import progress_doc_path, task_doc_dir
|
||
from backend.prospecting.contracts import KNOWLEDGE_ID
|
||
from backend.prospecting.orchestrator import ProspectingOrchestrator, collection_is_incomplete
|
||
from backend.prospecting import workflows
|
||
|
||
router = APIRouter()
|
||
orchestrator = ProspectingOrchestrator()
|
||
STARTED_AT = time.time()
|
||
|
||
|
||
class StartRequest(BaseModel):
|
||
knowledge_id: Literal["xiaohongshu_prospecting"] = KNOWLEDGE_ID
|
||
message: str = Field(default="", max_length=2000)
|
||
inputs: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class InputsRequest(BaseModel):
|
||
inputs: dict[str, Any] | None = None
|
||
message: str = Field(default="", max_length=2000)
|
||
|
||
|
||
class AnswerRequest(BaseModel):
|
||
answer: str = Field(default="继续", max_length=2000)
|
||
|
||
|
||
class MessageRequest(BaseModel):
|
||
message: str = Field(min_length=1, max_length=2000)
|
||
|
||
|
||
class ClarificationRequest(BaseModel):
|
||
card_id: str = Field(min_length=1, max_length=200)
|
||
goal_version: int = Field(ge=1)
|
||
answers: dict[str, str]
|
||
|
||
|
||
class AlternativeRequest(BaseModel):
|
||
goal_version: int = Field(ge=1)
|
||
result_version: str = Field(pattern=r"^[a-f0-9]{16}$")
|
||
force: bool = False
|
||
|
||
|
||
class FeedbackRequest(BaseModel):
|
||
rating: Literal["up", "down"]
|
||
reason: str = Field(default="", max_length=1000)
|
||
|
||
|
||
class CorrectionRequest(BaseModel):
|
||
text: str = Field(min_length=1, max_length=2000)
|
||
|
||
|
||
class BusinessOutcomeRequest(BaseModel):
|
||
source_id: str = Field(min_length=1, max_length=128)
|
||
outcome: Literal["replied", "need_confirmed", "cooperated", "no_reply", "not_relevant"]
|
||
note: str = Field(default="", max_length=1000)
|
||
|
||
|
||
class MethodFeedbackRequest(BaseModel):
|
||
task_id: str | None = Field(default=None, max_length=128)
|
||
text: str = Field(min_length=1, max_length=1000)
|
||
|
||
|
||
class OutreachRequest(BaseModel):
|
||
action: Literal["follow", "message"]
|
||
source_ids: list[str] = Field(min_length=1)
|
||
content: str = Field(default="", max_length=2000)
|
||
account_profile_url: str = Field(min_length=1, max_length=8192)
|
||
|
||
|
||
class AcknowledgeOutreachRequest(BaseModel):
|
||
account_profile_url: str = Field(min_length=1, max_length=8192)
|
||
confirmation: Literal[True]
|
||
|
||
|
||
class SettingsRequest(BaseModel):
|
||
llm_provider: Literal["deepseek", "qwen"] | None = None
|
||
llm_api_key: str | None = Field(default=None, max_length=1000)
|
||
dashscope_api_key: str | None = Field(default=None, max_length=1000)
|
||
llm_model: str | None = Field(default=None, max_length=100)
|
||
llm_base_url: str | None = Field(default=None, max_length=300)
|
||
|
||
|
||
def task_for(task_id: str) -> dict:
|
||
try:
|
||
return orchestrator.task(task_id)
|
||
except ValueError as error:
|
||
raise HTTPException(404, str(error)) from error
|
||
|
||
|
||
def observed_task(task: dict) -> dict:
|
||
"""Use the same saved evidence for UI versions and their later actions."""
|
||
artifacts = orchestrator._recover_evidence(task["id"], task.get("artifacts") or {})
|
||
observed = {**task, "artifacts": artifacts}
|
||
# Recover coverage only from recorded, actually observed URLs. Older tasks
|
||
# predate search_result_ai recognition. This projection never writes the DB
|
||
# or the evidence file and never equates search arrival with reading.
|
||
from backend.browser.tools import observed_search_keyword
|
||
history = artifacts.get("browser_history") or {}
|
||
coverage = dict(artifacts.get("coverage") or {})
|
||
keywords = list(coverage.get("search_keywords_observed") or [])
|
||
newly_observed = False
|
||
for url in history.get("urls") or []:
|
||
keyword = observed_search_keyword(url)
|
||
if keyword and keyword not in keywords:
|
||
keywords.append(keyword)
|
||
newly_observed = True
|
||
if newly_observed:
|
||
pending = [word for word in task.get("inputs", {}).get("keywords") or [] if word not in keywords]
|
||
coverage.update(search_keywords_observed=keywords, search_keywords_not_verified=pending)
|
||
artifacts["coverage"] = coverage
|
||
artifacts["coverage_note"] = "仅代表已保存的实际文字及回复;未展开、未加载内容与媒体信息仍未知。实际观察到搜索页的关键词:" + "、".join(keywords) + "。"
|
||
if pending:
|
||
artifacts["coverage_note"] += "尚未核对检索页访问:" + "、".join(pending) + "。"
|
||
reason = artifacts.get("stop_reason")
|
||
if isinstance(reason, dict):
|
||
reason = reason.get("code")
|
||
if task.get("status") == "completed" and reason == "review_completed" and collection_is_incomplete(observed):
|
||
detail = "已保存资料的复核已完成;原网页采集仍有未完成范围,尚未完成整个任务。"
|
||
artifacts.update(stop_reason="agent_reported_failure", stop_detail=detail,
|
||
summary="阶段结果:" + str(artifacts.get("summary") or detail))
|
||
observed.update(status="blocked", current_step="stage_results", pause_reason=detail)
|
||
return observed
|
||
|
||
|
||
def decorate(task: dict) -> dict:
|
||
from backend.prospecting import cards
|
||
# A running browser checkpoints observations before its final return. This
|
||
# read-only projection shows saved evidence without modifying the task row.
|
||
task = observed_task(task)
|
||
artifacts = task["artifacts"]
|
||
ui = cards.project(task)
|
||
doc = progress_doc_path(task["id"])
|
||
steps = task_doc_dir(task["id"]) / "steps"
|
||
shots = sorted(steps.glob("step_*.png")) if steps.exists() else []
|
||
artifacts = dict(artifacts)
|
||
corrections = {row["source_id"]: row["text"] for row in artifacts.get("corrections") or []}
|
||
artifacts["leads"] = [{**lead, **({"correction": corrections[lead.get("source_id", lead.get("id"))]}
|
||
if lead.get("source_id", lead.get("id")) in corrections else {})}
|
||
for lead in artifacts.get("leads") or []]
|
||
artifacts["alternatives"] = workflows.alternatives(task)
|
||
artifacts["exports"] = ui["delivery"]["exports"]
|
||
return {**task, "ui": ui, "artifacts": artifacts, "business_name": "小红书拓客", "goal": task["inputs"].get("goal", ""),
|
||
"reply": (task.get("artifacts") or {}).get("summary"),
|
||
"needs_input": (task.get("artifacts") or {}).get("plan", {}).get("missing", []),
|
||
"alternatives": workflows.alternatives(task), "progress_doc_path": str(doc) if doc.exists() else None,
|
||
"latest_screenshot": str(shots[-1]) if shots else None,
|
||
"live_session": runner.has_live_session(task["id"]), "takeover": runner.is_taken_over(task["id"])}
|
||
|
||
|
||
@router.get("/health")
|
||
async def health():
|
||
return {"status": "ok", "pid": os.getpid(), "product": "橘子拓客"}
|
||
|
||
|
||
@router.get("/meta")
|
||
async def meta():
|
||
cfg = get_llm_config()
|
||
from backend.browser import llm_keys
|
||
return {"pid": os.getpid(), "version": "0.4.0", "product": "橘子拓客", "platform": "xiaohongshu",
|
||
"operator_mode": True, "embedded": runner.embedded_mode(), "school_id": settings.school_id,
|
||
"workspace_id": settings.school_id, "tracking": {"enabled": False, "poll_seconds": 0},
|
||
"live_sessions": runner.live_session_ids(), "uptime_s": round(time.time() - STARTED_AT),
|
||
"llm": {"provider": cfg["provider"], "model": cfg["model"], "api_key_set": bool(cfg["api_key"]),
|
||
**llm_keys.status(cfg["provider"], cfg["primary_api_key"])},
|
||
"features": {"prospecting": True, "public_research": True, "conversation": True,
|
||
"threaded_replies": True, "csv_export": True, "feedback": True,
|
||
"outreach": True, "outreach_requires_authorization": True, "outreach_live_verified": False}}
|
||
|
||
|
||
@router.get("/knowledge")
|
||
async def knowledge():
|
||
item = db.get_knowledge(KNOWLEDGE_ID)
|
||
return [item] if item else []
|
||
|
||
|
||
@router.get("/knowledge/methods")
|
||
async def knowledge_methods(task_id: str | None = None):
|
||
from backend.prospecting import knowledge as ledger
|
||
from backend.prospecting.verification import context_for
|
||
task = task_for(task_id) if task_id else None
|
||
context = context_for(task) if task else {"platform": "xiaohongshu"}
|
||
available = ledger.list_methods(context, include_legacy=False)
|
||
context["available_method_ids"] = [row["id"] for row in available if row.get("kind") == "operation"
|
||
and row.get("applicability") and row.get("status") != "paused"]
|
||
return {"methods": ledger.list_methods(context),
|
||
"selected_ids": (task.get("artifacts") or {}).get("knowledge_selected_ids", []) if task else [],
|
||
"policy": {"version": ledger.RULE_VERSION, "minimum_tasks": 3, "minimum_objects": 3,
|
||
"ranking_is_probability": False}}
|
||
|
||
|
||
@router.post("/knowledge/methods/{method_id}/feedback")
|
||
async def method_feedback(method_id: str, body: MethodFeedbackRequest):
|
||
from backend.prospecting import knowledge as ledger
|
||
import uuid
|
||
if not any(row["id"] == method_id for row in ledger.list_methods()):
|
||
raise HTTPException(404, "方法不属于当前工作区")
|
||
if not body.text.strip():
|
||
raise HTTPException(400, "请输入需要纠正或不适用的情况")
|
||
task = task_for(body.task_id) if body.task_id else None
|
||
event = ledger.record_business_feedback(body.task_id or "knowledge_feedback",
|
||
workflows.result_version(task) if task else "method:" + method_id,
|
||
{"kind": "method_feedback", "method_id": method_id, "text": body.text.strip(), "technical_verdict": "unknown"},
|
||
event_key=uuid.uuid4().hex, goal_version=task["inputs"].get("_goal_version", 1) if task else None)
|
||
return {"ok": True, "id": event["id"], "message": "已记录方法反馈,系统将结合后续证据判断;本次目标与技术成功计数保持不变。"}
|
||
|
||
|
||
def save_business_feedback(task: dict, feedback: dict, event_key: str) -> None:
|
||
from backend.prospecting.knowledge import record_business_feedback
|
||
record_business_feedback(task["id"], workflows.result_version(task), feedback,
|
||
goal_version=task["inputs"].get("_goal_version", 1), event_key=event_key, source="user")
|
||
|
||
|
||
def record_export(task_id: str, entry: dict) -> None:
|
||
from backend.prospecting.verification import export_fact
|
||
current = task_for(task_id)
|
||
artifacts = export_fact(current, entry)
|
||
db.update_task(task_id, artifacts=artifacts)
|
||
# A learning storage issue must not turn an already generated file into
|
||
# an export failure or cause its creation to be repeated.
|
||
try:
|
||
orchestrator._learn(task_id, artifacts)
|
||
except Exception:
|
||
db.update_task(task_id, artifacts={**artifacts, "learning_error": "文件已交付,方法验证记录暂未保存,可稍后重试记录。"})
|
||
|
||
|
||
@router.get("/tasks")
|
||
async def tasks(limit: int = Query(default=30, ge=1, le=100)):
|
||
return [decorate(task) for task in db.list_tasks(limit) if task["knowledge_id"] == KNOWLEDGE_ID]
|
||
|
||
|
||
@router.post("/tasks/start")
|
||
async def start(body: StartRequest):
|
||
try:
|
||
return decorate(await orchestrator.start(body.message, body.inputs))
|
||
except ValueError as error:
|
||
raise HTTPException(400, str(error)) from error
|
||
|
||
|
||
@router.get("/tasks/{task_id}")
|
||
async def get_task(task_id: str):
|
||
return decorate(task_for(task_id))
|
||
|
||
|
||
@router.get("/tasks/{task_id}/progress-doc")
|
||
async def get_progress(task_id: str):
|
||
task_for(task_id)
|
||
doc = progress_doc_path(task_id)
|
||
return {"content": doc.read_text(encoding="utf-8") if doc.exists() else ""}
|
||
|
||
|
||
@router.get("/tasks/{task_id}/validation-proof")
|
||
async def validation_proof(task_id: str, reference: str = Query(min_length=1, max_length=512),
|
||
event_id: str | None = Query(default=None, min_length=1, max_length=80)):
|
||
task = observed_task(task_for(task_id))
|
||
facts = task.get("artifacts", {}).get("operation_evidence") or []
|
||
if event_id:
|
||
from backend.prospecting import knowledge as ledger
|
||
summary = ledger.task_summary(task_id)
|
||
event = next((row for row in summary["events"] if row.get("id") == event_id and row.get("task_id") == task_id), None)
|
||
refs = (event or {}).get("evidence_refs") or {}
|
||
pre_refs = refs.get("pre")
|
||
pre_refs = pre_refs if isinstance(pre_refs, list) else [pre_refs]
|
||
post_refs = refs.get("post")
|
||
post_refs = post_refs if isinstance(post_refs, list) else [post_refs]
|
||
method = next((row for row in summary["methods"] if event and row.get("id") == event.get("method_id")), None)
|
||
if not event or not method or reference not in pre_refs or len(post_refs) != 1 or not post_refs[0]:
|
||
raise HTTPException(404, "该任务没有对应的验证事件及完整证据引用")
|
||
|
||
def snapshot(side: str, ref: str) -> dict | None:
|
||
for fact in facts:
|
||
if not isinstance(fact, dict) or (fact.get("evidence_refs") or {}).get(side) != ref:
|
||
continue
|
||
value = (fact.get("details") or {}).get(side + "_snapshot")
|
||
if isinstance(value, dict) and value:
|
||
return value
|
||
return None
|
||
|
||
pre, post = snapshot("pre", reference), snapshot("post", post_refs[0])
|
||
if pre is None or post is None:
|
||
raise HTTPException(404, "该验证事件缺少已持久化的动作前或动作后快照")
|
||
return {"id": event["id"], "event_id": event["id"], "capability": method["capability"],
|
||
"outcome": event["outcome"], "verifier": event.get("metadata", {}).get("verifier"),
|
||
"observed_at": event.get("created_at"), "pre": pre, "post": post}
|
||
for fact in facts:
|
||
if reference not in (fact.get("evidence_refs") or {}).values():
|
||
continue
|
||
details = fact.get("details") or {}
|
||
return {"id": fact["id"], "capability": fact["capability"], "outcome": fact["outcome"],
|
||
"verifier": fact["verifier"], "observed_at": fact.get("observed_at"),
|
||
"pre": details.get("pre_snapshot", {}), "post": details.get("post_snapshot", {})}
|
||
raise HTTPException(404, "该任务没有此项持久化动作依据,不能把引用当作验收成功")
|
||
|
||
|
||
@router.post("/tasks/{task_id}/message")
|
||
async def message(task_id: str, body: MessageRequest):
|
||
task_for(task_id)
|
||
try:
|
||
return decorate(await orchestrator.message(task_id, body.message))
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/clarification")
|
||
async def clarification(task_id: str, body: ClarificationRequest):
|
||
task_for(task_id)
|
||
try:
|
||
return decorate(await orchestrator.clarify(task_id, body.card_id, body.goal_version, body.answers))
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/feedback")
|
||
async def feedback(task_id: str, body: FeedbackRequest):
|
||
event = workflows.record_feedback(task_for(task_id), body.rating, body.reason)
|
||
save_business_feedback(task_for(task_id), {"kind": "satisfaction", "rating": body.rating, "reason": body.reason}, event["id"])
|
||
return decorate(task_for(task_id))
|
||
|
||
|
||
@router.post("/tasks/{task_id}/leads/{source_id}/correction")
|
||
async def correction(task_id: str, source_id: str, body: CorrectionRequest):
|
||
try:
|
||
workflows.record_correction(task_for(task_id), source_id, body.text)
|
||
return decorate(task_for(task_id))
|
||
except ValueError as error:
|
||
raise HTTPException(400, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/export")
|
||
async def export(task_id: str):
|
||
task = observed_task(task_for(task_id))
|
||
if task_id in orchestrator.jobs or task.get("status") == "running":
|
||
raise HTTPException(409, "请先等待当前阶段结束或停止,再导出当前名单")
|
||
try:
|
||
entry = workflows.export_leads(task)
|
||
record_export(task_id, entry)
|
||
return entry
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.get("/tasks/{task_id}/exports/{export_id}")
|
||
async def exported_file(task_id: str, export_id: str):
|
||
task = task_for(task_id)
|
||
try:
|
||
path, entry = workflows.export_path(task, export_id)
|
||
return FileResponse(path, media_type="text/csv", filename=entry["filename"])
|
||
except ValueError as error:
|
||
raise HTTPException(404, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/alternatives/{route_id}")
|
||
async def alternative(task_id: str, route_id: Literal["review", "export"], body: AlternativeRequest):
|
||
from backend.prospecting.cards import goal_version
|
||
task = observed_task(task_for(task_id))
|
||
if task_id in orchestrator.jobs or task.get("status") == "running":
|
||
raise HTTPException(409, "请先等待当前阶段结束或停止,再选择后续路线")
|
||
if body.goal_version != goal_version(task) or body.result_version != workflows.result_version(task):
|
||
raise HTTPException(409, "任务或结果已更新,请查看最新路线")
|
||
route = next((row for row in workflows.alternatives(task) if row["id"] == route_id), None)
|
||
if not route or not route["enabled"]:
|
||
raise HTTPException(409, "这条路线当前不可用,请查看已有结果")
|
||
try:
|
||
if route_id == "export":
|
||
entry = workflows.export_leads(task)
|
||
record_export(task_id, entry)
|
||
return decorate(task_for(task_id))
|
||
return decorate(await orchestrator.review_saved(task_id, force=body.force))
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/business-outcome")
|
||
async def business_outcome(task_id: str, body: BusinessOutcomeRequest):
|
||
task = task_for(task_id)
|
||
if body.source_id not in {row.get("source_id") for row in (task.get("artifacts") or {}).get("sources") or []}:
|
||
raise HTTPException(404, "反馈来源不属于本任务")
|
||
from backend.prospecting.workflows import now
|
||
import uuid
|
||
event = {"id": uuid.uuid4().hex, "kind": "business_outcome", "source_id": body.source_id,
|
||
"outcome": body.outcome, "note": body.note, "at": now(), "source": "user", "attribution": "unproven"}
|
||
artifacts = dict(task.get("artifacts") or {})
|
||
artifacts["business_outcomes"] = [*(artifacts.get("business_outcomes") or []), event]
|
||
db.update_task(task_id, artifacts=artifacts)
|
||
save_business_feedback(task, event, event["id"])
|
||
return decorate(task_for(task_id))
|
||
|
||
|
||
@router.post("/tasks/{task_id}/outreach/authorize")
|
||
async def authorize_outreach(task_id: str, body: OutreachRequest):
|
||
from backend.prospecting.outreach import authorize_outreach as authorize
|
||
task = task_for(task_id)
|
||
if task_id in orchestrator.jobs or task["status"] == "running":
|
||
raise HTTPException(409, "请先等待当前核对阶段结束或停止,再执行具体跟进计划")
|
||
try:
|
||
authorize(task, body.action, body.source_ids, body.content, body.account_profile_url)
|
||
return decorate(await orchestrator.resume(task_id))
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/outreach/recheck")
|
||
async def recheck_outreach(task_id: str):
|
||
task = task_for(task_id)
|
||
if task_id in orchestrator.jobs or task["status"] == "running":
|
||
raise HTTPException(409, "当前页面仍在执行,请先等待或停止")
|
||
if task["inputs"].get("intent") not in {"follow", "message"}:
|
||
raise HTTPException(409, "当前任务尚无跟进计划")
|
||
artifacts = dict(task.get("artifacts") or {})
|
||
artifacts["outreach_grants"] = [{**row, "status": "recheck_requested"} if row.get("status") == "authorized" else row
|
||
for row in artifacts.get("outreach_grants") or []]
|
||
artifacts.pop("outreach_account", None)
|
||
db.update_task(task_id, artifacts=artifacts)
|
||
return decorate(await orchestrator.resume(task_id))
|
||
|
||
|
||
@router.post("/tasks/{task_id}/outreach/acknowledge")
|
||
async def acknowledge_outreach(task_id: str, body: AcknowledgeOutreachRequest):
|
||
from backend.prospecting.outreach import acknowledge_account_pause
|
||
task = task_for(task_id)
|
||
if task_id in orchestrator.jobs or task["status"] == "running":
|
||
raise HTTPException(409, "请先等待当前页面核对结束")
|
||
try:
|
||
acknowledge_account_pause(task, body.account_profile_url, body.confirmation)
|
||
return decorate(task_for(task_id))
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.get("/workflows")
|
||
async def list_workflows():
|
||
return workflows.STAGE_TEMPLATES
|
||
|
||
|
||
@router.get("/tasks/{task_id}/screenshot")
|
||
async def screenshot(task_id: str):
|
||
task = decorate(task_for(task_id))
|
||
if not task["latest_screenshot"]:
|
||
raise HTTPException(404, "尚无截图")
|
||
return FileResponse(task["latest_screenshot"], media_type="image/png")
|
||
|
||
|
||
@router.post("/tasks/{task_id}/inputs")
|
||
async def submit_inputs(task_id: str, body: InputsRequest):
|
||
task_for(task_id)
|
||
try:
|
||
return decorate(await orchestrator.inputs(task_id, body.inputs, body.message))
|
||
except ValueError as error:
|
||
raise HTTPException(400, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/human")
|
||
async def human(task_id: str, body: AnswerRequest):
|
||
task_for(task_id)
|
||
try:
|
||
return decorate(await orchestrator.resume(task_id, body.answer))
|
||
except ValueError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.post("/tasks/{task_id}/resume")
|
||
async def resume(task_id: str):
|
||
return await human(task_id, AnswerRequest())
|
||
|
||
|
||
@router.post("/tasks/{task_id}/cancel")
|
||
async def cancel(task_id: str):
|
||
task_for(task_id)
|
||
return decorate(await orchestrator.cancel(task_id))
|
||
|
||
|
||
@router.post("/tasks/{task_id}/takeover")
|
||
async def takeover(task_id: str):
|
||
task_for(task_id)
|
||
try:
|
||
await runner.takeover(task_id)
|
||
await runner.set_browser_window(task_id, "show")
|
||
except RuntimeError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
return {"ok": True, "takeover": True}
|
||
|
||
|
||
@router.post("/tasks/{task_id}/takeover-done")
|
||
async def takeover_done(task_id: str):
|
||
task_for(task_id)
|
||
await runner.takeover_done(task_id)
|
||
return {"ok": True, "takeover": False}
|
||
|
||
|
||
@router.post("/tasks/{task_id}/browser/{action}")
|
||
async def browser_window(task_id: str, action: Literal["show", "hide"]):
|
||
task_for(task_id)
|
||
try:
|
||
return await runner.set_browser_window(task_id, action)
|
||
except RuntimeError as error:
|
||
raise HTTPException(409, str(error)) from error
|
||
|
||
|
||
@router.get("/learned")
|
||
async def learned(source_task_id: str | None = None, status: str | None = None):
|
||
if source_task_id:
|
||
task_for(source_task_id)
|
||
if status is not None and status not in db.LEARNED_STATUSES:
|
||
raise HTTPException(400, "无效经验状态")
|
||
return db.list_learned(KNOWLEDGE_ID, "prospecting", status, source_task_id)
|
||
|
||
|
||
@router.post("/learned/{entry_id}/{action}")
|
||
async def review_learned(entry_id: int, action: Literal["verify", "reject"]):
|
||
entry = db.get_learned(entry_id)
|
||
if not entry or entry["knowledge_id"] != KNOWLEDGE_ID:
|
||
raise HTTPException(404, "经验不存在")
|
||
return db.set_learned_status(entry_id, "verified" if action == "verify" else "rejected")
|
||
|
||
|
||
@router.get("/settings")
|
||
async def get_settings():
|
||
cfg = get_llm_config()
|
||
return {"llm_provider": cfg["provider"], "llm_api_key_set": bool(cfg["api_key"]),
|
||
"fallback_key_set": bool(cfg["fallback_api_key"]), "using_fallback": cfg["using_fallback"],
|
||
"llm_model": cfg["model"], "llm_base_url": cfg["base_url"],
|
||
"providers": list(MODELS_BY_PROVIDER), "models_by_provider": MODELS_BY_PROVIDER}
|
||
|
||
|
||
@router.put("/settings")
|
||
async def save_settings(body: SettingsRequest):
|
||
changes = body.model_dump(exclude_none=True)
|
||
if body.llm_base_url and not body.llm_base_url.startswith("https://"):
|
||
raise HTTPException(400, "模型接口须为 HTTPS 地址")
|
||
provider = body.llm_provider or get_llm_config()["provider"]
|
||
if body.llm_model and body.llm_model not in MODELS_BY_PROVIDER[provider]:
|
||
raise HTTPException(400, "请从当前提供商支持的模型中选择")
|
||
for key, value in changes.items():
|
||
if value.strip():
|
||
db.set_setting(key, value.strip())
|
||
return await get_settings()
|
||
|
||
|
||
@router.post("/settings/test")
|
||
async def test_model():
|
||
cfg = get_llm_config()
|
||
if not cfg["api_key"]:
|
||
raise HTTPException(400, "尚未配置 DeepSeek Key,请填写并保存")
|
||
payload = {"model": cfg["model"], "messages": [{"role": "user", "content": "只回复:连接成功"}], "max_tokens": 32}
|
||
if cfg["provider"] == "deepseek":
|
||
payload["thinking"] = {"type": "disabled"}
|
||
try:
|
||
async with httpx.AsyncClient(timeout=30) as client:
|
||
response = await client.post(cfg["base_url"].rstrip("/") + "/chat/completions", json=payload,
|
||
headers={"Authorization": "Bearer " + cfg["api_key"]})
|
||
if response.status_code >= 400:
|
||
raise HTTPException(400, {401: "Key 无效", 402: "DeepSeek 余额不足", 429: "模型接口限流"}.get(response.status_code, f"模型接口 HTTP {response.status_code}"))
|
||
body = response.json()
|
||
if not (body.get("choices") or [{}])[0].get("message", {}).get("content"):
|
||
raise HTTPException(502, "模型接口未返回有效回复,连接尚未验证")
|
||
return {"ok": True, "model": body.get("model", cfg["model"]), "message": "连接测试通过"}
|
||
except httpx.HTTPError as error:
|
||
raise HTTPException(502, f"无法连接模型服务({type(error).__name__})") from error
|
||
|
||
|
||
class SearchRequest(BaseModel):
|
||
query: str = Field(min_length=1, max_length=128)
|
||
count: int = Field(default=3, ge=1, le=10)
|
||
|
||
|
||
class FetchRequest(BaseModel):
|
||
url: str = Field(min_length=1, max_length=8192)
|
||
|
||
|
||
@router.post("/research/search")
|
||
async def research_search(body: SearchRequest):
|
||
from backend.research import ResearchError, search_public
|
||
try:
|
||
return await search_public(body.query, body.count)
|
||
except ResearchError as error:
|
||
raise HTTPException(error.status_code, str(error)) from error
|
||
except ValueError as error:
|
||
raise HTTPException(400, str(error)) from error
|
||
|
||
|
||
@router.post("/research/fetch")
|
||
async def research_fetch(body: FetchRequest):
|
||
from backend.research import ResearchError, fetch_public
|
||
try:
|
||
return await fetch_public(body.url)
|
||
except ResearchError as error:
|
||
raise HTTPException(error.status_code, str(error)) from error
|
||
except ValueError as error:
|
||
raise HTTPException(400, str(error)) from error
|
||
|
||
|
||
@router.post("/shutdown")
|
||
async def shutdown():
|
||
async def stop():
|
||
await asyncio.sleep(0.3)
|
||
await runner.close_all_sessions()
|
||
os._exit(0)
|
||
asyncio.create_task(stop())
|
||
return {"ok": True, "pid": os.getpid()}
|