存档
This commit is contained in:
+15
-1
@@ -4,8 +4,22 @@ data/*
|
||||
!data/plugin/
|
||||
data/plugin/skill/
|
||||
|
||||
# 插件运行时 SQLite / 状态
|
||||
data/plugin/*/_data/
|
||||
|
||||
tmp/
|
||||
.env
|
||||
|
||||
.idea/
|
||||
.venv/
|
||||
.venv/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
data/plugin/*/frontend/dist/
|
||||
data/plugin/*/frontend/node_modules/
|
||||
+15
-1
@@ -2,12 +2,24 @@
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Install dependencies and build the static assets
|
||||
# 主前端
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# 重型插件前端:每个插件独立 vite lib build,输出 dist/plugin-element.js + wc-manifest.json
|
||||
# 这一段通过 sh 循环:复制源码 → npm install → build。任何一个插件失败都会让镜像构建失败,
|
||||
# 用 || true 兜底过于宽松——这里选择硬失败,便于第一时间发现 build 问题。
|
||||
COPY data/plugin /app/data/plugin
|
||||
RUN set -e; \
|
||||
for d in /app/data/plugin/*/frontend; do \
|
||||
if [ -f "$d/package.json" ]; then \
|
||||
echo "==> Building plugin frontend: $d"; \
|
||||
cd "$d" && npm install && npm run build; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Stage 2: Build the Python backend and serve
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
@@ -36,6 +48,8 @@ COPY . .
|
||||
|
||||
# Copy the built frontend static assets from Stage 1
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
||||
# 重型插件前端 build 产物(让 /plugin-ui/<name>/ 静态挂载有内容可挂)
|
||||
COPY --from=frontend-builder /app/data/plugin /app/data/plugin
|
||||
|
||||
# Expose FastAPI and Ray Dashboard ports
|
||||
EXPOSE 8000 8265
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add plugin_owned column to base_individual
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"base_individual",
|
||||
sa.Column("plugin_owned", sa.String(64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_base_individual_plugin_owned",
|
||||
"base_individual",
|
||||
["plugin_owned"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_base_individual_plugin_owned", table_name="base_individual")
|
||||
op.drop_column("base_individual", "plugin_owned")
|
||||
@@ -0,0 +1 @@
|
||||
"""data_analytics 重型插件包。"""
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "analyst",
|
||||
"role": "数据分析师",
|
||||
"system_prompt": "你是一位严谨、克制的数据分析师。任务进来后:1) 先用 s3_list_objects/s3_peek 看几行了解结构;2) 决定用 python_executor(小数据,单机 pandas)或 ray_submit(大数据,分布式);3) 执行分析、得出明确结论,必要时给出图表链接或样例数据。注意:你只能读取 S3,**不能写入**。如果用户让你上传/删除/修改对象,请明确告知做不到。",
|
||||
"tools": ["s3_list_objects", "s3_peek", "s3_get_object", "ray_submit", "python_executor"],
|
||||
"skills": [],
|
||||
"peers": []
|
||||
}
|
||||
],
|
||||
"orchestration": {
|
||||
"type": "react",
|
||||
"entry": "analyst"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""data_analytics 插件 API:凭证 CRUD + 分析任务提交/查询/事件流。
|
||||
|
||||
挂载后路径前缀为 /api/v1/plugin/data_analytics/...,跟核心 API 完全独立。
|
||||
所有数据库读写都走 organization actor 的代理方法(确保分布式模式下不跨 actor
|
||||
共享 SQLAlchemy session)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
router = APIRouter(tags=["data_analytics"])
|
||||
|
||||
|
||||
# ─── Schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CredentialCreate(BaseModel):
|
||||
display_name: str = Field(..., max_length=100)
|
||||
endpoint_url: Optional[str] = None
|
||||
region: str = "us-east-1"
|
||||
access_key: str = Field(..., min_length=1)
|
||||
secret_key: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
cred_id: str
|
||||
description: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_org():
|
||||
try:
|
||||
return ray_actor_hook("org_data_analytics").org_data_analytics
|
||||
except Exception as e:
|
||||
raise HTTPException(503, f"data_analytics 插件未就绪:{e}")
|
||||
|
||||
|
||||
# ─── Credentials ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/credentials")
|
||||
async def list_credentials(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
rows = await org.cred_list.remote(token_data.username)
|
||||
return {"credentials": rows}
|
||||
|
||||
|
||||
@router.post("/credentials")
|
||||
async def create_credential(
|
||||
payload: CredentialCreate,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
row = await org.cred_create.remote(
|
||||
user_id=token_data.username,
|
||||
display_name=payload.display_name,
|
||||
access_key=payload.access_key,
|
||||
secret_key=payload.secret_key,
|
||||
endpoint_url=payload.endpoint_url,
|
||||
region=payload.region,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/credentials/{cred_id}")
|
||||
async def delete_credential(
|
||||
cred_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
ok = await org.cred_delete.remote(cred_id, token_data.username)
|
||||
if not ok:
|
||||
raise HTTPException(404, "凭证不存在或不属于当前用户")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ─── Jobs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs")
|
||||
async def create_job(
|
||||
payload: JobCreate,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
try:
|
||||
return await org.job_create.remote(
|
||||
user_id=token_data.username,
|
||||
cred_id=payload.cred_id,
|
||||
description=payload.description,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def list_jobs(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
rows = await org.job_list.remote(token_data.username)
|
||||
return {"jobs": rows}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def get_job(
|
||||
job_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
row = await org.job_get.remote(job_id, token_data.username)
|
||||
if row is None:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/stream")
|
||||
async def stream_job(
|
||||
job_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
"""转发 organization 事件流为 SSE。"""
|
||||
import json
|
||||
|
||||
org = _get_org()
|
||||
row = await org.job_get.remote(job_id, token_data.username)
|
||||
if row is None:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
org_task_id = row.get("org_task_id")
|
||||
if not org_task_id:
|
||||
raise HTTPException(409, "任务尚未投递到 organization")
|
||||
|
||||
async def _generate():
|
||||
async for event in await org.stream.remote(org_task_id):
|
||||
payload = event if isinstance(event, str) else json.dumps(event, ensure_ascii=False)
|
||||
yield f"data: {payload}\n\n"
|
||||
|
||||
return StreamingResponse(_generate(), media_type="text/event-stream")
|
||||
@@ -0,0 +1 @@
|
||||
"""data_analytics organization 实现。"""
|
||||
@@ -0,0 +1,235 @@
|
||||
"""data_analytics 插件本地 SQLite 表与 DAO。
|
||||
|
||||
注意:本插件用的 ``DeclarativeBase`` 跟核心 PG 完全独立,避免元数据空间串场。
|
||||
所有数据落到 ``data/plugin/data_analytics/_data/data_analytics.db``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from kilostar.utils.crypto import decrypt_dict_secrets, encrypt_dict_secrets
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""data_analytics 插件私有的元数据空间,跟核心 PG 隔离。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class S3Credential(Base):
|
||||
__tablename__ = "s3_credential"
|
||||
|
||||
cred_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
endpoint_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
region: Mapped[str] = mapped_column(String(50), default="us-east-1")
|
||||
access_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
secret_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class AnalysisJob(Base):
|
||||
__tablename__ = "analysis_job"
|
||||
|
||||
job_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
cred_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
org_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
result: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False, index=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class CredentialDAO:
|
||||
"""S3 凭证 DAO:写入时自动加密,读取时自动解密。"""
|
||||
|
||||
SENSITIVE_KEYS = ("access_key", "secret_key")
|
||||
|
||||
def __init__(self, sm: async_sessionmaker[AsyncSession]):
|
||||
self._sm = sm
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: S3Credential, *, include_secrets: bool) -> dict:
|
||||
d = {
|
||||
"cred_id": row.cred_id,
|
||||
"user_id": row.user_id,
|
||||
"display_name": row.display_name,
|
||||
"endpoint_url": row.endpoint_url,
|
||||
"region": row.region,
|
||||
"access_key": row.access_key,
|
||||
"secret_key": row.secret_key,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
if not include_secrets:
|
||||
ak = decrypt_dict_secrets({"access_key": d["access_key"]}).get("access_key", "")
|
||||
d["access_key"] = (ak[:4] + "***" + ak[-2:]) if len(ak) > 6 else "***"
|
||||
d.pop("secret_key", None)
|
||||
return d
|
||||
# include_secrets=True 用于工具内部,返回明文给 boto3
|
||||
return decrypt_dict_secrets(d)
|
||||
|
||||
async def list_by_user(self, user_id: str) -> List[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.user_id == user_id)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r, include_secrets=False) for r in rows]
|
||||
|
||||
async def get(self, cred_id: str, *, include_secrets: bool = False) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_dict(row, include_secrets=include_secrets)
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
cred_id: str,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
endpoint_url: Optional[str] = None,
|
||||
region: str = "us-east-1",
|
||||
) -> dict:
|
||||
encrypted = encrypt_dict_secrets(
|
||||
{"access_key": access_key, "secret_key": secret_key}
|
||||
)
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
|
||||
existing = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.display_name = display_name
|
||||
existing.endpoint_url = endpoint_url
|
||||
existing.region = region
|
||||
existing.access_key = encrypted["access_key"]
|
||||
existing.secret_key = encrypted["secret_key"]
|
||||
s.add(existing)
|
||||
await s.commit()
|
||||
await s.refresh(existing)
|
||||
return self._row_to_dict(existing, include_secrets=False)
|
||||
row = S3Credential(
|
||||
cred_id=cred_id,
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
endpoint_url=endpoint_url,
|
||||
region=region,
|
||||
access_key=encrypted["access_key"],
|
||||
secret_key=encrypted["secret_key"],
|
||||
)
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row, include_secrets=False)
|
||||
|
||||
async def delete(self, cred_id: str, user_id: str) -> bool:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(
|
||||
S3Credential.cred_id == cred_id, S3Credential.user_id == user_id
|
||||
)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
await s.delete(row)
|
||||
await s.commit()
|
||||
return True
|
||||
|
||||
|
||||
class JobDAO:
|
||||
"""分析任务记录 DAO。"""
|
||||
|
||||
def __init__(self, sm: async_sessionmaker[AsyncSession]):
|
||||
self._sm = sm
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: AnalysisJob) -> dict:
|
||||
return {
|
||||
"job_id": row.job_id,
|
||||
"user_id": row.user_id,
|
||||
"cred_id": row.cred_id,
|
||||
"description": row.description,
|
||||
"status": row.status,
|
||||
"org_task_id": row.org_task_id,
|
||||
"result": row.result,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
description: str,
|
||||
cred_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
async with self._sm() as s:
|
||||
row = AnalysisJob(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
description=description,
|
||||
cred_id=cred_id,
|
||||
)
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
result: Optional[str] = None,
|
||||
org_task_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if status is not None:
|
||||
row.status = status
|
||||
if result is not None:
|
||||
row.result = result
|
||||
if org_task_id is not None:
|
||||
row.org_task_id = org_task_id
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list_by_user(self, user_id: str, limit: int = 50) -> List[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = (
|
||||
select(AnalysisJob)
|
||||
.where(AnalysisJob.user_id == user_id)
|
||||
.order_by(AnalysisJob.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r) for r in rows]
|
||||
|
||||
async def get(self, job_id: str) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
@@ -0,0 +1,135 @@
|
||||
"""data_analytics organization:管理本插件的 SQLite 元数据 + 注入凭证 ctx。
|
||||
|
||||
凭证经由 ``S3_CREDS_VAR`` ContextVar 传给工具,避免污染 agent tool signature
|
||||
(agent 看到的工具不带 cred 参数,模型不会误传)。
|
||||
|
||||
API 层通过本类暴露的 ``cred_*`` / ``job_*`` 代理方法跨 actor 调 DAO,
|
||||
保证分布式模式下 actor 之间不直接共享 SQLAlchemy session。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from kilostar.plugin_runtime.base_organization import BaseOrganization
|
||||
from kilostar.plugin_runtime.event import OrgEvent
|
||||
|
||||
from .db import Base, CredentialDAO, JobDAO
|
||||
|
||||
# 当前任务的 S3 凭证(明文):工具内部读 .get() 拿
|
||||
S3_CREDS_VAR: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
|
||||
"data_analytics_s3_creds", default=None
|
||||
)
|
||||
|
||||
|
||||
class DataAnalyticsOrganization(BaseOrganization):
|
||||
"""对接 S3 的数据分析组织。"""
|
||||
|
||||
async def setup(self) -> None:
|
||||
await super().setup()
|
||||
await self.init_local_db([Base])
|
||||
# 跨工具/跨 API 共享的 DAO 实例
|
||||
self.cred_dao = CredentialDAO(self._session_maker)
|
||||
self.job_dao = JobDAO(self._session_maker)
|
||||
|
||||
async def on_first_install(self) -> None:
|
||||
self.logger.info(
|
||||
"data_analytics installed; configure S3 credentials in dashboard."
|
||||
)
|
||||
|
||||
async def react(
|
||||
self,
|
||||
task_description: str,
|
||||
ctx: Dict[str, Any],
|
||||
emit: Callable[[OrgEvent], Any],
|
||||
) -> Any:
|
||||
cred_id = ctx.get("cred_id")
|
||||
if cred_id and getattr(self, "cred_dao", None) is not None:
|
||||
cred = await self.cred_dao.get(cred_id, include_secrets=True)
|
||||
if cred is None:
|
||||
raise RuntimeError(f"S3 凭证 {cred_id} 不存在")
|
||||
S3_CREDS_VAR.set(cred)
|
||||
ctx["s3_cred_display"] = cred.get("display_name")
|
||||
else:
|
||||
S3_CREDS_VAR.set(None)
|
||||
return await super().react(task_description, ctx, emit)
|
||||
|
||||
# ─── 凭证代理(API 层调用) ─────────────────────────────────────
|
||||
|
||||
async def cred_list(self, user_id: str) -> List[dict]:
|
||||
return await self.cred_dao.list_by_user(user_id)
|
||||
|
||||
async def cred_create(
|
||||
self,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
endpoint_url: Optional[str] = None,
|
||||
region: str = "us-east-1",
|
||||
) -> dict:
|
||||
cred_id = uuid.uuid4().hex
|
||||
return await self.cred_dao.upsert(
|
||||
cred_id=cred_id,
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
endpoint_url=endpoint_url,
|
||||
region=region,
|
||||
)
|
||||
|
||||
async def cred_delete(self, cred_id: str, user_id: str) -> bool:
|
||||
return await self.cred_dao.delete(cred_id, user_id)
|
||||
|
||||
# ─── 任务代理 ──────────────────────────────────────────────────
|
||||
|
||||
async def job_create(
|
||||
self, user_id: str, cred_id: str, description: str
|
||||
) -> dict:
|
||||
# 校验凭证归属
|
||||
cred = await self.cred_dao.get(cred_id, include_secrets=False)
|
||||
if cred is None or cred.get("user_id") != user_id:
|
||||
raise ValueError("凭证不存在或不属于当前用户")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
await self.job_dao.create(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
description=description,
|
||||
cred_id=cred_id,
|
||||
)
|
||||
# 投递 organization 任务(拿 task_id 回填,便于前端拉事件流)
|
||||
task_id = await self.submit(
|
||||
description, {"user_id": user_id, "cred_id": cred_id, "job_id": job_id}
|
||||
)
|
||||
await self.job_dao.update(job_id, status="running", org_task_id=task_id)
|
||||
return {"job_id": job_id, "task_id": task_id, "status": "running"}
|
||||
|
||||
async def job_list(self, user_id: str) -> List[dict]:
|
||||
return await self.job_dao.list_by_user(user_id)
|
||||
|
||||
async def job_get(self, job_id: str, user_id: str) -> Optional[dict]:
|
||||
row = await self.job_dao.get(job_id)
|
||||
if row is None or row.get("user_id") != user_id:
|
||||
return None
|
||||
# 附带最新 organization 状态
|
||||
org_task_id = row.get("org_task_id")
|
||||
if org_task_id:
|
||||
ts = await self.status(org_task_id)
|
||||
if ts is not None:
|
||||
row["task_status"] = ts.get("status")
|
||||
row["task_result"] = ts.get("result")
|
||||
row["task_error"] = ts.get("error")
|
||||
# 任务终态时把结果回写 SQLite,方便重启后查询
|
||||
if ts.get("status") in ("completed", "failed") and row.get("status") != ts.get("status"):
|
||||
result_payload = ts.get("result") if ts.get("status") == "completed" else ts.get("error")
|
||||
await self.job_dao.update(
|
||||
job_id,
|
||||
status=ts.get("status"),
|
||||
result=str(result_payload) if result_payload is not None else None,
|
||||
)
|
||||
row["status"] = ts.get("status")
|
||||
return row
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Loader2, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
loading: boolean;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function CredentialPanel({ credentials, loading, onChanged }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
display_name: '',
|
||||
endpoint_url: '',
|
||||
region: 'us-east-1',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setForm({ display_name: '', endpoint_url: '', region: 'us-east-1', access_key: '', secret_key: '' });
|
||||
setError('');
|
||||
setShowSecret(false);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.display_name.trim() || !form.access_key.trim() || !form.secret_key.trim()) {
|
||||
setError('显示名 / Access Key / Secret Key 必填');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/credentials`, {
|
||||
display_name: form.display_name.trim(),
|
||||
endpoint_url: form.endpoint_url.trim() || null,
|
||||
region: form.region.trim() || 'us-east-1',
|
||||
access_key: form.access_key,
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
reset();
|
||||
setShowForm(false);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (cred_id: string) => {
|
||||
if (!confirm('确定删除该凭证?删除后该凭证下的任务将无法继续运行。')) return;
|
||||
try {
|
||||
await client.delete(`${API_BASE}/credentials/${cred_id}`);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Key size={16} className="text-accent" /> S3 凭证
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">访问密钥加密存储于本地 SQLite。</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5"
|
||||
onClick={() => { setShowForm((s) => !s); setError(''); }}
|
||||
>
|
||||
<Plus size={14} /> {showForm ? '取消' : '新增'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="space-y-3 mb-4 p-4 bg-bg-secondary rounded-xl border border-border-secondary">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="显示名(如 prod-aws)"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Endpoint URL(可选,自托管 S3 / MinIO 填写)"
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => setForm({ ...form, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Region(默认 us-east-1)"
|
||||
value={form.region}
|
||||
onChange={(e) => setForm({ ...form, region: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Access Key"
|
||||
value={form.access_key}
|
||||
onChange={(e) => setForm({ ...form, access_key: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Secret Key"
|
||||
value={form.secret_key}
|
||||
onChange={(e) => setForm({ ...form, secret_key: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
||||
onClick={() => setShowSecret((s) => !s)}
|
||||
>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||||
还没有凭证,点右上角「新增」开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{credentials.map((c) => (
|
||||
<div
|
||||
key={c.cred_id}
|
||||
className="flex items-center justify-between p-3 bg-bg-secondary rounded-xl border border-border-secondary"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text-primary truncate">{c.display_name}</div>
|
||||
<div className="text-[11px] text-text-muted font-mono mt-0.5">
|
||||
{c.endpoint_url || 'aws-s3'} · {c.region} · {c.access_key}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-1.5 text-text-muted hover:text-danger transition"
|
||||
onClick={() => remove(c.cred_id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BarChart3, Plus, Loader2, ListChecks } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import { CredentialPanel } from './CredentialPanel';
|
||||
import { NewJobDialog } from './NewJobDialog';
|
||||
import { JobDetail } from './JobDetail';
|
||||
import type { S3Credential, AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export function Dashboard({ pluginName }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credentials, setCredentials] = useState<S3Credential[]>([]);
|
||||
const [credLoading, setCredLoading] = useState(true);
|
||||
const [jobs, setJobs] = useState<AnalysisJob[]>([]);
|
||||
const [jobLoading, setJobLoading] = useState(true);
|
||||
const [showNewJob, setShowNewJob] = useState(false);
|
||||
const [openJobId, setOpenJobId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadCredentials = useCallback(async () => {
|
||||
setCredLoading(true);
|
||||
try {
|
||||
const resp = await client.get<{ credentials: S3Credential[] }>(`${API_BASE}/credentials`);
|
||||
setCredentials(resp.data.credentials || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '加载凭证失败');
|
||||
} finally {
|
||||
setCredLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setJobLoading(true);
|
||||
try {
|
||||
const resp = await client.get<{ jobs: AnalysisJob[] }>(`${API_BASE}/jobs`);
|
||||
setJobs(resp.data.jobs || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '加载任务失败');
|
||||
} finally {
|
||||
setJobLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCredentials();
|
||||
loadJobs();
|
||||
}, [loadCredentials, loadJobs]);
|
||||
|
||||
// 轮询任务列表,方便看状态变化
|
||||
useEffect(() => {
|
||||
const t = setInterval(loadJobs, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadJobs]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-bg-base">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 pb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-light text-accent flex items-center justify-center">
|
||||
<BarChart3 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-primary">数据分析</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
对接 S3,让 agent 自主决定分析路径(python_executor / ray_submit)。{' '}
|
||||
<span className="font-mono text-[10px] opacity-70">{pluginName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
|
||||
)}
|
||||
|
||||
<CredentialPanel credentials={credentials} loading={credLoading} onChanged={loadCredentials} />
|
||||
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<ListChecks size={16} className="text-accent" /> 分析任务
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">点行查看详情和事件流。每 5 秒自动刷新。</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5 disabled:opacity-50"
|
||||
onClick={() => setShowNewJob(true)}
|
||||
disabled={credentials.length === 0}
|
||||
title={credentials.length === 0 ? '请先添加凭证' : ''}
|
||||
>
|
||||
<Plus size={14} /> 新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobLoading && jobs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||||
还没有分析任务。点右上角「新建任务」开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((j) => (
|
||||
<button
|
||||
key={j.job_id}
|
||||
className="w-full text-left p-3 bg-bg-secondary rounded-xl border border-border-secondary hover:border-accent transition flex items-center justify-between gap-3"
|
||||
onClick={() => setOpenJobId(j.job_id)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-text-primary truncate">{j.description}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 font-mono">
|
||||
{j.job_id.slice(0, 8)} · {j.created_at?.slice(0, 19).replace('T', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={j.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewJob && (
|
||||
<NewJobDialog
|
||||
credentials={credentials}
|
||||
onClose={() => setShowNewJob(false)}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
)}
|
||||
{openJobId && <JobDetail jobId={openJobId} onClose={() => setOpenJobId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-bg-base text-text-muted border-border-primary',
|
||||
running: 'bg-warning-bg text-warning border-warning/20',
|
||||
completed: 'bg-success-bg text-success border-success/20',
|
||||
failed: 'bg-danger-bg text-danger border-danger/20',
|
||||
};
|
||||
const cls = map[status] || map.pending;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium px-2 py-1 rounded-lg border ${cls} shrink-0`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, X, Activity } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
jobId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
type?: string;
|
||||
ts?: number;
|
||||
payload?: unknown;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export function JobDetail({ jobId, onClose }: Props) {
|
||||
const { client, token, apiBase } = usePluginContext();
|
||||
const [job, setJob] = useState<AnalysisJob | null>(null);
|
||||
const [events, setEvents] = useState<StreamEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const eventBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 初次加载 + 后台轮询
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchJob = async () => {
|
||||
try {
|
||||
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
|
||||
if (!cancelled) setJob(resp.data);
|
||||
} catch (e) {
|
||||
console.error('fetch job failed', e);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchJob();
|
||||
const t = setInterval(fetchJob, 4000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [client, jobId]);
|
||||
|
||||
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const run = async () => {
|
||||
try {
|
||||
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.body) return;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buf = '';
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split('\n\n');
|
||||
buf = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
try {
|
||||
setEvents((prev) => [...prev, JSON.parse(payload)]);
|
||||
} catch {
|
||||
setEvents((prev) => [...prev, { raw: payload }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => controller.abort();
|
||||
}, [apiBase, jobId, token]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (eventBoxRef.current) {
|
||||
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Activity size={16} className="text-accent" />
|
||||
任务详情
|
||||
</h3>
|
||||
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
|
||||
</div>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : job ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Field label="状态" value={job.task_status || job.status} />
|
||||
<Field label="描述" value={job.description} />
|
||||
{job.task_error && <Field label="错误" value={job.task_error} danger />}
|
||||
</div>
|
||||
|
||||
{job.task_result !== undefined && job.task_result !== null && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">执行结果</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
|
||||
{typeof job.task_result === 'string'
|
||||
? job.task_result
|
||||
: JSON.stringify(job.task_result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">事件流(SSE)</div>
|
||||
<div
|
||||
ref={eventBoxRef}
|
||||
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<span className="text-text-muted">(等待事件…)</span>
|
||||
) : (
|
||||
events.map((e, i) => (
|
||||
<div key={i} className="text-text-secondary">
|
||||
<span className="text-accent">{e.type || 'event'}</span>{' '}
|
||||
{e.payload !== undefined ? (
|
||||
<span>{JSON.stringify(e.payload)}</span>
|
||||
) : e.raw ? (
|
||||
<span>{e.raw}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted text-center py-8">任务不存在或已被删除</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
|
||||
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Send, X } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function NewJobDialog({ credentials, onClose, onCreated }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credId, setCredId] = useState(credentials[0]?.cred_id || '');
|
||||
const [description, setDescription] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = async () => {
|
||||
if (!credId) {
|
||||
setError('请选择 S3 凭证');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setError('请描述要做的分析');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/jobs`, {
|
||||
cred_id: credId,
|
||||
description: description.trim(),
|
||||
});
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '提交失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-lg bg-bg-card rounded-2xl border border-border-primary shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<h3 className="font-semibold text-text-primary">新建分析任务</h3>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">S3 凭证</label>
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-xs text-warning bg-warning-bg/50 border border-warning/20 rounded-lg p-2">
|
||||
请先在上方添加 S3 凭证。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
value={credId}
|
||||
onChange={(e) => setCredId(e.target.value)}
|
||||
>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.cred_id} value={c.cred_id}>
|
||||
{c.display_name} · {c.region}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">任务描述</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent min-h-[120px] resize-y"
|
||||
placeholder="例如:分析 s3://my-bucket/sales/2026-q1/ 的销售趋势,输出按月汇总"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
Agent 会先用 s3_peek/s3_list_objects 探查数据,然后选择 python_executor 或 ray_submit 执行分析。
|
||||
</p>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border-primary">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-text-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center gap-1.5"
|
||||
onClick={submit}
|
||||
disabled={busy || credentials.length === 0}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// vite lib 模式 build 完后写一份 wc-manifest.json,给后端 /ui-manifest 端点读
|
||||
const distDir = 'dist';
|
||||
if (!existsSync(distDir)) {
|
||||
console.error('dist/ not found; run vite build first');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(distDir);
|
||||
const js = files.find((f) => f.endsWith('.js')) || 'plugin-element.js';
|
||||
const css = files.filter((f) => f.endsWith('.css'));
|
||||
|
||||
const manifest = {
|
||||
tag: 'plugin-data-analytics',
|
||||
js,
|
||||
css,
|
||||
};
|
||||
|
||||
writeFileSync(join(distDir, 'wc-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
console.log(`wrote dist/wc-manifest.json: ${JSON.stringify(manifest)}`);
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface PluginContextValue {
|
||||
client: AxiosInstance;
|
||||
token: string;
|
||||
apiBase: string;
|
||||
}
|
||||
|
||||
export const PluginContext = createContext<PluginContextValue | null>(null);
|
||||
|
||||
export function usePluginContext(): PluginContextValue {
|
||||
const ctx = useContext(PluginContext);
|
||||
if (!ctx) throw new Error('PluginContext missing — Web Component not initialized');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function makeClient(token: string, apiBase: string): AxiosInstance {
|
||||
const c = axios.create({
|
||||
baseURL: apiBase || undefined,
|
||||
});
|
||||
c.interceptors.request.use((cfg) => {
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
(cfg.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
return c;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { PluginContext, makeClient } from './client';
|
||||
|
||||
// 把 build 出来的 CSS 当字符串收入,作为 ConstructableStyleSheet 注入到 shadow root,
|
||||
// 既能享受 shadow DOM 的样式隔离,也不需要额外的 fetch 步骤。
|
||||
import css from './styles.css?inline';
|
||||
|
||||
const TAG = 'plugin-data-analytics';
|
||||
|
||||
class DataAnalyticsElement extends HTMLElement {
|
||||
private root?: Root;
|
||||
private mount?: HTMLDivElement;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['token', 'api-base'];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
|
||||
// 用 <style> 注入 CSS(ConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
shadow.appendChild(style);
|
||||
|
||||
this.mount = document.createElement('div');
|
||||
this.mount.style.cssText = 'height:100%;width:100%';
|
||||
shadow.appendChild(this.mount);
|
||||
|
||||
this.root = createRoot(this.mount);
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.root) this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.root?.unmount();
|
||||
this.root = undefined;
|
||||
}
|
||||
|
||||
private render() {
|
||||
const token = this.getAttribute('token') ?? '';
|
||||
const apiBase = this.getAttribute('api-base') ?? '';
|
||||
const client = makeClient(token, apiBase);
|
||||
this.root!.render(
|
||||
<React.StrictMode>
|
||||
<PluginContext.Provider value={{ client, token, apiBase }}>
|
||||
<Dashboard pluginName="data_analytics" />
|
||||
</PluginContext.Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get(TAG)) {
|
||||
customElements.define(TAG, DataAnalyticsElement);
|
||||
}
|
||||
+1744
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "plugin-data-analytics",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build && node build-manifest.mjs",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 在 shadow DOM 内 :root 不匹配,用 :host 给 Web Component 自身定义主题 token。
|
||||
颜色名称跟主前端 frontend/src/index.css 保持一致——这样组件里的 bg-bg-card / text-accent
|
||||
等类名在插件 build 时也能解析到对应的 var()。 */
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-card: var(--bg-card);
|
||||
--color-bg-sidebar: var(--bg-sidebar);
|
||||
--color-bg-input: var(--bg-input);
|
||||
--color-bg-hover: var(--bg-hover);
|
||||
--color-bg-active: var(--bg-active);
|
||||
--color-bg-base: var(--bg-base);
|
||||
--color-border-primary: var(--border-primary);
|
||||
--color-border-secondary: var(--border-secondary);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-light: var(--accent-light);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-bg: var(--danger-bg);
|
||||
--color-success: var(--success);
|
||||
--color-success-bg: var(--success-bg);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-bg: var(--warning-bg);
|
||||
}
|
||||
|
||||
:host {
|
||||
/* light theme defaults — 跟主前端保持一致 */
|
||||
--bg-primary: #f2f0ed;
|
||||
--bg-secondary: #eae8e4;
|
||||
--bg-tertiary: #e0ddd8;
|
||||
--bg-card: #faf9f7;
|
||||
--bg-sidebar: #eae8e4;
|
||||
--bg-input: #f2f0ed;
|
||||
--bg-hover: rgba(255, 255, 255, 0.4);
|
||||
--bg-active: rgba(156, 175, 136, 0.08);
|
||||
--bg-base: #f2f0ed;
|
||||
--border-primary: #e0ddd8;
|
||||
--border-secondary: #eae8e4;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #5a5a5a;
|
||||
--text-tertiary: #8c8680;
|
||||
--text-muted: #b5afa8;
|
||||
--accent: #9caf88;
|
||||
--accent-hover: #8a9e78;
|
||||
--accent-light: rgba(156, 175, 136, 0.12);
|
||||
--danger: #c4917a;
|
||||
--danger-bg: rgba(196, 145, 122, 0.08);
|
||||
--success: #7a8e6a;
|
||||
--success-bg: rgba(122, 142, 106, 0.08);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.08);
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 跟随系统/主前端的暗色主题:宿主元素加 [data-theme="dark"] 时切换 */
|
||||
:host([data-theme="dark"]) {
|
||||
--bg-primary: #1c1b19;
|
||||
--bg-secondary: #232220;
|
||||
--bg-tertiary: #2d2b28;
|
||||
--bg-card: #252421;
|
||||
--bg-sidebar: #1e1d1b;
|
||||
--bg-input: #2d2b28;
|
||||
--bg-hover: rgba(255, 255, 255, 0.04);
|
||||
--bg-active: rgba(156, 175, 136, 0.1);
|
||||
--bg-base: #1c1b19;
|
||||
--border-primary: rgba(255, 255, 255, 0.06);
|
||||
--border-secondary: rgba(255, 255, 255, 0.03);
|
||||
--text-primary: #e8e6e3;
|
||||
--text-secondary: #c8c5c0;
|
||||
--text-tertiary: #a09c96;
|
||||
--text-muted: #7a7772;
|
||||
--accent: #a8bc94;
|
||||
--accent-hover: #b8caa6;
|
||||
--accent-light: rgba(156, 175, 136, 0.15);
|
||||
--danger: #d4a894;
|
||||
--danger-bg: rgba(196, 145, 122, 0.1);
|
||||
--success: #9caf88;
|
||||
--success-bg: rgba(156, 175, 136, 0.1);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["*.ts", "*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface S3Credential {
|
||||
cred_id: string;
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
endpoint_url: string | null;
|
||||
region: string;
|
||||
access_key: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AnalysisJob {
|
||||
job_id: string;
|
||||
user_id: string;
|
||||
cred_id: string | null;
|
||||
description: string;
|
||||
status: string;
|
||||
org_task_id: string | null;
|
||||
result: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
task_status?: string;
|
||||
task_result?: unknown;
|
||||
task_error?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'index.tsx',
|
||||
formats: ['es'],
|
||||
fileName: () => 'plugin-element.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (info) => {
|
||||
if (info.name && info.name.endsWith('.css')) return 'plugin-element.css';
|
||||
return 'assets/[name]-[hash][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "data_analytics",
|
||||
"version": "0.1.0",
|
||||
"display_name": "数据分析",
|
||||
"description": "对接 S3 对象存储,由 agent 自主决定使用 python_executor 或 ray_submit 跑分析。仅读不写。",
|
||||
"entry": "core.organization:DataAnalyticsOrganization",
|
||||
"concurrency": "queue",
|
||||
"node_affinity": "cpu",
|
||||
"api_prefix": "/api/v1/plugin/data_analytics",
|
||||
"capabilities": ["data_analysis", "s3_readonly"],
|
||||
"dependencies": {
|
||||
"python": [],
|
||||
"plugins": []
|
||||
},
|
||||
"ui": {
|
||||
"entry": "frontend/index.tsx",
|
||||
"icon": "BarChart3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""data_analytics 插件本地工具集。
|
||||
|
||||
agent 看到这些工具时不带凭证参数,凭证由 organization 通过 ContextVar 注入。
|
||||
"""
|
||||
|
||||
from .s3_list_objects import s3_list_objects
|
||||
from .s3_peek import s3_peek
|
||||
from .s3_get_object import s3_get_object
|
||||
from .ray_submit import ray_submit
|
||||
|
||||
__all__ = [
|
||||
"s3_list_objects",
|
||||
"s3_peek",
|
||||
"s3_get_object",
|
||||
"ray_submit",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""S3 工具共用辅助:从 ContextVar 拿凭证 + 解析 URI。
|
||||
|
||||
所有 s3_* 工具都依赖这个模块,把"明文凭证"的取用集中在一处。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
def get_s3_creds_or_raise() -> Dict[str, Any]:
|
||||
"""从 organization 注入的 ContextVar 中取出明文凭证;未注入则抛错。"""
|
||||
# 延迟 import 避免循环;这里走 organization 子类被加载时注入的虚拟包路径
|
||||
from ..core.organization import S3_CREDS_VAR
|
||||
|
||||
creds = S3_CREDS_VAR.get()
|
||||
if not creds:
|
||||
raise RuntimeError(
|
||||
"未提供 S3 凭证:本任务上下文中没有 cred_id,请在创建 job 时选择凭证。"
|
||||
)
|
||||
return creds
|
||||
|
||||
|
||||
def parse_s3_uri(uri: str) -> Tuple[str, str]:
|
||||
"""解析 ``s3://bucket/key`` → ``(bucket, key)``;非法格式抛 ValueError。"""
|
||||
m = re.match(r"^s3://([^/]+)/(.+)$", uri.strip())
|
||||
if not m:
|
||||
raise ValueError(f"非法 S3 URI:{uri!r}(期待 s3://bucket/key 形式)")
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def make_session_kwargs(creds: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""转 boto3/aiobotocore client 调用所需的 kwargs。"""
|
||||
kw: Dict[str, Any] = {
|
||||
"aws_access_key_id": creds["access_key"],
|
||||
"aws_secret_access_key": creds["secret_key"],
|
||||
"region_name": creds.get("region") or "us-east-1",
|
||||
}
|
||||
endpoint = creds.get("endpoint_url")
|
||||
if endpoint:
|
||||
kw["endpoint_url"] = endpoint
|
||||
return kw
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "data_analytics_internal",
|
||||
"version": "0.1.0",
|
||||
"description": "data_analytics 插件内部工具:S3 只读 + Ray 提交。仅限本插件内部 agent 调用。",
|
||||
"tools": [
|
||||
{
|
||||
"name": "s3_list_objects",
|
||||
"file": "s3_list_objects.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "s3_peek",
|
||||
"file": "s3_peek.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "s3_get_object",
|
||||
"file": "s3_get_object.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "ray_submit",
|
||||
"file": "ray_submit.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""ray_submit:把分析脚本提交到 Ray(distributed)或 subprocess(standalone)执行。
|
||||
|
||||
凭证以 ``AWS_*`` 环境变量注入子进程,让 boto3/pandas-s3 自然读到。
|
||||
脚本走 ``kilostar.utils.sandbox.validate_python_code`` 的静态屏蔽兜底。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from kilostar.utils.ray_compat import _STANDALONE
|
||||
from kilostar.utils.sandbox import (
|
||||
CodeViolation,
|
||||
get_python_timeout,
|
||||
validate_python_code,
|
||||
)
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise
|
||||
|
||||
|
||||
def _build_env(creds) -> dict:
|
||||
env = os.environ.copy()
|
||||
env["AWS_ACCESS_KEY_ID"] = creds["access_key"]
|
||||
env["AWS_SECRET_ACCESS_KEY"] = creds["secret_key"]
|
||||
env["AWS_DEFAULT_REGION"] = creds.get("region") or "us-east-1"
|
||||
if creds.get("endpoint_url"):
|
||||
env["AWS_ENDPOINT_URL_S3"] = creds["endpoint_url"]
|
||||
env["AWS_ENDPOINT_URL"] = creds["endpoint_url"]
|
||||
return env
|
||||
|
||||
|
||||
async def ray_submit(script: str, timeout: int = 300) -> str:
|
||||
"""提交 Python 脚本到 Ray(分布式)或子进程(单机)执行。
|
||||
|
||||
脚本中可直接 ``import boto3`` 读 S3(凭证已通过环境变量注入);可用
|
||||
pandas / polars / numpy 等已安装的依赖。**只读**——不要尝试 put/delete。
|
||||
|
||||
Args:
|
||||
script: Python 源码
|
||||
timeout: 超时秒数(默认 300)
|
||||
|
||||
Returns:
|
||||
stdout(必要时尾部追加 stderr 与 exit code)
|
||||
"""
|
||||
try:
|
||||
script = validate_python_code(script)
|
||||
except CodeViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
env = _build_env(creds)
|
||||
timeout = get_python_timeout(timeout)
|
||||
|
||||
# standalone 与 distributed 第一版都走 subprocess,保证环境变量传递可控
|
||||
# (ray.remote 跑函数时 env vars 需另装 runtime_env,复杂度跟 subprocess 持平
|
||||
# 但前者透明可控,先这样落地)
|
||||
tmp_file = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(script)
|
||||
tmp_file = f.name
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
tmp_file,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
out = stdout.decode("utf-8", errors="replace")
|
||||
err = stderr.decode("utf-8", errors="replace")
|
||||
result = ""
|
||||
if out:
|
||||
result += out
|
||||
if err:
|
||||
result += f"\n[stderr]\n{err}"
|
||||
if proc.returncode != 0:
|
||||
result += f"\n[exit code: {proc.returncode}]"
|
||||
result = result.strip() or "(no output)"
|
||||
if not _STANDALONE:
|
||||
result = f"[mode: ray-cluster (subprocess)]\n{result}"
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return f"[Error] ray_submit 执行超时({timeout}s)"
|
||||
except Exception as e:
|
||||
return f"[Error] ray_submit 失败:{e}"
|
||||
finally:
|
||||
if tmp_file and os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""s3_get_object:下载到 artifact 目录(路径强校验防穿越)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from kilostar.utils.settings import get_artifact_dir
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
|
||||
|
||||
|
||||
async def s3_get_object(uri: str, save_as: str) -> str:
|
||||
"""把 S3 对象下载到本进程的 artifact 工作区,返回本地绝对路径。
|
||||
|
||||
``save_as`` 必须是相对路径,落到 ``data/artifact/data_analytics_downloads/``
|
||||
下面(防越权写入任意目录)。下载后供 python_executor / ray_submit 中以
|
||||
pandas/polars 读取。
|
||||
|
||||
Args:
|
||||
uri: 形如 ``s3://bucket/key`` 的对象路径
|
||||
save_as: 保存的相对文件名(不能含 ``..`` 或绝对路径)
|
||||
|
||||
Returns:
|
||||
本地保存的绝对路径
|
||||
"""
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
bucket, key = parse_s3_uri(uri)
|
||||
|
||||
save_path = Path(save_as).as_posix()
|
||||
if save_path.startswith("/") or ".." in save_path.split("/"):
|
||||
raise ValueError(f"save_as 必须是相对、不含 .. 的路径,收到 {save_as!r}")
|
||||
|
||||
base = get_artifact_dir() / "data_analytics_downloads"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
target = base / save_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session = get_session()
|
||||
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
|
||||
resp = await client.get_object(Bucket=bucket, Key=key)
|
||||
body = await resp["Body"].read()
|
||||
target.write_bytes(body)
|
||||
return str(target.resolve())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""s3_list_objects:列出 bucket+prefix 下的对象列表(key/size/last_modified)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs
|
||||
|
||||
|
||||
async def s3_list_objects(
|
||||
bucket: str,
|
||||
prefix: str = "",
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""列出 S3 bucket 下指定 prefix 的对象(最多 limit 条)。
|
||||
|
||||
Args:
|
||||
bucket: S3 bucket 名
|
||||
prefix: 对象 key 前缀,留空表示根路径
|
||||
limit: 最多返回条数(1-1000),默认 50
|
||||
|
||||
Returns:
|
||||
对象信息列表,每项含 key / size / last_modified(ISO 字符串)
|
||||
"""
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
limit = max(1, min(int(limit), 1000))
|
||||
|
||||
session = get_session()
|
||||
out: List[Dict[str, Any]] = []
|
||||
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
async for page in paginator.paginate(
|
||||
Bucket=bucket, Prefix=prefix, PaginationConfig={"MaxItems": limit}
|
||||
):
|
||||
for item in page.get("Contents", []) or []:
|
||||
out.append({
|
||||
"key": item.get("Key"),
|
||||
"size": item.get("Size"),
|
||||
"last_modified": (
|
||||
item["LastModified"].isoformat() if item.get("LastModified") else None
|
||||
),
|
||||
})
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
@@ -0,0 +1,35 @@
|
||||
"""s3_peek:读取对象的头若干字节并尝试 UTF-8 解码(看几行用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
|
||||
|
||||
|
||||
async def s3_peek(uri: str, n_bytes: int = 4096) -> str:
|
||||
"""读取 S3 对象的头 ``n_bytes`` 字节,UTF-8 解码后返回。
|
||||
|
||||
适合快速预览 csv/json/log 等文本类对象的开头几行。二进制内容会以
|
||||
``[binary, ...]`` 占位说明返回。
|
||||
|
||||
Args:
|
||||
uri: 形如 ``s3://bucket/key`` 的对象路径
|
||||
n_bytes: 读取字节数,默认 4096,上限 1MB
|
||||
|
||||
Returns:
|
||||
对象内容片段(解码后的字符串或占位说明)
|
||||
"""
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
bucket, key = parse_s3_uri(uri)
|
||||
n = max(1, min(int(n_bytes), 1024 * 1024))
|
||||
|
||||
session = get_session()
|
||||
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
|
||||
resp = await client.get_object(Bucket=bucket, Key=key, Range=f"bytes=0-{n-1}")
|
||||
body = await resp["Body"].read()
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
return text
|
||||
except UnicodeDecodeError:
|
||||
return f"[binary, {len(body)} bytes; first 64 hex] {body[:64].hex()}"
|
||||
+19
-4
@@ -15,6 +15,7 @@ import { RightPanel } from './components/Chat/RightPanel';
|
||||
import { WorkflowListView } from './components/Chat/WorkflowListView';
|
||||
import { NewWorkflowDialog } from './components/Chat/NewWorkflowDialog';
|
||||
import { AuthPage } from './components/Auth/AuthPage';
|
||||
import { HeavyPluginShell } from './plugins/HeavyPluginShell';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
import { useChatStore } from './store/useChatStore';
|
||||
|
||||
@@ -29,10 +30,12 @@ function App() {
|
||||
agentTab,
|
||||
applyTheme,
|
||||
locale,
|
||||
loadInstalledPlugins,
|
||||
} = useAppStore();
|
||||
|
||||
const { loadSessions } = useChatStore();
|
||||
const [showSetupGuide, setShowSetupGuide] = useState(false);
|
||||
const activeHeavyPlugin = useAppStore((s) => s.activeHeavyPlugin);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme();
|
||||
@@ -58,9 +61,10 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSessions();
|
||||
loadInstalledPlugins();
|
||||
setShowSetupGuide(true);
|
||||
}
|
||||
}, [isAuthenticated, loadSessions]);
|
||||
}, [isAuthenticated, loadSessions, loadInstalledPlugins]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage onLoginSuccess={() => setIsAuthenticated(true)} />;
|
||||
@@ -90,11 +94,17 @@ function App() {
|
||||
{mode === 'work' && workTab === 'chat' && (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="chats" />
|
||||
<ChatPanel />
|
||||
{activeHeavyPlugin ? (
|
||||
<HeavyPluginShell name={activeHeavyPlugin} />
|
||||
) : (
|
||||
<ChatPanel />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'work' && workTab === 'workflow' && <WorkflowShell />}
|
||||
{mode === 'work' && workTab === 'workflow' && (
|
||||
<WorkflowShell />
|
||||
)}
|
||||
|
||||
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
|
||||
|
||||
@@ -149,7 +159,12 @@ function WorkflowShell() {
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkflowListView onSelectWorkflow={setSelectedWorkflow} />;
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="workflows" />
|
||||
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon, Pencil, Check } from 'lucide-react';
|
||||
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon, Pencil, Check, Box } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
import type { Workflow } from '../../types';
|
||||
import { useChatStore } from '../../store/useChatStore';
|
||||
import { useAppStore } from '../../store/useAppStore';
|
||||
|
||||
interface LeftPanelProps {
|
||||
activeTab: string;
|
||||
@@ -26,6 +27,12 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
updateSessionTitle,
|
||||
} = useChatStore();
|
||||
|
||||
const installedHeavyPlugins = useAppStore((s) => s.installedHeavyPlugins);
|
||||
const activeHeavyPlugin = useAppStore((s) => s.activeHeavyPlugin);
|
||||
const setActiveHeavyPlugin = useAppStore((s) => s.setActiveHeavyPlugin);
|
||||
const heavyPluginsWithUi = installedHeavyPlugins.filter((p) => p.has_ui);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
@@ -61,6 +68,7 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
|
||||
const handleNewChat = () => {
|
||||
setActiveSessionId(null);
|
||||
setActiveHeavyPlugin(null);
|
||||
};
|
||||
|
||||
const handleDeleteChat = (e: React.MouseEvent, id: string) => {
|
||||
@@ -115,7 +123,10 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
onClick={() => {
|
||||
setActiveSessionId(session.id);
|
||||
setActiveHeavyPlugin(null);
|
||||
}}
|
||||
className={`group flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
@@ -160,39 +171,73 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
})
|
||||
)
|
||||
) : (
|
||||
loadingWorkflows ? (
|
||||
<div className="px-3 py-8 text-center text-text-muted text-xs">{t('workflow.loading')}</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-text-muted text-xs">
|
||||
{t('workflow.noWorkflows')}
|
||||
</div>
|
||||
) : (
|
||||
workflows.map((wf) => {
|
||||
const isActive = selectedWorkflow === wf.trace_id;
|
||||
return (
|
||||
<div
|
||||
key={wf.trace_id}
|
||||
onClick={() => setSelectedWorkflow(wf.trace_id)}
|
||||
className={`group flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
: 'hover:bg-white/60 dark:hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-[7px] flex items-center justify-center flex-shrink-0 ${isActive ? 'bg-accent-light' : 'bg-bg-primary'}`}>
|
||||
<WorkflowIcon size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
|
||||
<>
|
||||
{loadingWorkflows ? (
|
||||
<div className="px-3 py-8 text-center text-text-muted text-xs">{t('workflow.loading')}</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-text-muted text-xs">
|
||||
{t('workflow.noWorkflows')}
|
||||
</div>
|
||||
) : (
|
||||
workflows.map((wf) => {
|
||||
const isActive = selectedWorkflow === wf.trace_id;
|
||||
return (
|
||||
<div
|
||||
key={wf.trace_id}
|
||||
onClick={() => setSelectedWorkflow(wf.trace_id)}
|
||||
className={`group flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
: 'hover:bg-white/60 dark:hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-[7px] flex items-center justify-center flex-shrink-0 ${isActive ? 'bg-accent-light' : 'bg-bg-primary'}`}>
|
||||
<WorkflowIcon size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{wf.title || t('common.unnamed')}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{wf.title || t('common.unnamed')}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isChats && heavyPluginsWithUi.length > 0 && (
|
||||
<div className="border-t border-border-primary px-2 py-3 shrink-0">
|
||||
<div className="px-2.5 text-[10px] font-semibold text-text-muted uppercase tracking-[1.2px] mb-1.5">
|
||||
{t('chat.plugins.title')}
|
||||
</div>
|
||||
{heavyPluginsWithUi.map((p) => {
|
||||
const isActive = activeHeavyPlugin === p.name;
|
||||
return (
|
||||
<button
|
||||
key={p.name}
|
||||
onClick={() => {
|
||||
setActiveHeavyPlugin(p.name);
|
||||
setActiveSessionId(null);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
: 'hover:bg-white/60 dark:hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-[7px] flex items-center justify-center flex-shrink-0 ${isActive ? 'bg-accent-light' : 'bg-bg-primary'}`}>
|
||||
<Box size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
|
||||
</div>
|
||||
<span className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{p.display_name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"chat": "Chat",
|
||||
"workflow": "Workflow",
|
||||
"plugin": "Plugin",
|
||||
"heavyPlugin": "Plugins",
|
||||
"agents": "Agents",
|
||||
"toolsets": "Toolsets",
|
||||
"config": "Config",
|
||||
@@ -50,6 +51,9 @@
|
||||
"writeCode": "Write code",
|
||||
"summarize": "Summarize doc",
|
||||
"search": "Search info"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Heavy Plugins"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"chat": "对话",
|
||||
"workflow": "工作流",
|
||||
"plugin": "插件",
|
||||
"heavyPlugin": "重型插件",
|
||||
"agents": "智能体",
|
||||
"toolsets": "工具集",
|
||||
"config": "配置",
|
||||
@@ -50,6 +51,9 @@
|
||||
"writeCode": "写代码",
|
||||
"summarize": "总结文档",
|
||||
"search": "查找资料"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "重型插件"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
interface UiManifest {
|
||||
name: string;
|
||||
tag: string;
|
||||
js: string;
|
||||
css?: string[];
|
||||
display_name?: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 已加载过的插件 JS:避免切换插件时重复 import 同一个 module(customElements 也不允许重复 define)
|
||||
const loadedJs = new Set<string>();
|
||||
|
||||
export function HeavyPluginShell({ name }: Props) {
|
||||
const setActiveHeavyPlugin = useAppStore((s) => s.setActiveHeavyPlugin);
|
||||
const meta = useAppStore((s) => s.installedHeavyPlugins.find((p) => p.name === name));
|
||||
const resolvedTheme = useAppStore((s) => s.resolvedTheme);
|
||||
const [manifest, setManifest] = useState<UiManifest | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 取 token:和 apiClient 拦截器同源(localStorage 'token',详见 frontend/src/api/client.ts)
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('token') ?? '' : '';
|
||||
|
||||
// 1. 拉取 ui-manifest 并按需注入 JS(CSS 在 Web Component 内部 inline 注入,这里不管)
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setManifest(null);
|
||||
setError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await apiClient.get<UiManifest>(`/api/v1/plugin/${name}/ui-manifest`);
|
||||
const m = resp.data;
|
||||
if (!loadedJs.has(m.js)) {
|
||||
// 动态 import 插件 ESM bundle —— @vite-ignore 避免 vite 把这个 URL 当成构建期资源
|
||||
await import(/* @vite-ignore */ m.js);
|
||||
loadedJs.add(m.js);
|
||||
}
|
||||
if (!canceled) setManifest(m);
|
||||
} catch (e: unknown) {
|
||||
if (canceled) return;
|
||||
const msg =
|
||||
(e as { response?: { data?: { detail?: string } } }).response?.data?.detail ||
|
||||
(e as Error).message ||
|
||||
'failed to load plugin UI';
|
||||
setError(msg);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
// 2. 把 token / api-base / data-theme 三个属性写到自定义元素上
|
||||
// 创建/复用一个元素 placeholder:每次 manifest 切换时清空 host 重新挂载
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !manifest) return;
|
||||
host.innerHTML = '';
|
||||
const el = document.createElement(manifest.tag);
|
||||
el.setAttribute('token', token);
|
||||
el.setAttribute('api-base', '');
|
||||
el.setAttribute('data-theme', resolvedTheme);
|
||||
(el as HTMLElement).style.cssText = 'display:block;width:100%;height:100%';
|
||||
host.appendChild(el);
|
||||
return () => {
|
||||
host.contains(el) && host.removeChild(el);
|
||||
};
|
||||
}, [manifest, token, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-primary bg-bg-card">
|
||||
<button
|
||||
onClick={() => setActiveHeavyPlugin(null)}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-accent transition-all"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
返回会话
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-medium text-text-primary">
|
||||
{meta?.display_name || manifest?.display_name || name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{error ? (
|
||||
<div className="p-8 text-danger text-sm">
|
||||
插件 <code className="text-accent">{name}</code> 加载失败:{error}
|
||||
</div>
|
||||
) : !manifest ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div ref={hostRef} className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,15 @@ type WorkTab = 'chat' | 'workflow';
|
||||
type AgentTab = 'plugin' | 'agents' | 'toolsets' | 'config' | 'logs';
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface InstalledPlugin {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
has_ui?: boolean;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Auth
|
||||
isAuthenticated: boolean;
|
||||
@@ -44,6 +53,12 @@ interface AppState {
|
||||
// Locale
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
|
||||
// Heavy plugin (workspace dashboards)
|
||||
activeHeavyPlugin: string | null;
|
||||
setActiveHeavyPlugin: (name: string | null) => void;
|
||||
installedHeavyPlugins: InstalledPlugin[];
|
||||
loadInstalledPlugins: () => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeMode): 'light' | 'dark' {
|
||||
@@ -107,6 +122,21 @@ export const useAppStore = create<AppState>()(
|
||||
// 同步到 i18next,确保刷新后语言一致
|
||||
import('i18next').then((i18n) => i18n.default.changeLanguage(locale));
|
||||
},
|
||||
|
||||
activeHeavyPlugin: null,
|
||||
setActiveHeavyPlugin: (activeHeavyPlugin) => set({ activeHeavyPlugin }),
|
||||
installedHeavyPlugins: [],
|
||||
loadInstalledPlugins: async () => {
|
||||
try {
|
||||
const apiClient = (await import('../api/client')).default;
|
||||
const resp = await apiClient.get('/api/v1/plugin/list');
|
||||
const list: InstalledPlugin[] = resp.data?.plugins || [];
|
||||
set({ installedHeavyPlugins: list.filter((p) => p.has_ui) });
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch installed plugins', e);
|
||||
set({ installedHeavyPlugins: [] });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kilostar-app-storage',
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@app/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@app': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -109,6 +109,17 @@ app.include_router(plugin_router) # plugin路径
|
||||
app.include_router(task_router) # 短任务路径
|
||||
|
||||
|
||||
# 重型插件:纯文件扫描挂载各插件自带的 ``api.py`` router(不依赖 actor 启动顺序)
|
||||
try:
|
||||
from kilostar.plugin_runtime.loader import collect_plugin_routers
|
||||
from kilostar.utils.settings import get_plugin_dir as _get_plugin_dir
|
||||
|
||||
for _prefix, _plugin_router in collect_plugin_routers(_get_plugin_dir()):
|
||||
app.include_router(_plugin_router, prefix=_prefix)
|
||||
except Exception as _e:
|
||||
_api_logger.warning(f"failed to mount plugin routers: {_e}")
|
||||
|
||||
|
||||
@app.exception_handler(BusinessError)
|
||||
async def business_error_handler(request: Request, exc: BusinessError):
|
||||
"""业务可预期错误:按 ``http_status`` 返回 4xx,附 ``code`` + 异常消息。"""
|
||||
@@ -149,6 +160,30 @@ base_dir = os.path.dirname(
|
||||
)
|
||||
frontend_dir = os.path.join(base_dir, "frontend", "dist")
|
||||
|
||||
# 重型插件 UI:扫描 ``data/plugin/*/frontend/dist/``,按目录名挂成静态资源
|
||||
# (/plugin-ui/<name>/ → <plugin>/frontend/dist/)。html=True 让目录访问回退到 index.html
|
||||
# (单页应用风格),但实际我们靠 wc-manifest.json + import() 加载,不依赖该回退。
|
||||
try:
|
||||
from kilostar.utils.settings import get_plugin_dir as _get_plugin_dir_for_ui
|
||||
|
||||
_plugin_root_for_ui = _get_plugin_dir_for_ui()
|
||||
if _plugin_root_for_ui.exists():
|
||||
for _p in _plugin_root_for_ui.iterdir():
|
||||
if not _p.is_dir():
|
||||
continue
|
||||
_dist = _p / "frontend" / "dist"
|
||||
if not _dist.is_dir():
|
||||
continue
|
||||
app.mount(
|
||||
f"/plugin-ui/{_p.name}",
|
||||
StaticFiles(directory=str(_dist), html=True),
|
||||
name=f"plugin_ui_{_p.name}",
|
||||
)
|
||||
_api_logger.info(f"mounted plugin UI: /plugin-ui/{_p.name} → {_dist}")
|
||||
except Exception as _e:
|
||||
_api_logger.warning(f"failed to mount plugin UIs: {_e}")
|
||||
|
||||
|
||||
if os.path.exists(frontend_dir):
|
||||
app.mount(
|
||||
"/assets",
|
||||
|
||||
+25
-6
@@ -192,24 +192,34 @@ async def create_worker_individual(
|
||||
async def get_worker_individual_list(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
"""列出当前登录用户名下的全部 Worker Agent。"""
|
||||
"""列出当前登录用户名下的全部 Worker Agent,并附加所有 plugin_owned slot。
|
||||
|
||||
plugin_owned slot 是插件登记的"占位 agent",所有用户共享同一份配置,
|
||||
在前端展示时会用徽标标记,并允许任何登录用户装配 provider/model。
|
||||
"""
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
workers = await postgres_database.get_worker_individual_list.remote(
|
||||
owner_id=token_data.user_id
|
||||
)
|
||||
return {"workers": workers}
|
||||
) or []
|
||||
all_workers = await postgres_database.get_all_worker_individual.remote() or []
|
||||
seen_ids = {w.agent_id for w in workers}
|
||||
plugin_slots = [
|
||||
w for w in all_workers
|
||||
if getattr(w, "plugin_owned", None) and w.agent_id not in seen_ids
|
||||
]
|
||||
return {"workers": list(workers) + plugin_slots}
|
||||
|
||||
|
||||
@agent_router.get("/worker/{agent_id}")
|
||||
async def get_worker_individual(
|
||||
agent_id: str, token_data: TokenData = Depends(Accessor.get_current_user)
|
||||
):
|
||||
"""按 ``agent_id`` 查询 Worker Agent;非本人的 Agent 返回 403。"""
|
||||
"""按 ``agent_id`` 查询 Worker Agent;非本人的 Agent 返回 403(plugin_owned slot 例外)。"""
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
|
||||
if not worker:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if worker.owner_id != token_data.user_id:
|
||||
if not getattr(worker, "plugin_owned", None) and worker.owner_id != token_data.user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Forbidden: You do not own this agent"
|
||||
)
|
||||
@@ -227,7 +237,8 @@ async def update_worker_individual(
|
||||
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
|
||||
if not worker:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if worker.owner_id != token_data.user_id:
|
||||
# plugin_owned slot:任何登录用户都能装配 provider/model;普通 worker 仅 owner 可改
|
||||
if not getattr(worker, "plugin_owned", None) and worker.owner_id != token_data.user_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Forbidden: You do not own this agent"
|
||||
)
|
||||
@@ -243,6 +254,14 @@ async def update_worker_individual(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# plugin_owned 时顺带触发对应插件的 reload,让新 provider/model 立刻生效
|
||||
if getattr(worker, "plugin_owned", None):
|
||||
try:
|
||||
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
|
||||
await pm.reload.remote(worker.plugin_owned)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"message": "success", "worker": updated_worker}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.settings import get_plugin_dir
|
||||
|
||||
plugin_router = APIRouter(prefix="/api/v1/plugin", tags=["plugin"])
|
||||
|
||||
@@ -106,3 +109,56 @@ async def reload_plugin(
|
||||
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
|
||||
await pm.reload.remote(name)
|
||||
return {"status": "ok", "name": name}
|
||||
|
||||
|
||||
@plugin_router.get("/{name}/ui-manifest")
|
||||
async def ui_manifest(
|
||||
name: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
"""返回 Web Component 注入所需的元数据。
|
||||
|
||||
读插件 build 产物 ``<plugin>/frontend/dist/wc-manifest.json``,把 ``js`` / ``css`` 路径
|
||||
转成绝对静态路径(与 ``/plugin-ui/<name>/`` 静态挂载对齐)。dist 不存在 → 404。
|
||||
"""
|
||||
plugin_dir = get_plugin_dir() / name
|
||||
if not plugin_dir.is_dir():
|
||||
raise HTTPException(404, f"plugin {name!r} not found")
|
||||
wc_path = plugin_dir / "frontend" / "dist" / "wc-manifest.json"
|
||||
if not wc_path.is_file():
|
||||
raise HTTPException(404, f"plugin {name!r} has no built UI")
|
||||
try:
|
||||
wc = json.loads(wc_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"invalid wc-manifest.json: {e}")
|
||||
|
||||
js_rel = wc.get("js")
|
||||
if not js_rel or not isinstance(js_rel, str):
|
||||
raise HTTPException(500, "wc-manifest.json missing 'js'")
|
||||
tag = wc.get("tag")
|
||||
if not tag or not isinstance(tag, str):
|
||||
raise HTTPException(500, "wc-manifest.json missing 'tag'")
|
||||
|
||||
base_url = f"/plugin-ui/{name}"
|
||||
css_rel = wc.get("css") or []
|
||||
if isinstance(css_rel, str):
|
||||
css_rel = [css_rel]
|
||||
|
||||
# 顺便把 manifest.json 的展示元数据带回前端,少一次往返
|
||||
display_name = name
|
||||
icon = None
|
||||
try:
|
||||
manifest_data = json.loads((plugin_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
display_name = manifest_data.get("display_name") or name
|
||||
icon = (manifest_data.get("ui") or {}).get("icon")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"tag": tag,
|
||||
"js": f"{base_url}/{js_rel.lstrip('/')}",
|
||||
"css": [f"{base_url}/{c.lstrip('/')}" for c in css_rel],
|
||||
"display_name": display_name,
|
||||
"icon": icon,
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ class BaseIndividualModel(BaseDataModel):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
plugin_owned: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True, index=True
|
||||
)
|
||||
|
||||
__mapper_args__ = {"polymorphic_on": "agent_type", "polymorphic_identity": "base"}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ from kilostar.core.postgres_database.model.individual import (
|
||||
OrdinaryIndividualModel,
|
||||
SpecialIndividualModel,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List, Optional
|
||||
from kilostar.core.postgres_database.database_exception import database_exception
|
||||
from kilostar.utils.error import BusinessError
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
@@ -95,7 +96,11 @@ class IndividualDatabase:
|
||||
|
||||
@database_exception
|
||||
async def delete_worker_individual(self, agent_id: str) -> bool:
|
||||
"""删除 Individual;不存在返回 False,删除成功返回 True。"""
|
||||
"""删除 Individual;不存在返回 False,删除成功返回 True。
|
||||
|
||||
``plugin_owned`` 不为空时拒绝删除(插件 agent 由插件生命周期管理,
|
||||
要清理该插件目录后由启动期兜底逻辑收回)。
|
||||
"""
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(BaseIndividualModel).where(
|
||||
BaseIndividualModel.agent_id == agent_id
|
||||
@@ -104,6 +109,11 @@ class IndividualDatabase:
|
||||
individual = results.scalar_one_or_none()
|
||||
if not individual:
|
||||
return False
|
||||
if individual.plugin_owned:
|
||||
raise BusinessError(
|
||||
f"agent {agent_id} 由插件 {individual.plugin_owned} 拥有,"
|
||||
f"不可删除(请改卸载插件)"
|
||||
)
|
||||
await session.delete(individual)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -115,3 +125,85 @@ class IndividualDatabase:
|
||||
statement = select(BaseIndividualModel)
|
||||
results = await session.execute(statement)
|
||||
return list(results.scalars().all())
|
||||
|
||||
# ─── plugin_owned slot 专用 ─────────────────────────────────────
|
||||
|
||||
@database_exception
|
||||
async def find_plugin_slot(self, plugin_name: str, slot_name: str):
|
||||
"""按 ``(plugin_owned, agent_name)`` 查 slot;返回 ORM 对象或 None。"""
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = select(BaseIndividualModel).where(
|
||||
and_(
|
||||
BaseIndividualModel.plugin_owned == plugin_name,
|
||||
BaseIndividualModel.agent_name == slot_name,
|
||||
)
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
@database_exception
|
||||
async def upsert_plugin_slot(
|
||||
self,
|
||||
plugin_name: str,
|
||||
slot_name: str,
|
||||
description: str,
|
||||
owner_id: str = "system",
|
||||
node_affinity: str = "cpu",
|
||||
):
|
||||
"""插件安装期登记一个 agent slot。
|
||||
|
||||
slot 的 provider/model 留空,等用户在前端 Agent 页面装配。已存在则
|
||||
只刷新 description/node_affinity(用户自己装配的 provider+model 不被覆盖)。
|
||||
"""
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = select(BaseIndividualModel).where(
|
||||
and_(
|
||||
BaseIndividualModel.plugin_owned == plugin_name,
|
||||
BaseIndividualModel.agent_name == slot_name,
|
||||
)
|
||||
)
|
||||
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.description = description
|
||||
existing.node_affinity = node_affinity
|
||||
session.add(existing)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
|
||||
row = BaseIndividualModel(
|
||||
agent_id=str(ULID()),
|
||||
agent_name=slot_name,
|
||||
description=description,
|
||||
provider_title="",
|
||||
model_id="",
|
||||
owner_id=owner_id,
|
||||
agent_type="base",
|
||||
node_affinity=node_affinity,
|
||||
plugin_owned=plugin_name,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@database_exception
|
||||
async def list_plugin_owned_names(self) -> List[str]:
|
||||
"""返回当前 DB 中所有出现过的 ``plugin_owned`` 值(去重)。"""
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = select(BaseIndividualModel.plugin_owned).where(
|
||||
BaseIndividualModel.plugin_owned.is_not(None)
|
||||
).distinct()
|
||||
return [r for (r,) in (await session.execute(stmt)).all() if r]
|
||||
|
||||
@database_exception
|
||||
async def delete_plugin_slots(self, plugin_name: str) -> int:
|
||||
"""删掉 ``plugin_owned == plugin_name`` 的所有 slot;返回被删条数。"""
|
||||
async with self.async_session_maker() as session:
|
||||
stmt = select(BaseIndividualModel).where(
|
||||
BaseIndividualModel.plugin_owned == plugin_name
|
||||
)
|
||||
rows = list((await session.execute(stmt)).scalars().all())
|
||||
for row in rows:
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
@@ -246,6 +246,36 @@ class PostgresDatabase:
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.get_all_worker_individual()
|
||||
|
||||
# plugin_owned slot 专用门面方法
|
||||
async def find_plugin_slot(self, plugin_name: str, slot_name: str):
|
||||
"""按 (plugin, slot) 查找已登记的插件 agent slot。"""
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.find_plugin_slot(plugin_name, slot_name)
|
||||
|
||||
async def upsert_plugin_slot(
|
||||
self,
|
||||
plugin_name: str,
|
||||
slot_name: str,
|
||||
description: str,
|
||||
owner_id: str = "system",
|
||||
node_affinity: str = "cpu",
|
||||
):
|
||||
"""插件安装期登记 agent slot;用户自配 provider/model 不被覆盖。"""
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.upsert_plugin_slot(
|
||||
plugin_name, slot_name, description, owner_id, node_affinity
|
||||
)
|
||||
|
||||
async def list_plugin_owned_names(self):
|
||||
"""枚举当前数据库中所有 plugin_owned 值(去重)。"""
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.list_plugin_owned_names()
|
||||
|
||||
async def delete_plugin_slots(self, plugin_name: str) -> int:
|
||||
"""删掉某插件登记的全部 slot,返回被删条数。"""
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.delete_plugin_slots(plugin_name)
|
||||
|
||||
# Workflow Database Methods
|
||||
async def create_workflow(
|
||||
self, trace_id: str, user_id: str, title: str, command: str
|
||||
|
||||
@@ -21,12 +21,16 @@ class AgentDef(BaseModel):
|
||||
``tools`` / ``skills`` 名字按下面顺序解析:
|
||||
1. 本组织 toolset/ 里声明的工具
|
||||
2. cabinet 全局工具白名单(python_executor 等基础工具)
|
||||
|
||||
``model`` 留空表示这是一个 **slot**:插件不指定 provider/model,由用户在前端
|
||||
Agent 设置页装配。组织实际构建 agent 时从 DB 中按 ``(plugin, slot)`` 查询用户
|
||||
配置;查不到则跳过该 slot 并日志告警。
|
||||
"""
|
||||
|
||||
name: str
|
||||
role: str = ""
|
||||
system_prompt: str = ""
|
||||
model: AgentModelRef
|
||||
model: Optional[AgentModelRef] = None
|
||||
tools: List[str] = Field(default_factory=list)
|
||||
skills: List[str] = Field(default_factory=list)
|
||||
peers: List[str] = Field(default_factory=list)
|
||||
|
||||
@@ -12,14 +12,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Type
|
||||
from ulid import ULID
|
||||
|
||||
from kilostar.plugin_runtime.event import OrgEvent, TaskState
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.agents_config import AgentsConfig, AgentDef
|
||||
from kilostar.utils.logger import get_logger
|
||||
from kilostar.utils.settings import get_artifact_dir
|
||||
from kilostar.utils.settings import get_artifact_dir, get_plugin_data_dir
|
||||
|
||||
|
||||
class BaseOrganization:
|
||||
@@ -59,6 +59,10 @@ class BaseOrganization:
|
||||
self._tools_by_name: Dict[str, Callable] = {}
|
||||
self._agents: Dict[str, Any] = {} # name -> pydantic-ai Agent
|
||||
|
||||
# 插件本地 SQLite 引擎(按需启用,调 init_local_db)
|
||||
self._engine: Any = None
|
||||
self._session_maker: Any = None
|
||||
|
||||
# ─── 生命周期 ──────────────────────────────────────────────
|
||||
|
||||
async def setup(self) -> None:
|
||||
@@ -74,6 +78,48 @@ class BaseOrganization:
|
||||
self._stopped = True
|
||||
if self._worker_task is not None:
|
||||
self._worker_task.cancel()
|
||||
if self._engine is not None:
|
||||
try:
|
||||
await self._engine.dispose()
|
||||
except Exception:
|
||||
self.logger.debug("engine dispose failed; ignored")
|
||||
|
||||
async def on_first_install(self) -> None:
|
||||
"""安装期一次性钩子:插件首次落地时被调用一次。
|
||||
|
||||
典型用途:建数据表、写默认配置、提示用户去前端做后续配置。失败会抛错并让
|
||||
plugin_manager 回滚(不写 marker,下次启动会重试)。子类按需覆盖;默认空实现。
|
||||
"""
|
||||
return None
|
||||
|
||||
async def init_local_db(self, base_classes: List[Type[Any]]) -> None:
|
||||
"""建立插件私有 SQLite 引擎并按 ``base_classes`` 的元数据建表。
|
||||
|
||||
``base_classes`` 是插件自己定义的 ``DeclarativeBase`` 子类(每个插件用独立的 Base,
|
||||
避免跟核心 PG 模型的元数据空间串场)。每次 setup 调用都安全:
|
||||
``create_all`` 是幂等的,已存在的表不会被改动。
|
||||
|
||||
建立后 ``self._session_maker`` 可用于工具/API 内部按需 ``async with sm() as s``。
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
db_path = get_plugin_data_dir(self.name) / f"{self.name}.db"
|
||||
url = f"sqlite+aiosqlite:///{db_path}"
|
||||
self._engine = create_async_engine(url, future=True)
|
||||
self._session_maker = async_sessionmaker(
|
||||
self._engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with self._engine.begin() as conn:
|
||||
for base in base_classes:
|
||||
metadata = getattr(base, "metadata", None)
|
||||
if metadata is None:
|
||||
continue
|
||||
await conn.run_sync(metadata.create_all)
|
||||
self.logger.info(f"local sqlite ready: {db_path}")
|
||||
|
||||
# ─── 对外通道 ──────────────────────────────────────────────
|
||||
|
||||
@@ -326,29 +372,62 @@ class BaseOrganization:
|
||||
全局工具白名单(``python_executor`` 等)也合并进来,给 agent 兜底。
|
||||
"""
|
||||
from pathlib import Path
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
|
||||
toolset_dir = Path(self.plugin_dir) / "toolset"
|
||||
if toolset_dir.exists() and (toolset_dir / "manifest.json").exists():
|
||||
with open(toolset_dir / "manifest.json", "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# 跟 loader._import_entry_class 共用一条虚拟 package 链:
|
||||
# ``_kilostar_plugin_<name>`` → ``.toolset``,让 ``from ._s3_common import ...``
|
||||
# 这种相对导入能正常解析。
|
||||
root_pkg = f"_kilostar_plugin_{self.name}"
|
||||
tool_pkg = f"{root_pkg}.toolset"
|
||||
if root_pkg not in sys.modules:
|
||||
root_mod = types.ModuleType(root_pkg)
|
||||
root_mod.__path__ = [str(Path(self.plugin_dir))]
|
||||
sys.modules[root_pkg] = root_mod
|
||||
if tool_pkg not in sys.modules:
|
||||
pkg = types.ModuleType(tool_pkg)
|
||||
pkg.__path__ = [str(toolset_dir)]
|
||||
sys.modules[tool_pkg] = pkg
|
||||
|
||||
# 第一遍:把 toolset 目录下所有 .py 都按文件名注册成子模块,
|
||||
# 让共享辅助模块(如 ``_s3_common``)先就位。
|
||||
for py_path in sorted(toolset_dir.glob("*.py")):
|
||||
if py_path.name == "__init__.py":
|
||||
continue
|
||||
sub_name = f"{tool_pkg}.{py_path.stem}"
|
||||
if sub_name in sys.modules:
|
||||
continue
|
||||
spec = importlib.util.spec_from_file_location(sub_name, str(py_path))
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
mod.__package__ = tool_pkg
|
||||
sys.modules[sub_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"failed to load tool module {py_path.name}: {e}")
|
||||
sys.modules.pop(sub_name, None)
|
||||
|
||||
# 第二遍:按 manifest 列表挑出工具函数
|
||||
for tool_def in manifest.get("tools", []):
|
||||
tname = tool_def.get("name")
|
||||
tfile = tool_def.get("file", f"{tname}.py")
|
||||
if not tname:
|
||||
continue
|
||||
fpath = toolset_dir / tfile
|
||||
if not fpath.exists():
|
||||
self.logger.warning(f"tool file not found: {fpath}")
|
||||
stem = Path(tfile).stem
|
||||
sub_name = f"{tool_pkg}.{stem}"
|
||||
mod = sys.modules.get(sub_name)
|
||||
if mod is None:
|
||||
self.logger.warning(f"tool module not loaded: {tfile}")
|
||||
continue
|
||||
module_name = f"data.plugin.{self.name}.toolset.{tname}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(fpath))
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
func = getattr(mod, tname, None)
|
||||
if callable(func):
|
||||
self._tools_by_name[tname] = func
|
||||
@@ -373,6 +452,11 @@ class BaseOrganization:
|
||||
每个 agent 注入:
|
||||
- 自己声明的 tools(从 ``_tools_by_name`` 取)
|
||||
- 一个特殊 ``consult`` 工具(如果 peers 非空),用于跨 agent 协作
|
||||
|
||||
provider+model 的来源:
|
||||
1. agents.json 里若已写死 ``model`` → 直接用(兼容老插件)
|
||||
2. 否则按 ``(plugin_name, slot_name)`` 查 DB,拿用户在 Agent 设置页配置的
|
||||
provider+model;查不到则跳过该 slot(日志 warning,让用户先去配置)
|
||||
"""
|
||||
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
|
||||
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
|
||||
@@ -381,10 +465,16 @@ class BaseOrganization:
|
||||
factory = AgentFactory()
|
||||
|
||||
for adef in self.agents_config.agents:
|
||||
provider = snapshot.providers.get(adef.model.provider_title)
|
||||
provider_title, model_id = await self._resolve_slot_model(adef)
|
||||
if not provider_title or not model_id:
|
||||
self.logger.warning(
|
||||
f"agent slot {adef.name!r}: provider/model 未配置(请在 Agent 设置页装配)"
|
||||
)
|
||||
continue
|
||||
provider = snapshot.providers.get(provider_title)
|
||||
if provider is None:
|
||||
self.logger.warning(
|
||||
f"provider {adef.model.provider_title!r} not found; agent {adef.name} skipped"
|
||||
f"provider {provider_title!r} not found; agent {adef.name} skipped"
|
||||
)
|
||||
continue
|
||||
tools = [
|
||||
@@ -399,7 +489,7 @@ class BaseOrganization:
|
||||
try:
|
||||
agent = factory.create_agent(
|
||||
provider=provider,
|
||||
model_id=adef.model.model_id,
|
||||
model_id=model_id,
|
||||
output_type=str,
|
||||
system_prompt=adef.system_prompt or f"You are {adef.role}.",
|
||||
deps_type=type(None),
|
||||
@@ -411,6 +501,26 @@ class BaseOrganization:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"build agent {adef.name} failed: {e}")
|
||||
|
||||
async def _resolve_slot_model(self, adef: AgentDef) -> tuple[str, str]:
|
||||
"""决定 slot 用哪个 provider+model。
|
||||
|
||||
优先静态绑定(向后兼容老插件),否则查 DB 中用户为该 slot 配置的值。
|
||||
DB 不可用时返回空——构建侧据此跳过该 slot。
|
||||
"""
|
||||
if adef.model and adef.model.provider_title and adef.model.model_id:
|
||||
return adef.model.provider_title, adef.model.model_id
|
||||
try:
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
row = await pg.find_plugin_slot.remote(self.name, adef.name)
|
||||
if row is None:
|
||||
return "", ""
|
||||
return getattr(row, "provider_title", "") or "", getattr(row, "model_id", "") or ""
|
||||
except Exception as e:
|
||||
self.logger.debug(f"slot model lookup failed (DB?): {e}")
|
||||
return "", ""
|
||||
|
||||
def _make_consult_tool(self, adef: AgentDef):
|
||||
"""为 agent 生成一个 ``consult(peer, question)`` 工具。
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[
|
||||
"""形如 ``core.organization:DataCleaningOrg`` 的入口字符串解析。
|
||||
|
||||
``:`` 左边是相对插件根的模块路径(用 / 或 . 分隔均可),右边是类名。
|
||||
会预先把插件根 + 入口模块所在子目录注册成虚拟 package,让相对导入
|
||||
(``from .db import ...``)能正常工作。
|
||||
"""
|
||||
if ":" not in entry:
|
||||
raise ValueError(f"invalid entry {entry!r}: missing ':<ClassName>'")
|
||||
@@ -91,11 +93,34 @@ def _import_entry_class(plugin_dir: Path, entry: str, plugin_name: str) -> Type[
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"plugin {plugin_name} entry file not found: {file_path}")
|
||||
|
||||
module_name = f"data.plugin.{plugin_name}.{mod_path.replace('/', '.')}"
|
||||
# 注册虚拟 root package(如 ``_kilostar_plugin_data_analytics``)+ 入口所在子包
|
||||
# (如 ``_kilostar_plugin_data_analytics.core``),这样 ``from .db import Base``
|
||||
# 才能在 spec_from_file_location 加载的模块里正常解析。
|
||||
import types as _types
|
||||
|
||||
root_pkg = f"_kilostar_plugin_{plugin_name}"
|
||||
if root_pkg not in sys.modules:
|
||||
root_mod = _types.ModuleType(root_pkg)
|
||||
root_mod.__path__ = [str(plugin_dir)]
|
||||
sys.modules[root_pkg] = root_mod
|
||||
|
||||
parts = mod_path.replace("/", ".").split(".")
|
||||
cur_pkg = root_pkg
|
||||
cur_dir = plugin_dir
|
||||
for p in parts[:-1]:
|
||||
cur_pkg = f"{cur_pkg}.{p}"
|
||||
cur_dir = cur_dir / p
|
||||
if cur_pkg not in sys.modules:
|
||||
sub_mod = _types.ModuleType(cur_pkg)
|
||||
sub_mod.__path__ = [str(cur_dir)]
|
||||
sys.modules[cur_pkg] = sub_mod
|
||||
|
||||
module_name = f"{root_pkg}.{mod_path.replace('/', '.')}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load module {module_name}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
mod.__package__ = ".".join(module_name.split(".")[:-1])
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
@@ -126,3 +151,52 @@ async def install_dependencies(deps_python: List[str]) -> None:
|
||||
f"uv pip install failed (rc={proc.returncode}): {stderr.decode()}"
|
||||
)
|
||||
logger.info(f"installed deps: {deps_python}")
|
||||
|
||||
|
||||
def discover_plugin_api(plugin_dir: Path, plugin_name: str) -> Any:
|
||||
"""加载 ``<plugin_dir>/api.py``,返回模块的 ``router`` 属性(或 None)。
|
||||
|
||||
约定:插件如需暴露 HTTP 路由,在自己根目录写一个 ``api.py``,里面实例化
|
||||
``router = APIRouter(...)`` 并按业务挂端点。主程序启动期统一以
|
||||
``manifest.api_prefix`` 把它 include 到 FastAPI app。
|
||||
"""
|
||||
api_path = plugin_dir / "api.py"
|
||||
if not api_path.exists():
|
||||
return None
|
||||
module_name = f"data.plugin.{plugin_name}.api"
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(api_path))
|
||||
if spec is None or spec.loader is None:
|
||||
logger.warning(f"plugin {plugin_name}: cannot load api.py at {api_path}")
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.warning(f"plugin {plugin_name}: api.py import failed: {e}")
|
||||
return None
|
||||
return getattr(mod, "router", None)
|
||||
|
||||
|
||||
def collect_plugin_routers(plugin_root: Path) -> List[tuple]:
|
||||
"""扫描所有插件,返回 ``[(api_prefix, router)]`` 列表。
|
||||
|
||||
用于 FastAPI 启动期统一挂载。纯文件扫描,不依赖任何 actor,避免启动顺序耦合。
|
||||
无 ``api.py`` / 加载失败 / 缺 ``api_prefix`` 的插件被静默跳过。
|
||||
"""
|
||||
out: List[tuple] = []
|
||||
for plugin_dir in discover_plugins(plugin_root):
|
||||
try:
|
||||
with open(plugin_dir / "manifest.json", "r", encoding="utf-8") as f:
|
||||
manifest = OrgManifest.model_validate(json.load(f))
|
||||
except Exception as e:
|
||||
logger.warning(f"skip plugin {plugin_dir.name} (manifest invalid): {e}")
|
||||
continue
|
||||
if not manifest.api_prefix:
|
||||
continue
|
||||
router = discover_plugin_api(plugin_dir, manifest.name)
|
||||
if router is None:
|
||||
continue
|
||||
out.append((manifest.api_prefix, router))
|
||||
logger.info(f"discovered plugin router: {manifest.name} @ {manifest.api_prefix}")
|
||||
return out
|
||||
|
||||
@@ -21,7 +21,7 @@ from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
|
||||
from kilostar.utils.logger import get_logger
|
||||
from kilostar.utils.ray_compat import _STANDALONE, actor_class
|
||||
from kilostar.utils.ray_hook import register_standalone
|
||||
from kilostar.utils.settings import get_plugin_dir
|
||||
from kilostar.utils.settings import get_plugin_data_dir, get_plugin_dir
|
||||
|
||||
logger = get_logger("plugin_manager")
|
||||
|
||||
@@ -84,15 +84,21 @@ class GlobalPluginManager:
|
||||
# ─── 查询接口 ──────────────────────────────────────────────
|
||||
|
||||
def list_plugins(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
out: List[Dict[str, Any]] = []
|
||||
plugin_root = get_plugin_dir()
|
||||
for name, info in self._orgs.items():
|
||||
manifest = info.get("manifest", {}) or {}
|
||||
ui = manifest.get("ui", {}) or {}
|
||||
wc_manifest = plugin_root / name / "frontend" / "dist" / "wc-manifest.json"
|
||||
out.append({
|
||||
"name": name,
|
||||
"display_name": info.get("display_name", name),
|
||||
"description": info.get("description", ""),
|
||||
"status": "running",
|
||||
}
|
||||
for name, info in self._orgs.items()
|
||||
]
|
||||
"has_ui": wc_manifest.exists(),
|
||||
"icon": ui.get("icon"),
|
||||
})
|
||||
return out
|
||||
|
||||
def get_dispatch_tools(self) -> Dict[str, Any]:
|
||||
"""返回所有 dispatch tools 的 {tool_name: callable} 字典。"""
|
||||
@@ -111,6 +117,23 @@ class GlobalPluginManager:
|
||||
|
||||
# 实例化 organization actor
|
||||
instance = cls(manifest_dict, agents_dict, dir_str)
|
||||
|
||||
# 一次性安装钩子:marker 文件不存在时调用 on_first_install
|
||||
marker = get_plugin_data_dir(name) / ".installed"
|
||||
first_install = not marker.exists()
|
||||
if first_install:
|
||||
try:
|
||||
await instance.on_first_install()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"plugin {name} on_first_install failed: {e}; aborting install"
|
||||
)
|
||||
raise
|
||||
marker.write_text(manifest.version, encoding="utf-8")
|
||||
|
||||
# 把 agents.json 的 slot 登记到 plugin_owned 表(best-effort,DB 不可用时静默跳过)
|
||||
await self._register_plugin_slots(name, agents_dict)
|
||||
|
||||
await instance.setup()
|
||||
|
||||
# 注册到 ray_actor_hook 命名空间
|
||||
@@ -135,3 +158,47 @@ class GlobalPluginManager:
|
||||
"actor_name": actor_name,
|
||||
}
|
||||
logger.info(f"loaded plugin: {name} (actor={actor_name})")
|
||||
|
||||
async def _register_plugin_slots(self, name: str, agents_dict: Dict[str, Any]) -> None:
|
||||
"""把插件 agents.json 中的每个 agent upsert 为一行 plugin_owned slot。
|
||||
|
||||
只刷新 description/node_affinity;用户在前端配置的 provider/model 不被覆盖。
|
||||
DB 不可用时静默跳过(standalone 启动早期 / 单测场景)。
|
||||
"""
|
||||
try:
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
for adef in agents_dict.get("agents", []):
|
||||
slot_name = adef.get("name")
|
||||
if not slot_name:
|
||||
continue
|
||||
description = adef.get("role") or adef.get("system_prompt") or slot_name
|
||||
await pg.upsert_plugin_slot.remote(
|
||||
plugin_name=name,
|
||||
slot_name=slot_name,
|
||||
description=description,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"register_plugin_slots skipped for {name}: {e}")
|
||||
|
||||
async def cleanup_orphan_plugin_slots(self) -> None:
|
||||
"""启动期兜底:DB 中存在但目录已不在的 plugin_owned slot 全部清掉。"""
|
||||
try:
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
recorded: List[str] = await pg.list_plugin_owned_names.remote() or []
|
||||
except Exception as e:
|
||||
logger.debug(f"cleanup_orphan_plugin_slots skipped: {e}")
|
||||
return
|
||||
|
||||
plugin_root = get_plugin_dir()
|
||||
present = {p.name for p in plugin_root.iterdir() if p.is_dir()} if plugin_root.exists() else set()
|
||||
for plugin_name in recorded:
|
||||
if plugin_name not in present:
|
||||
try:
|
||||
n = await pg.delete_plugin_slots.remote(plugin_name)
|
||||
logger.info(f"cleaned {n} orphan slots for missing plugin {plugin_name!r}")
|
||||
except Exception as e:
|
||||
logger.warning(f"failed to clean orphan slots for {plugin_name}: {e}")
|
||||
|
||||
@@ -86,6 +86,19 @@ def get_toolset_dir() -> "pathlib.Path":
|
||||
return project_root / "data" / "toolset"
|
||||
|
||||
|
||||
def get_plugin_data_dir(plugin_name: str) -> "pathlib.Path":
|
||||
"""返回单个插件的私有数据目录:``<plugin_dir>/<name>/_data/``。
|
||||
|
||||
放 SQLite 文件、安装 marker、本地缓存等运行时状态——跟随插件代码同根目录,
|
||||
用户备份/迁移整个插件文件夹时数据自动跟着走。目录不存在时自动创建。
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
target = get_plugin_dir() / plugin_name / "_data"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
def get_artifact_dir() -> "pathlib.Path":
|
||||
"""返回工作流产物(agent 通过 send_file 推送的文件)存放根目录。
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ description = "A multi-agent system"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
dependencies = [
|
||||
"aiobotocore>=2.13.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"alembic>=1.13.0",
|
||||
"asyncpg>=0.31.0",
|
||||
"cryptography>=42.0.0",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""``IndividualDatabase`` —— plugin_owned slot 路径单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from kilostar.core.postgres_database.module.individual import IndividualDatabase
|
||||
from kilostar.utils.error import BusinessError
|
||||
|
||||
|
||||
def _make_db():
|
||||
session = AsyncMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
session_maker = MagicMock(return_value=session)
|
||||
return IndividualDatabase(session_maker), session
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_blocks_plugin_owned_row():
|
||||
db, session = _make_db()
|
||||
fake = MagicMock()
|
||||
fake.plugin_owned = "data_analytics"
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = fake
|
||||
session.execute = AsyncMock(return_value=execute_result)
|
||||
|
||||
with pytest.raises(BusinessError, match="不可删除"):
|
||||
await db.delete_worker_individual("agent-x")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_allows_user_owned_row():
|
||||
db, session = _make_db()
|
||||
fake = MagicMock()
|
||||
fake.plugin_owned = None
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = fake
|
||||
session.execute = AsyncMock(return_value=execute_result)
|
||||
session.delete = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
ok = await db.delete_worker_individual("agent-x")
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_upsert_plugin_slot_inserts_when_missing():
|
||||
db, session = _make_db()
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = None # 不存在
|
||||
session.execute = AsyncMock(return_value=execute_result)
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
|
||||
row = await db.upsert_plugin_slot(
|
||||
plugin_name="data_analytics",
|
||||
slot_name="analyst",
|
||||
description="数据分析师",
|
||||
)
|
||||
session.add.assert_called_once()
|
||||
added = session.add.call_args[0][0]
|
||||
assert added.plugin_owned == "data_analytics"
|
||||
assert added.agent_name == "analyst"
|
||||
assert added.provider_title == "" # 等用户装配
|
||||
assert row is added
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_upsert_plugin_slot_refreshes_when_exists():
|
||||
db, session = _make_db()
|
||||
fake = MagicMock()
|
||||
fake.description = "old"
|
||||
fake.node_affinity = "cpu"
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = fake
|
||||
session.execute = AsyncMock(return_value=execute_result)
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
|
||||
await db.upsert_plugin_slot("data_analytics", "analyst", "新描述", node_affinity="gpu")
|
||||
assert fake.description == "新描述"
|
||||
assert fake.node_affinity == "gpu"
|
||||
@@ -3,9 +3,19 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from kilostar.plugin_runtime.base_organization import BaseOrganization
|
||||
from kilostar.plugin_runtime.manifest import OrgManifest
|
||||
from kilostar.plugin_runtime.agents_config import AgentsConfig
|
||||
from kilostar.plugin_runtime.loader import discover_plugins, load_plugin
|
||||
from kilostar.plugin_runtime.loader import (
|
||||
collect_plugin_routers,
|
||||
discover_plugin_api,
|
||||
discover_plugins,
|
||||
load_plugin,
|
||||
)
|
||||
from kilostar.plugin_runtime.tool_bridge import make_dispatch_tool
|
||||
|
||||
_PLUGIN_ROOT = Path(__file__).parent.parent.parent / "data" / "plugin"
|
||||
@@ -61,3 +71,198 @@ def test_make_dispatch_tool_signature():
|
||||
assert tool.__name__ == "dispatch_to_example_dept"
|
||||
assert callable(tool)
|
||||
assert "演示用" in tool.__doc__
|
||||
|
||||
|
||||
# ─── 框架层:SQLite 隔离基础设施 ─────────────────────────────────
|
||||
|
||||
|
||||
class _PluginBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class _PluginThing(_PluginBase):
|
||||
__tablename__ = "plugin_thing"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(50))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_init_local_db_creates_sqlite_with_metadata(tmp_path, monkeypatch):
|
||||
"""init_local_db 在插件 _data 目录下落 SQLite,create_all 建表,session_maker 可用。"""
|
||||
from kilostar.utils import settings as _settings_mod
|
||||
|
||||
monkeypatch.setenv("KILOSTAR_PLUGIN_DIR", str(tmp_path))
|
||||
_settings_mod.get_settings.cache_clear()
|
||||
|
||||
manifest = {"name": "tplug", "version": "0.1.0", "display_name": "测试插件"}
|
||||
agents = {
|
||||
"agents": [
|
||||
{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}
|
||||
],
|
||||
"orchestration": {"type": "react", "entry": "a"},
|
||||
}
|
||||
org = BaseOrganization(manifest, agents, str(tmp_path / "tplug"))
|
||||
try:
|
||||
await org.init_local_db([_PluginBase])
|
||||
|
||||
db_path = tmp_path / "tplug" / "_data" / "tplug.db"
|
||||
assert db_path.exists()
|
||||
assert org._engine is not None and org._session_maker is not None
|
||||
|
||||
async with org._session_maker() as session:
|
||||
session.add(_PluginThing(name="hello"))
|
||||
await session.commit()
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
async with org._session_maker() as session:
|
||||
row = (await session.execute(select(_PluginThing))).scalar_one()
|
||||
assert row.name == "hello"
|
||||
finally:
|
||||
if org._engine is not None:
|
||||
await org._engine.dispose()
|
||||
_settings_mod.get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_first_install_default_is_noop():
|
||||
"""默认 on_first_install 是空实现,子类可覆盖;调用不抛错。"""
|
||||
manifest = {"name": "noop", "version": "0.1.0"}
|
||||
agents = {
|
||||
"agents": [{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}],
|
||||
"orchestration": {"type": "react", "entry": "a"},
|
||||
}
|
||||
org = BaseOrganization(manifest, agents, "/tmp/x")
|
||||
result = await org.on_first_install()
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_install_marker_drives_on_first_install(tmp_path, monkeypatch):
|
||||
"""首次装载触发 on_first_install + 写 marker;二次装载不再触发。"""
|
||||
from kilostar.utils import settings as _settings_mod
|
||||
from kilostar.utils import ray_compat
|
||||
|
||||
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
|
||||
monkeypatch.setenv("KILOSTAR_PLUGIN_DIR", str(tmp_path))
|
||||
_settings_mod.get_settings.cache_clear()
|
||||
|
||||
# 强制走 standalone 分支,重新 import plugin_manager 以 re-decorate
|
||||
import importlib
|
||||
import kilostar.plugin_runtime.plugin_manager as pm_mod
|
||||
importlib.reload(pm_mod)
|
||||
GlobalPluginManager = pm_mod.GlobalPluginManager
|
||||
|
||||
plugin_dir = tmp_path / "demo_plug"
|
||||
(plugin_dir / "core").mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||
"name": "demo_plug",
|
||||
"version": "0.1.0",
|
||||
"display_name": "demo",
|
||||
}))
|
||||
(plugin_dir / "agents.json").write_text(json.dumps({
|
||||
"agents": [{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}],
|
||||
"orchestration": {"type": "react", "entry": "a"},
|
||||
}))
|
||||
|
||||
install_count = {"n": 0}
|
||||
|
||||
async def _no_setup(self_):
|
||||
return None
|
||||
|
||||
async def _count_install(self_):
|
||||
install_count["n"] += 1
|
||||
|
||||
monkeypatch.setattr(BaseOrganization, "setup", _no_setup)
|
||||
monkeypatch.setattr(BaseOrganization, "on_first_install", _count_install)
|
||||
|
||||
try:
|
||||
pm = GlobalPluginManager()
|
||||
await pm._install_from_path(plugin_dir)
|
||||
assert install_count["n"] == 1
|
||||
marker = tmp_path / "demo_plug" / "_data" / ".installed"
|
||||
assert marker.exists()
|
||||
|
||||
# 模拟"二次装载":清掉内存态后重装
|
||||
pm._orgs.pop("demo_plug", None)
|
||||
await pm._install_from_path(plugin_dir)
|
||||
assert install_count["n"] == 1 # 不应再触发
|
||||
finally:
|
||||
_settings_mod.get_settings.cache_clear()
|
||||
|
||||
|
||||
# ─── 框架层:插件 API router 自动挂载 ────────────────────────────
|
||||
|
||||
|
||||
def _write_plugin_skeleton(root: Path, name: str, *, with_api: bool, api_prefix: str | None) -> Path:
|
||||
plugin_dir = root / name
|
||||
(plugin_dir / "core").mkdir(parents=True)
|
||||
manifest = {
|
||||
"name": name,
|
||||
"version": "0.1.0",
|
||||
"display_name": name,
|
||||
}
|
||||
if api_prefix:
|
||||
manifest["api_prefix"] = api_prefix
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
(plugin_dir / "agents.json").write_text(json.dumps({
|
||||
"agents": [{"name": "a", "role": "r", "model": {"provider_title": "p", "model_id": "m"}}],
|
||||
"orchestration": {"type": "react", "entry": "a"},
|
||||
}))
|
||||
if with_api:
|
||||
(plugin_dir / "api.py").write_text(
|
||||
"from fastapi import APIRouter\n"
|
||||
"router = APIRouter()\n"
|
||||
"@router.get('/ping')\n"
|
||||
"async def _ping():\n"
|
||||
" return {'ok': True}\n"
|
||||
)
|
||||
return plugin_dir
|
||||
|
||||
|
||||
def test_discover_plugin_api_returns_router_when_present(tmp_path):
|
||||
plugin_dir = _write_plugin_skeleton(tmp_path, "p_with_api", with_api=True, api_prefix="/x")
|
||||
router = discover_plugin_api(plugin_dir, "p_with_api")
|
||||
assert router is not None
|
||||
assert any(r.path == "/ping" for r in router.routes)
|
||||
|
||||
|
||||
def test_discover_plugin_api_returns_none_when_missing(tmp_path):
|
||||
plugin_dir = _write_plugin_skeleton(tmp_path, "p_no_api", with_api=False, api_prefix="/x")
|
||||
assert discover_plugin_api(plugin_dir, "p_no_api") is None
|
||||
|
||||
|
||||
def test_collect_plugin_routers_filters_by_api_prefix_and_api_py(tmp_path):
|
||||
"""没 api_prefix 的不挂;没 api.py 的也不挂;都满足才返回。"""
|
||||
_write_plugin_skeleton(tmp_path, "ok", with_api=True, api_prefix="/api/v1/plugin/ok")
|
||||
_write_plugin_skeleton(tmp_path, "no_prefix", with_api=True, api_prefix=None)
|
||||
_write_plugin_skeleton(tmp_path, "no_api", with_api=False, api_prefix="/api/v1/plugin/no_api")
|
||||
|
||||
routers = collect_plugin_routers(tmp_path)
|
||||
prefixes = [p for p, _r in routers]
|
||||
assert prefixes == ["/api/v1/plugin/ok"]
|
||||
|
||||
|
||||
# ─── 框架层:plugin_owned slot ────────────────────────────
|
||||
|
||||
|
||||
def test_agent_def_model_is_optional():
|
||||
"""slot 形态:agents.json 里 model 可省略,等用户在前端装配。"""
|
||||
cfg = AgentsConfig.model_validate({
|
||||
"agents": [{"name": "analyst", "role": "数据分析师"}],
|
||||
"orchestration": {"type": "react", "entry": "analyst"},
|
||||
})
|
||||
assert cfg.agents[0].model is None
|
||||
|
||||
|
||||
def test_agent_def_model_static_still_works():
|
||||
"""老插件留一手:写死 model 仍然合法。"""
|
||||
cfg = AgentsConfig.model_validate({
|
||||
"agents": [{
|
||||
"name": "x", "role": "r",
|
||||
"model": {"provider_title": "p", "model_id": "m"},
|
||||
}],
|
||||
"orchestration": {"type": "react", "entry": "x"},
|
||||
})
|
||||
assert cfg.agents[0].model is not None
|
||||
assert cfg.agents[0].model.provider_title == "p"
|
||||
|
||||
@@ -22,6 +22,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/74/913c9b8fc566c6da650aecbddf25a5d8186b54138df265eb9eb546f56141/ag_ui_protocol-0.1.18-py3-none-any.whl", hash = "sha256:d151c0f0a34160647f1571163f7185746f4326b15a56d1560de5082a7a0e7a12", size = 12607, upload-time = "2026-04-21T20:45:00.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiobotocore"
|
||||
version = "3.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aioitertools" },
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "multidict" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
version = "3.9.0"
|
||||
@@ -140,6 +158,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3b/40a68de458904bcc143622015fff2352b6461cd92fd66d3527bf1c6f5716/aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d", size = 25231, upload-time = "2025-03-31T14:16:18.478Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aioitertools"
|
||||
version = "0.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
@@ -153,6 +180,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.18.4"
|
||||
@@ -508,30 +544,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.6"
|
||||
version = "1.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/65/47670987f2f9e181397872c7ee6415b7b95156d711b7eab6c55f66e575bc/boto3-1.43.0.tar.gz", hash = "sha256:80d44a943ef90aba7958ab31d30c155c198acc8a9581b5846b3878b2c8951086", size = 113143, upload-time = "2026-04-29T22:07:49.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl", hash = "sha256:8ebe03754a4b73a5cb6ec2f14cca03ac33bd4760d0adea53da4724845130258b", size = 140497, upload-time = "2026-04-29T22:07:46.216Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.6"
|
||||
version = "1.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2138,6 +2174,8 @@ name = "kilostar"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiobotocore" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "cryptography" },
|
||||
@@ -2170,6 +2208,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiobotocore", specifier = ">=2.13.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
||||
{ name = "alembic", specifier = ">=1.13.0" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "cryptography", specifier = ">=42.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user