This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
+15 -1
View File
@@ -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
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""data_analytics 重型插件包。"""
+16
View File
@@ -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"
}
}
+150
View File
@@ -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 实现。"""
+235
View File
@@ -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> 注入 CSSConstructableStyleSheet 兼容性更好但 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);
}
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]';
},
},
},
},
});
+19
View File
@@ -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:把分析脚本提交到 Raydistributed)或 subprocessstandalone)执行。
凭证以 ``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_modifiedISO 字符串)
"""
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
View File
@@ -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;
+77 -32
View File
@@ -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>
);
}
+4
View File
@@ -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": {
+4
View File
@@ -9,6 +9,7 @@
"chat": "对话",
"workflow": "工作流",
"plugin": "插件",
"heavyPlugin": "重型插件",
"agents": "智能体",
"toolsets": "工具集",
"config": "配置",
@@ -50,6 +51,9 @@
"writeCode": "写代码",
"summarize": "总结文档",
"search": "查找资料"
},
"plugins": {
"title": "重型插件"
}
},
"workflow": {
+109
View File
@@ -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 同一个 modulecustomElements 也不允许重复 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 并按需注入 JSCSS 在 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>
);
}
+30
View File
@@ -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',
+7 -1
View File
@@ -19,7 +19,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@app/*": ["src/*"]
}
},
"include": ["src"]
}
+6
View File
@@ -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'),
},
},
})
+35
View File
@@ -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
View File
@@ -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 返回 403plugin_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}
+56
View File
@@ -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
+5 -1
View File
@@ -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)
+125 -15
View File
@@ -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)`` 工具。
+75 -1
View File
@@ -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
+73 -6
View File
@@ -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-effortDB 不可用时静默跳过)
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}")
+13
View File
@@ -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 推送的文件)存放根目录。
+2
View 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",
+86
View File
@@ -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"
+206 -1
View File
@@ -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 目录下落 SQLitecreate_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"
Generated
+46 -6
View File
@@ -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" },