246 lines
8.4 KiB
Python
246 lines
8.4 KiB
Python
"""Plugin management for the local console; credentials never leave this API."""
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import io
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
from typing import Literal
|
|
import uuid
|
|
import zipfile
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.routing import APIRoute
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from artemis.plugins.installer import download, install_archive, install_local, load_catalog
|
|
from artemis.plugins.models import PluginManifest, PluginProfile, PluginSelection
|
|
from artemis.plugins.runtime import PluginRuntime
|
|
from artemis.plugins.store import PluginStore
|
|
|
|
|
|
class PluginRoute(APIRoute):
|
|
def get_route_handler(self):
|
|
handler = super().get_route_handler()
|
|
|
|
async def sanitized(request):
|
|
try:
|
|
return await handler(request)
|
|
except RequestValidationError:
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={
|
|
"detail": "插件配置格式不正确,请检查字段类型和清单;输入内容不会回显。"
|
|
},
|
|
)
|
|
|
|
return sanitized
|
|
|
|
|
|
router = APIRouter(prefix="/api/plugins", tags=["plugins"], route_class=PluginRoute)
|
|
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-installer")
|
|
_jobs: dict[str, dict] = {}
|
|
_lock = Lock()
|
|
|
|
|
|
def get_store() -> PluginStore:
|
|
return PluginStore()
|
|
|
|
|
|
class InstallRequest(BaseModel):
|
|
plugin_id: str | None = None
|
|
version: str | None = None
|
|
local_path: str | None = None
|
|
|
|
|
|
class EnableRequest(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class DiagnoseRequest(BaseModel):
|
|
profile: str = "default"
|
|
|
|
|
|
class CustomRequest(BaseModel):
|
|
manifest: PluginManifest
|
|
skill_content: str = Field(default="", max_length=262144)
|
|
|
|
|
|
class ResourceConfirmation(BaseModel):
|
|
resource: str = Field(min_length=1, max_length=256)
|
|
confirmed_stopped: Literal[True]
|
|
|
|
|
|
def public_error(exc: Exception) -> str:
|
|
return "插件清单或配置格式不正确" if isinstance(exc, ValidationError) else str(exc)
|
|
|
|
|
|
@router.get("")
|
|
def list_plugins():
|
|
store = get_store()
|
|
return {"plugins": store.list(), "blocked_resources": store.blocked_resources()}
|
|
|
|
|
|
@router.post("/resources/confirm-stopped")
|
|
def confirm_resource_stopped(request: ResourceConfirmation):
|
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
|
|
|
if any(
|
|
request.resource in owner.resource_ids
|
|
for owner in DeviceExecutionLock.get_active_owners().values()
|
|
):
|
|
raise HTTPException(409, "此资源仍被任务占用,请先停止相关任务并确认插件动作已停止")
|
|
get_store().confirm_resource_stopped(request.resource)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/catalog")
|
|
def marketplace():
|
|
try:
|
|
return load_catalog().model_dump()
|
|
except Exception:
|
|
raise HTTPException(502, "暂时无法读取插件市场,请稍后重试;已安装插件不受影响") from None
|
|
|
|
|
|
@router.post("/install", status_code=202)
|
|
def install_plugin(request: InstallRequest):
|
|
if bool(request.local_path) == bool(request.plugin_id):
|
|
raise HTTPException(400, "请选择一个市场插件或本地安装包")
|
|
with _lock:
|
|
if any(j["status"] in {"queued", "running"} for j in _jobs.values()):
|
|
raise HTTPException(409, "已有插件正在安装,请等待安装结束")
|
|
for key in list(_jobs)[:-30]:
|
|
del _jobs[key]
|
|
job_id = str(uuid.uuid4())
|
|
_jobs[job_id] = {"id": job_id, "status": "queued", "stage": "等待安装", "progress": 0}
|
|
|
|
def progress(stage, percent):
|
|
with _lock:
|
|
_jobs[job_id].update(status="running", stage=stage, progress=percent)
|
|
|
|
def run():
|
|
try:
|
|
store = get_store()
|
|
if request.local_path:
|
|
installed = install_local(store, Path(request.local_path), progress)
|
|
else:
|
|
progress("获取市场索引", 0)
|
|
candidates = [
|
|
p
|
|
for p in load_catalog().plugins
|
|
if p.manifest.id == request.plugin_id
|
|
and (request.version is None or p.manifest.version == request.version)
|
|
]
|
|
from packaging.version import Version
|
|
|
|
compatible = []
|
|
for entry in candidates:
|
|
try:
|
|
entry.manifest.check_compatible()
|
|
compatible.append(entry)
|
|
except ValueError:
|
|
continue
|
|
if not compatible:
|
|
raise ValueError("没有已验证且兼容的插件版本")
|
|
entry = max(compatible, key=lambda p: Version(p.manifest.version))
|
|
installed = install_archive(
|
|
store, download(entry.archive_url, progress), entry=entry, progress=progress
|
|
)
|
|
with _lock:
|
|
_jobs[job_id].update(
|
|
status="completed",
|
|
stage="安装完成,请配置并启用",
|
|
progress=100,
|
|
plugin_id=installed["manifest"]["id"],
|
|
)
|
|
except Exception as exc:
|
|
with _lock:
|
|
# HTTP exceptions may contain signed URLs; expose only our validation messages.
|
|
message = (
|
|
public_error(exc)
|
|
if isinstance(exc, (ValueError, FileNotFoundError))
|
|
else "下载或安装失败,请检查网络及安装包"
|
|
)
|
|
_jobs[job_id].update(status="failed", stage=message)
|
|
|
|
_executor.submit(run)
|
|
return {"job_id": job_id}
|
|
|
|
|
|
@router.get("/install/{job_id}")
|
|
def install_progress(job_id: str):
|
|
with _lock:
|
|
if job_id not in _jobs:
|
|
raise HTTPException(404, "安装记录不存在,服务可能已重启;请刷新插件列表")
|
|
return dict(_jobs[job_id])
|
|
|
|
|
|
@router.post("/custom")
|
|
def register_custom(request: CustomRequest):
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w") as archive:
|
|
archive.writestr("plugin.json", request.manifest.model_dump_json())
|
|
if request.manifest.skill:
|
|
archive.writestr(request.manifest.skill, request.skill_content)
|
|
try:
|
|
return install_archive(get_store(), buffer.getvalue())
|
|
except (ValueError, OSError) as exc:
|
|
raise HTTPException(400, public_error(exc)) from None
|
|
|
|
|
|
@router.put("/{plugin_id}/profiles")
|
|
def save_profile(plugin_id: str, profile: PluginProfile):
|
|
try:
|
|
get_store().save_profile(plugin_id, profile)
|
|
return get_store().get(plugin_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, public_error(exc)) from None
|
|
|
|
|
|
@router.put("/{plugin_id}/enabled")
|
|
def enable_plugin(plugin_id: str, request: EnableRequest):
|
|
try:
|
|
get_store().set_enabled(plugin_id, request.enabled)
|
|
return get_store().get(plugin_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, public_error(exc)) from None
|
|
|
|
|
|
@router.post("/{plugin_id}/diagnose")
|
|
async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
|
|
runtime = None
|
|
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
|
|
try:
|
|
store = get_store()
|
|
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"])
|
|
runtime = PluginRuntime(
|
|
[PluginSelection(id=plugin_id, profile=request.profile)],
|
|
store=store,
|
|
target=manifest.targets[0],
|
|
)
|
|
loaded = await runtime.load(plugin_id)
|
|
result = {
|
|
"ok": loaded.status == "success",
|
|
"tools": loaded.structured_content or {},
|
|
"message": "插件连接正常",
|
|
}
|
|
except Exception:
|
|
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
|
|
finally:
|
|
if runtime:
|
|
try:
|
|
await runtime.aclose()
|
|
except RuntimeError:
|
|
result = {"ok": False, "message": "插件未确认停止,请检查受保护的资源"}
|
|
return result
|
|
|
|
|
|
@router.delete("/{plugin_id}")
|
|
def uninstall_plugin(plugin_id: str):
|
|
try:
|
|
get_store().uninstall(plugin_id)
|
|
return {"ok": True}
|
|
except (ValueError, OSError) as exc:
|
|
raise HTTPException(409, public_error(exc)) from None
|