Compare commits

...
3 Commits
Author SHA1 Message Date
zhaoxi aa47a19e98 存档 2026-07-01 09:22:26 +00:00
zhaoxiandClaude Opus 4.7 4aa1dab283 feat: 清理 control_node + 引入 task 一等公民
- control_node 标注 DEPRECATED:保留目录壳子供未来远程探针节点复用,删除调用路径与相关测试
- 新增 task 表:极简元数据持久化 regulatory_node 完成的短任务(出报告/写文件/查询整理)
- regulatory_node 自标注:MessageResponse 扩展 task_action/title/summary,_run 末尾非阻塞落库
- query_task_list 改查 task 表,符合用户对"任务列表"的直觉,与 workflow 体系解耦
- 新增 /api/v1/task/list|/{id} 只读 API(task 由 regulatory 内部触发,不开放对外创建)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 16:30:19 +00:00
zhaoxiandClaude Opus 4.7 005ce566a8 feat: Provider model_settings 全链路 + 监管节点工具集 + 重型插件注入 + 前端打磨
- Provider model_settings (Provider+Model 级别参数配置): DB JSONB → API → GSM → AgentFactory.resolve → 三节点 agent.run 注入
- 新增 data/toolset/regulatory_toolset/: 监管节点专属工具(query_workflow_status / query_task_list / send_file)
- send_file 从 interactive_toolset 迁移至 regulatory_toolset,interactive 仅保留 approval
- mcp_helper 合入 GlobalPluginManager dispatch tools
- 前端 Provider 弹窗参数设置区加 JSON 编辑器(model_settings)
- 前端 Plugin 页面新增"重型插件"Tab(HeavyPluginList 占位)
- .gitignore 精简:去除系统默认项,修复 data/ 子目录追踪
- data/toolset/ 与 data/plugin/ 首次纳入版本控制

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 13:10:31 +00:00
111 changed files with 6298 additions and 172 deletions
+21 -14
View File
@@ -1,18 +1,25 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.idea
# Local runtime data (MCP registry, etc.)
data/
# 项目运行时数据:默认全部忽略,仅显式开放需要纳入版本控制的子目录
data/*
!data/toolset/
!data/plugin/
data/plugin/skill/
!data/toolset/
# 插件运行时 SQLite / 状态
data/plugin/*/_data/
tmp/
.env
.idea/
.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
+1 -1
View File
@@ -147,7 +147,7 @@ KiloStar/
│ │ ├── individual/ # Agent node implementations
│ │ │ ├── consciousness_node/ # Task planning
│ │ │ ├── regulatory_node/ # Quality oversight
│ │ │ ├── control_node/ # Routing & dispatch
│ │ │ ├── control_node/ # Deprecated (name reserved for future remote-probe node)
│ │ │ └── growth_node/ # Capability expansion
│ │ ├── work/ # Work execution layer
│ │ │ ├── workflow/ # Workflow engine (pydantic-graph)
@@ -0,0 +1,26 @@
"""add model_settings JSONB column to provider table
Revision ID: 0010
Revises: 0009
Create Date: 2026-06-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"provider",
sa.Column("model_settings", JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("provider", "model_settings")
@@ -0,0 +1,46 @@
"""add task table for regulatory_node short tasks
Revision ID: 0011
Revises: 0010
Create Date: 2026-06-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"task",
sa.Column("task_id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), index=True, nullable=False),
sa.Column("chat_id", sa.String(64), index=True, nullable=True),
sa.Column("command", sa.Text(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column(
"status", sa.String(20), index=True, server_default="completed"
),
sa.Column("result_summary", sa.Text(), nullable=True),
sa.Column("artifact_refs", JSONB, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
index=True,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("task")
@@ -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> 注入 CSS(ConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
const style = document.createElement('style');
style.textContent = css;
shadow.appendChild(style);
this.mount = document.createElement('div');
this.mount.style.cssText = 'height:100%;width:100%';
shadow.appendChild(this.mount);
this.root = createRoot(this.mount);
this.render();
}
attributeChangedCallback() {
if (this.root) this.render();
}
disconnectedCallback() {
this.root?.unmount();
this.root = undefined;
}
private render() {
const token = this.getAttribute('token') ?? '';
const apiBase = this.getAttribute('api-base') ?? '';
const client = makeClient(token, apiBase);
this.root!.render(
<React.StrictMode>
<PluginContext.Provider value={{ client, token, apiBase }}>
<Dashboard pluginName="data_analytics" />
</PluginContext.Provider>
</React.StrictMode>,
);
}
}
if (!customElements.get(TAG)) {
customElements.define(TAG, DataAnalyticsElement);
}
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:把分析脚本提交到 Ray(distributed)或 subprocess(standalone)执行。
凭证以 ``AWS_*`` 环境变量注入子进程,让 boto3/pandas-s3 自然读到。
脚本走 ``kilostar.utils.sandbox.validate_python_code`` 的静态屏蔽兜底。
"""
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
from kilostar.utils.ray_compat import _STANDALONE
from kilostar.utils.sandbox import (
CodeViolation,
get_python_timeout,
validate_python_code,
)
from ._s3_common import get_s3_creds_or_raise
def _build_env(creds) -> dict:
env = os.environ.copy()
env["AWS_ACCESS_KEY_ID"] = creds["access_key"]
env["AWS_SECRET_ACCESS_KEY"] = creds["secret_key"]
env["AWS_DEFAULT_REGION"] = creds.get("region") or "us-east-1"
if creds.get("endpoint_url"):
env["AWS_ENDPOINT_URL_S3"] = creds["endpoint_url"]
env["AWS_ENDPOINT_URL"] = creds["endpoint_url"]
return env
async def ray_submit(script: str, timeout: int = 300) -> str:
"""提交 Python 脚本到 Ray(分布式)或子进程(单机)执行。
脚本中可直接 ``import boto3`` 读 S3(凭证已通过环境变量注入);可用
pandas / polars / numpy 等已安装的依赖。**只读**——不要尝试 put/delete。
Args:
script: Python 源码
timeout: 超时秒数(默认 300)
Returns:
stdout(必要时尾部追加 stderr 与 exit code)
"""
try:
script = validate_python_code(script)
except CodeViolation as e:
return f"[Sandbox] {e}"
creds = get_s3_creds_or_raise()
env = _build_env(creds)
timeout = get_python_timeout(timeout)
# standalone 与 distributed 第一版都走 subprocess,保证环境变量传递可控
# (ray.remote 跑函数时 env vars 需另装 runtime_env,复杂度跟 subprocess 持平
# 但前者透明可控,先这样落地)
tmp_file = None
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as f:
f.write(script)
tmp_file = f.name
proc = await asyncio.create_subprocess_exec(
sys.executable,
tmp_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
out = stdout.decode("utf-8", errors="replace")
err = stderr.decode("utf-8", errors="replace")
result = ""
if out:
result += out
if err:
result += f"\n[stderr]\n{err}"
if proc.returncode != 0:
result += f"\n[exit code: {proc.returncode}]"
result = result.strip() or "(no output)"
if not _STANDALONE:
result = f"[mode: ray-cluster (subprocess)]\n{result}"
return result
except asyncio.TimeoutError:
return f"[Error] ray_submit 执行超时({timeout}s)"
except Exception as e:
return f"[Error] ray_submit 失败:{e}"
finally:
if tmp_file and os.path.exists(tmp_file):
os.unlink(tmp_file)
@@ -0,0 +1,46 @@
"""s3_get_object:下载到 artifact 目录(路径强校验防穿越)。"""
from __future__ import annotations
import os
from pathlib import Path
from kilostar.utils.settings import get_artifact_dir
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
async def s3_get_object(uri: str, save_as: str) -> str:
"""把 S3 对象下载到本进程的 artifact 工作区,返回本地绝对路径。
``save_as`` 必须是相对路径,落到 ``data/artifact/data_analytics_downloads/``
下面(防越权写入任意目录)。下载后供 python_executor / ray_submit 中以
pandas/polars 读取。
Args:
uri: 形如 ``s3://bucket/key`` 的对象路径
save_as: 保存的相对文件名(不能含 ``..`` 或绝对路径)
Returns:
本地保存的绝对路径
"""
from aiobotocore.session import get_session
creds = get_s3_creds_or_raise()
bucket, key = parse_s3_uri(uri)
save_path = Path(save_as).as_posix()
if save_path.startswith("/") or ".." in save_path.split("/"):
raise ValueError(f"save_as 必须是相对、不含 .. 的路径,收到 {save_as!r}")
base = get_artifact_dir() / "data_analytics_downloads"
base.mkdir(parents=True, exist_ok=True)
target = base / save_path
target.parent.mkdir(parents=True, exist_ok=True)
session = get_session()
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
resp = await client.get_object(Bucket=bucket, Key=key)
body = await resp["Body"].read()
target.write_bytes(body)
return str(target.resolve())
@@ -0,0 +1,47 @@
"""s3_list_objects:列出 bucket+prefix 下的对象列表(key/size/last_modified)。"""
from __future__ import annotations
from typing import Any, Dict, List
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs
async def s3_list_objects(
bucket: str,
prefix: str = "",
limit: int = 50,
) -> List[Dict[str, Any]]:
"""列出 S3 bucket 下指定 prefix 的对象(最多 limit 条)。
Args:
bucket: S3 bucket 名
prefix: 对象 key 前缀,留空表示根路径
limit: 最多返回条数(1-1000),默认 50
Returns:
对象信息列表,每项含 key / size / last_modified(ISO 字符串)
"""
from aiobotocore.session import get_session
creds = get_s3_creds_or_raise()
limit = max(1, min(int(limit), 1000))
session = get_session()
out: List[Dict[str, Any]] = []
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
paginator = client.get_paginator("list_objects_v2")
async for page in paginator.paginate(
Bucket=bucket, Prefix=prefix, PaginationConfig={"MaxItems": limit}
):
for item in page.get("Contents", []) or []:
out.append({
"key": item.get("Key"),
"size": item.get("Size"),
"last_modified": (
item["LastModified"].isoformat() if item.get("LastModified") else None
),
})
if len(out) >= limit:
return out
return out
@@ -0,0 +1,35 @@
"""s3_peek:读取对象的头若干字节并尝试 UTF-8 解码(看几行用)。"""
from __future__ import annotations
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
async def s3_peek(uri: str, n_bytes: int = 4096) -> str:
"""读取 S3 对象的头 ``n_bytes`` 字节,UTF-8 解码后返回。
适合快速预览 csv/json/log 等文本类对象的开头几行。二进制内容会以
``[binary, ...]`` 占位说明返回。
Args:
uri: 形如 ``s3://bucket/key`` 的对象路径
n_bytes: 读取字节数,默认 4096,上限 1MB
Returns:
对象内容片段(解码后的字符串或占位说明)
"""
from aiobotocore.session import get_session
creds = get_s3_creds_or_raise()
bucket, key = parse_s3_uri(uri)
n = max(1, min(int(n_bytes), 1024 * 1024))
session = get_session()
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
resp = await client.get_object(Bucket=bucket, Key=key, Range=f"bytes=0-{n-1}")
body = await resp["Body"].read()
try:
text = body.decode("utf-8")
return text
except UnicodeDecodeError:
return f"[binary, {len(body)} bytes; first 64 hex] {body[:64].hex()}"
+16
View File
@@ -0,0 +1,16 @@
# 示例部门 (Example Dept)
演示用的重型插件骨架。包含两个平级 agent(analyst + executor),
可作为开发新组织插件的模板。
## 目录结构
```
example_dept/
├── manifest.json # 插件元数据
├── agents.json # agent 定义
├── core/ # 业务逻辑
├── toolset/ # 本地工具
├── skills/ # 本地技能
└── dashboard/ # 前端面板(占位)
```
+32
View File
@@ -0,0 +1,32 @@
{
"agents": [
{
"name": "analyst",
"role": "数据分析专家",
"system_prompt": "你是一位数据分析专家,负责理解用户需求并给出分析方案。",
"model": {
"provider_title": "",
"model_id": ""
},
"tools": [],
"skills": [],
"peers": ["executor"]
},
{
"name": "executor",
"role": "执行专家",
"system_prompt": "你是一位执行专家,负责将分析方案转化为具体操作。",
"model": {
"provider_title": "",
"model_id": ""
},
"tools": ["shell_executor", "python_executor"],
"skills": [],
"peers": ["analyst"]
}
],
"orchestration": {
"type": "react",
"entry": "analyst"
}
}
@@ -0,0 +1,6 @@
from kilostar.plugin_runtime.base_organization import BaseOrganization
class ExampleOrganization(BaseOrganization):
"""示例组织 — 直接使用基类的 react 逻辑。"""
pass
+19
View File
@@ -0,0 +1,19 @@
{
"name": "example_dept",
"version": "0.1.0",
"display_name": "示例部门",
"description": "演示用的重型插件骨架,可作为开发模板。",
"entry": "core.organization:ExampleOrganization",
"concurrency": "queue",
"node_affinity": "cpu",
"api_prefix": "/plugin/example_dept",
"capabilities": ["text_processing"],
"dependencies": {
"python": [],
"plugins": []
},
"ui": {
"entry": "dashboard/index.html",
"icon": null
}
}
+30
View File
@@ -0,0 +1,30 @@
# base_toolset
KiloStar 内置基础工具集。提供文件操作、命令执行、搜索等通用能力,所有 Agent 默认可用。
## 工具列表
| 工具 | 说明 |
|------|------|
| `shell_executor` | 执行 shell 命令,返回 stdout/stderr |
| `file_reader` | 读取文件内容(支持按行偏移和行数限制) |
| `edit_file` | 按 old_string → new_string 的方式精确替换文件内容 |
| `write_file` | 整体写入或覆盖文件 |
| `search_file` | 在目录树内按 glob/正则搜索文件名或内容 |
| `python_executor` | 在沙箱中运行 Python 代码片段 |
| `tavily_search` | 调用 Tavily API 进行联网搜索(需配置 `api_key`) |
## 配置说明
`tavily_search` 需要在工具配置中填入 `api_key`,可选参数:
- `max_results`:返回结果条数,默认 `5`
- `search_depth`:`basic` 或 `advanced`
- `include_answer`:是否带 LLM 摘要,默认 `true`
其他工具开箱即用,无需配置。
## 安全提示
- `shell_executor` / `python_executor` 会在受限沙箱内执行,但仍建议在受信环境下使用
- `edit_file` / `write_file` 会修改本地文件系统,注意权限范围
+17
View File
@@ -0,0 +1,17 @@
from .shell_executor import shell_executor
from .file_reader import file_reader
from .edit_file import edit_file
from .write_file import write_file
from .search_file import search_file
from .python_executor import python_executor
from .tavily_search import tavily_search
__all__ = [
"shell_executor",
"file_reader",
"edit_file",
"write_file",
"search_file",
"python_executor",
"tavily_search",
]
+43
View File
@@ -0,0 +1,43 @@
import os
async def edit_file(
file_path: str,
old_content: str,
new_content: str,
) -> str:
"""通过查找替换的方式编辑文件内容。
Args:
file_path: 文件的路径
old_content: 要被替换的原始内容片段
new_content: 替换后的新内容
Returns:
操作结果描述
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
file_path = validate_path(file_path, write=True)
except PathViolation as e:
return f"[Sandbox] {e}"
try:
if not os.path.exists(file_path):
return f"[Error] 文件不存在: {file_path}"
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if old_content not in content:
return f"[Error] 未在文件中找到要替换的内容片段"
new_file_content = content.replace(old_content, new_content, 1)
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_file_content)
return f"已成功编辑文件: {file_path}"
except Exception as e:
return f"[Error] 编辑文件失败: {e}"
+23
View File
@@ -0,0 +1,23 @@
async def file_reader(file_path: str) -> str:
"""读取本地文件的内容。
Args:
file_path: 文件的绝对路径或相对路径
Returns:
文件内容文本,若文件不存在则返回错误信息
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
file_path = validate_path(file_path, write=False)
except PathViolation as e:
return f"[Sandbox] {e}"
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return f"[Error] File not found: {file_path}"
except Exception as e:
return f"[Error] Failed to read file: {str(e)}"
+68
View File
@@ -0,0 +1,68 @@
{
"name": "基础工具集",
"version": "0.1.0",
"description": "文件读写、命令执行、Python/搜索等通用能力",
"tools": [
{
"name": "shell_executor",
"file": "shell_executor.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "file_reader",
"file": "file_reader.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "edit_file",
"file": "edit_file.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "write_file",
"file": "write_file.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "search_file",
"file": "search_file.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "python_executor",
"file": "python_executor.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "tavily_search",
"file": "tavily_search.py",
"is_system": false,
"action_scope": ["consciousness_node", "regulatory_node"],
"config_args": {
"api_key": "",
"max_results": "5",
"search_depth": "basic",
"include_answer": "true"
},
"category": "search"
}
]
}
@@ -0,0 +1,59 @@
import asyncio
import sys
import tempfile
import os
async def python_executor(code: str, timeout: int = 30) -> str:
"""执行 Python 代码片段并返回输出。
Args:
code: 要执行的 Python 代码
timeout: 超时秒数,默认 30 秒
Returns:
代码的标准输出 + 标准错误
"""
from kilostar.utils.sandbox import (
validate_python_code, CodeViolation, get_python_timeout,
)
try:
code = validate_python_code(code)
except CodeViolation as e:
return f"[Sandbox] {e}"
timeout = get_python_timeout(timeout)
tmp_file = None
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as f:
f.write(code)
tmp_file = f.name
proc = await asyncio.create_subprocess_exec(
sys.executable, tmp_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
output = stdout.decode("utf-8", errors="replace")
err_output = stderr.decode("utf-8", errors="replace")
result = ""
if output:
result += output
if err_output:
result += f"\n[stderr]\n{err_output}"
if proc.returncode != 0:
result += f"\n[exit code: {proc.returncode}]"
return result.strip() or "(no output)"
except asyncio.TimeoutError:
return f"[Error] Python 代码执行超时({timeout}s)"
except Exception as e:
return f"[Error] 执行失败: {e}"
finally:
if tmp_file and os.path.exists(tmp_file):
os.unlink(tmp_file)
+55
View File
@@ -0,0 +1,55 @@
import asyncio
async def search_file(
keyword: str,
directory: str = ".",
file_pattern: str = "*",
max_results: int = 20,
) -> str:
"""在指定目录下递归搜索包含关键字的文件内容。
Args:
keyword: 要搜索的关键字或正则表达式
directory: 搜索的根目录,默认当前目录
file_pattern: 文件名匹配模式,如 "*.py"
max_results: 最大返回结果数
Returns:
匹配的文件名和行内容
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
directory = validate_path(directory, write=False)
except PathViolation as e:
return f"[Sandbox] {e}"
max_results = min(max_results, 100)
try:
grep_args = [
"grep", "-rn",
f"--include={file_pattern}",
"-m", str(max_results),
"--", keyword, directory,
]
proc = await asyncio.create_subprocess_exec(
*grep_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=30
)
output = stdout.decode("utf-8", errors="replace").strip()
if not output:
return f"未找到包含 '{keyword}' 的匹配项"
lines = output.split("\n")
if len(lines) > max_results:
output = "\n".join(lines[:max_results])
return output
except asyncio.TimeoutError:
return "[Error] 搜索超时"
except Exception as e:
return f"[Error] 搜索失败: {e}"
@@ -0,0 +1,46 @@
import asyncio
async def shell_executor(command: str, timeout: int = 30) -> str:
"""在服务器上执行 shell 命令并返回输出。
Args:
command: 要执行的 shell 命令
timeout: 超时秒数,默认 30 秒
Returns:
命令的 stdout + stderr 输出
"""
from kilostar.utils.sandbox import (
validate_shell_command, CommandViolation, get_shell_timeout,
)
try:
command = validate_shell_command(command)
except CommandViolation as e:
return f"[Sandbox] {e}"
timeout = get_shell_timeout(timeout)
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
output = stdout.decode("utf-8", errors="replace")
err_output = stderr.decode("utf-8", errors="replace")
result = ""
if output:
result += output
if err_output:
result += f"\n[stderr]\n{err_output}"
if proc.returncode != 0:
result += f"\n[exit code: {proc.returncode}]"
return result.strip() or "(no output)"
except asyncio.TimeoutError:
return f"[Error] 命令执行超时({timeout}s)"
except Exception as e:
return f"[Error] 执行失败: {e}"
@@ -0,0 +1,78 @@
import os
from typing import Optional
from tavily import AsyncTavilyClient
async def _resolve_api_key(explicit: Optional[str]) -> Optional[str]:
"""按优先级解析 Tavily API key:显式参数 > GSM 配置 > 环境变量。"""
if explicit:
return explicit
try:
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
snapshot = await fetch_snapshot()
cfg = snapshot.tool_configs.get("tavily_search") or {}
if isinstance(cfg, dict) and cfg.get("api_key"):
return cfg["api_key"]
except Exception:
pass
return os.environ.get("TAVILY_API_KEY")
async def tavily_search(
query: str,
max_results: int = 5,
search_depth: str = "basic",
include_answer: bool = True,
api_key: Optional[str] = None,
) -> str:
"""使用 Tavily 进行网络搜索,获取高质量的网络搜索结果。
Args:
query: 搜索查询内容
max_results: 返回的最大结果数量(1-10)
search_depth: 搜索深度,"basic" 或 "advanced"
include_answer: 是否包含 AI 生成的答案摘要
api_key: 可选;不传则按 GSM 配置 → 环境变量顺序解析
Returns:
格式化的搜索结果文本,包含标题、URL、摘要和可选的 AI 答案
"""
resolved_key = await _resolve_api_key(api_key)
if not resolved_key:
return (
"[Error] Tavily API key 未配置。"
"请在 ``/api/v1/resource/tool/config`` 写入或设置环境变量 ``TAVILY_API_KEY``。"
)
try:
client = AsyncTavilyClient(api_key=resolved_key)
result = await client.search(
query=query,
max_results=min(max_results, 10),
search_depth=search_depth,
include_answer=include_answer,
)
lines = []
if include_answer and result.get("answer"):
lines.append(f"【AI 摘要】{result['answer']}\n")
results = result.get("results", [])
if not results:
return "No results found for the query."
lines.append("【搜索结果】")
for i, item in enumerate(results, 1):
title = item.get("title", "Untitled")
url = item.get("url", "")
content = item.get("content", "").strip()
lines.append(f"\n{i}. {title}")
lines.append(f" URL: {url}")
if content:
lines.append(f" {content[:300]}{'...' if len(content) > 300 else ''}")
return "\n".join(lines)
except Exception as e:
return f"[Error] Tavily search failed: {str(e)}"
+31
View File
@@ -0,0 +1,31 @@
import os
async def write_file(file_path: str, content: str) -> str:
"""将内容写入指定文件(会覆盖已有内容,自动创建目录)。
Args:
file_path: 文件的路径
content: 要写入的内容
Returns:
操作结果描述
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
file_path = validate_path(file_path, write=True)
except PathViolation as e:
return f"[Sandbox] {e}"
try:
dir_path = os.path.dirname(file_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return f"已成功写入文件: {file_path}({len(content)} 字符)"
except Exception as e:
return f"[Error] 写入文件失败: {e}"
@@ -0,0 +1,26 @@
# interactive__toolset
KiloStar 工作流交互工具集。这些工具用于 Agent 与用户/前端之间的实时交互,依赖 `global_workflow_manager` 的消息通道。
## 工具列表
| 工具 | 说明 |
|------|------|
| `approval` | 在执行高风险操作前向用户发送审批请求,阻塞等待用户回复 |
| `send_file` | 把 Agent 生成的文件作为附件推送到当前对话窗口,前端渲染为可下载卡片 |
## 使用前提
这两个工具需要工作流上下文:调用方必须在 deps 中传入 `trace_id`,工具会通过 `global_workflow_manager` 的 pending 队列与前端通信。
- 在普通聊天场景下,`send_file` 在 `trace_id` 为空时会退化为直接返回文件内容字符串
- `approval` 在没有合法 `trace_id` 时会一直阻塞,建议仅在工作流节点中绑定
## 配置说明
无需任何配置,开箱即用。
## 适用场景
- Agent 计划执行删除、转账等高风险操作前的人工确认
- 让 Agent 把生成的报告、代码片段、图表数据以文件形式投递给用户
@@ -0,0 +1,5 @@
from .approval import approval
__all__ = [
"approval",
]
@@ -0,0 +1,17 @@
from kilostar.utils.ray_hook import ray_actor_hook
async def approval(message: str, trace_id: str) -> str:
"""当任务存在某些高风险操作或者计划需要让用户审批,发送请求给用户等待用户审批。
Args:
message: 发送给用户的请求
trace_id: 当前工作流的 trace_id
Returns:
用户的审批结果
"""
actor_list = ray_actor_hook("global_workflow_manager")
await actor_list.global_workflow_manager.put_pending.remote(trace_id, message)
reply = await actor_list.global_workflow_manager.get_received.remote(trace_id)
return reply
@@ -0,0 +1,15 @@
{
"name": "交互工具集",
"version": "0.1.0",
"description": "工作流场景下与用户/前端交互的工具(HITL 审批)",
"tools": [
{
"name": "approval",
"file": "approval.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
}
]
}
@@ -0,0 +1,9 @@
from .query_workflow_status import query_workflow_status
from .query_task_list import query_task_list
from .send_file import send_file
__all__ = [
"query_workflow_status",
"query_task_list",
"send_file",
]
@@ -0,0 +1,31 @@
{
"name": "监管节点工具集",
"version": "0.1.0",
"description": "监管节点(regulatory_node)专属能力:查询工作流、查询任务列表、发送文件",
"tools": [
{
"name": "query_workflow_status",
"file": "query_workflow_status.py",
"is_system": true,
"action_scope": ["regulatory_node"],
"config_args": {},
"category": "system"
},
{
"name": "query_task_list",
"file": "query_task_list.py",
"is_system": true,
"action_scope": ["regulatory_node"],
"config_args": {},
"category": "system"
},
{
"name": "send_file",
"file": "send_file.py",
"is_system": true,
"action_scope": ["regulatory_node"],
"config_args": {},
"category": "system"
}
]
}
@@ -0,0 +1,59 @@
"""query_task_list:列出当前用户的短任务记录。
regulatory_node 用以回答"我之前那份报告呢""昨天那个查询结果是什么"。
返回 task 表中的精简元数据列表(不含工作流的 graph state、context 等)。
注:此处的 "task" 是 regulatory_node 完成的轻量短任务(出报告/写文件/查询整理等),
与 workflow(多步骤复杂任务)是两套独立体系。如需查工作流进度,使用 query_workflow_status。
"""
from typing import Any, Dict, List, Optional
from kilostar.utils.ray_hook import ray_actor_hook
async def query_task_list(
user_id: str,
status_filter: Optional[str] = None,
limit: int = 20,
) -> Dict[str, Any]:
"""列出当前用户的短任务记录,按时间倒序。
Args:
user_id: 用户 ID(通常由调用方从对话上下文中带入)
status_filter: 可选,按状态过滤(running/completed/failed)
limit: 最多返回条数,默认 20
Returns:
{
"user_id": str,
"tasks": [
{"task_id": ..., "title": ..., "status": ...,
"result_summary": ..., "created_at": ...}
],
"total": int
}
"""
pg = ray_actor_hook("postgres_database").postgres_database
rows: List[Dict[str, Any]] = await pg.list_tasks_by_user.remote(
user_id=user_id,
status=status_filter,
limit=limit,
) or []
tasks = [
{
"task_id": r.get("task_id"),
"title": r.get("title"),
"status": r.get("status"),
"result_summary": r.get("result_summary"),
"created_at": r.get("created_at"),
}
for r in rows
]
return {
"user_id": user_id,
"tasks": tasks,
"total": len(tasks),
}
@@ -0,0 +1,51 @@
"""query_workflow_status:查询某个 trace_id 对应工作流的最近事件。
regulatory_node 在与用户对话时,可以借此工具回答"我那个任务跑到哪一步了"
之类的问题。返回最近 N 条事件 + 当前工作流 status。
"""
from typing import Any, Dict, List
from kilostar.utils.ray_hook import ray_actor_hook
async def query_workflow_status(trace_id: str, limit: int = 10) -> Dict[str, Any]:
"""查询指定工作流 trace_id 的状态与最近事件。
Args:
trace_id: 工作流追踪 ID
limit: 返回最近多少条事件,默认 10
Returns:
{
"trace_id": str,
"status": str | None, # 工作流当前状态(pending/running/completed/failed)
"title": str | None,
"recent_events": [ # 最近事件,按时间倒序
{"event_type": ..., "level": ..., "message": ..., "node_name": ..., "created_at": ...}
]
}
"""
pg = ray_actor_hook("postgres_database").postgres_database
workflow = await pg.get_workflow.remote(trace_id)
events = await pg.query_event_logs.remote(trace_id=trace_id, limit=limit)
recent: List[Dict[str, Any]] = []
for e in events or []:
recent.append(
{
"event_type": getattr(e, "event_type", None),
"level": getattr(e, "level", None),
"message": getattr(e, "message", None),
"node_name": getattr(e, "node_name", None),
"created_at": str(getattr(e, "created_at", "")),
}
)
return {
"trace_id": trace_id,
"status": getattr(workflow, "status", None) if workflow else None,
"title": getattr(workflow, "title", None) if workflow else None,
"recent_events": recent,
}
@@ -0,0 +1,63 @@
"""send_file:在对话/工作流场景下投递一份文件给用户。
regulatory_node 直接对话场景下用此工具把生成的文件发给用户:
- 工作流场景(带 trace_id):写入 data/artifact/<trace_id>/,前端通过 SSE
收到带下载链接的卡片。
- 直接对话场景(无 trace_id):退化为把文件内容拼回字符串返回给 agent,
让 agent 再以代码块形式吐给用户。
"""
import json
import re
import uuid
from pathlib import Path
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.settings import get_artifact_dir
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _sanitize_filename(name: str) -> str:
name = name.strip().replace("\\", "/").split("/")[-1]
name = _SAFE_NAME_RE.sub("_", name)
return name or "file"
async def send_file(filename: str, content: str, trace_id: str = "") -> str:
"""把 agent 生成的文件作为附件投递给用户。
Args:
filename: 文件名(含扩展名),如 "report.md" / "main.py"
content: 文件内容(UTF-8 文本)
trace_id: 当前会话/工作流的 trace_id;为空时退化为直接返回内容
Returns:
发送结果说明或文件内容
"""
if not trace_id:
return f"文件 {filename} 内容如下:\n\n```\n{content}\n```"
safe_name = _sanitize_filename(filename)
artifact_id = uuid.uuid4().hex[:12]
trace_dir: Path = get_artifact_dir() / trace_id
trace_dir.mkdir(parents=True, exist_ok=True)
file_path = trace_dir / f"{artifact_id}_{safe_name}"
file_path.write_text(content, encoding="utf-8")
payload = json.dumps(
{
"type": "file",
"filename": safe_name,
"artifact_id": artifact_id,
"url": f"/api/v1/resource/artifact/{trace_id}/{artifact_id}",
"size": len(content.encode("utf-8")),
},
ensure_ascii=False,
)
actor_list = ray_actor_hook("global_workflow_manager")
await actor_list.global_workflow_manager.put_pending.remote(
trace_id, f"__FILE__{payload}"
)
return f"已发送文件: {safe_name}"
+1 -1
View File
@@ -38,7 +38,7 @@ KiloStar/
│ │ │ ├── regulatory_node/ # 监管节点:直面用户对话、质量把关
│ │ │ │ ├── regulatory_node.py
│ │ │ │ └── template.py
│ │ │ ├── control_node/ # 控制节点:工作流节点内路由调度
│ │ │ ├── control_node/ # 控制节点(已废弃,名字保留给未来远程探针节点)
│ │ │ │ ├── control_node.py
│ │ │ │ └── template.py
│ │ │ └── growth_node/ # 生长节点:能力自扩展(占位)
+1 -1
View File
@@ -64,7 +64,7 @@ Mode is set via `KILOSTAR_MODE` env var. Entry point `main.py` branches into `st
### Backend Layout (`kilostar/`)
- `api/` — FastAPI routers (auth, chat, agent, workflow, system, resource, platform)
- `core/individual/` — 4 node types: RegulatoryNode (user-facing QA), ConsciousnessNode (planning), ControlNode (routing), GrowthNode (capability expansion)
- `core/individual/` — 4 node types: RegulatoryNode (user-facing QA + short tasks), ConsciousnessNode (workflow planning), ControlNode (deprecated; name reserved for future remote-probe node), GrowthNode (capability expansion, not yet implemented)
- `core/global_state_machine/` — Provider registry, model config state
- `core/global_workflow_manager/` — Workflow queue & recovery
- `core/postgres_database/` — DAO layer: `model/` (SQLAlchemy models), `module/` (CRUD methods), `postgres.py` (facade)
+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;
@@ -37,13 +37,14 @@ export function ProvidersSettings() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
const [selectedTypeId, setSelectedTypeId] = useState<string>('openai');
const [formData, setFormData] = useState({ provider_title: '', provider_url: '', provider_apikey: '', custom_models: '' });
const [formData, setFormData] = useState({ provider_title: '', provider_url: '', provider_apikey: '', custom_models: '', model_settings: '' });
const [showAdvanced, setShowAdvanced] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; error?: string; model_count?: number } | null>(null);
const [error, setError] = useState('');
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [modelSettingsError, setModelSettingsError] = useState('');
const selectedType = PROVIDER_TYPES.find((p) => p.id === selectedTypeId) || PROVIDER_TYPES[0];
@@ -65,8 +66,9 @@ export function ProvidersSettings() {
const openAddModal = () => {
setEditingProvider(null);
setSelectedTypeId('openai');
setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '' });
setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '', model_settings: '' });
setError('');
setModelSettingsError('');
setTestResult(null);
setShowAdvanced(false);
setIsModalOpen(true);
@@ -81,8 +83,12 @@ export function ProvidersSettings() {
provider_url: provider.provider_url || '',
provider_apikey: '',
custom_models: '',
model_settings: provider.model_settings && Object.keys(provider.model_settings).length > 0
? JSON.stringify(provider.model_settings, null, 2)
: '',
});
setError('');
setModelSettingsError('');
setTestResult(null);
setShowAdvanced(false);
setIsModalOpen(true);
@@ -97,6 +103,29 @@ export function ProvidersSettings() {
setTestResult(null);
};
const parseModelSettings = (): { ok: true; value: Record<string, Record<string, unknown>> | undefined } | { ok: false } => {
const raw = formData.model_settings.trim();
if (!raw) return { ok: true, value: undefined };
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
setModelSettingsError(t('agent.providerModelSettingsInvalid'));
return { ok: false };
}
for (const v of Object.values(parsed)) {
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
setModelSettingsError(t('agent.providerModelSettingsInvalid'));
return { ok: false };
}
}
setModelSettingsError('');
return { ok: true, value: parsed as Record<string, Record<string, unknown>> };
} catch {
setModelSettingsError(t('agent.providerModelSettingsInvalid'));
return { ok: false };
}
};
const buildPayload = () => {
const customModels = formData.custom_models
.split(',').map((s) => s.trim()).filter(Boolean);
@@ -107,6 +136,8 @@ export function ProvidersSettings() {
provider_apikey: formData.provider_apikey,
};
if (customModels.length > 0) payload.custom_models = customModels;
const ms = parseModelSettings();
if (ms.ok && ms.value) payload.model_settings = ms.value;
return payload;
};
@@ -134,6 +165,8 @@ export function ProvidersSettings() {
setError(t('agent.providerFillAll'));
return;
}
const ms = parseModelSettings();
if (!ms.ok) return;
setSubmitLoading(true);
setError('');
try {
@@ -361,6 +394,31 @@ export function ProvidersSettings() {
/>
<p className="text-[10px] text-text-muted mt-1">{t('agent.providerCustomModelsHint')}</p>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('agent.providerModelSettings')}
</label>
<textarea
value={formData.model_settings}
onChange={(e) => {
setFormData({ ...formData, model_settings: e.target.value });
if (modelSettingsError) setModelSettingsError('');
}}
placeholder={t('agent.providerModelSettingsPlaceholder')}
rows={8}
className={`w-full bg-bg-input border text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 text-text-primary placeholder:text-text-muted/50 font-mono resize-none ${
modelSettingsError
? 'border-danger/50 focus:ring-danger/20 focus:border-danger'
: 'border-border-primary focus:ring-accent/20 focus:border-accent'
}`}
/>
{modelSettingsError ? (
<p className="text-[10px] text-danger mt-1">{modelSettingsError}</p>
) : (
<p className="text-[10px] text-text-muted mt-1">{t('agent.providerModelSettingsHint')}</p>
)}
</div>
</div>
)}
</div>
+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>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Boxes, Loader2, Package } from 'lucide-react';
import apiClient from '../../api/client';
interface HeavyPlugin {
name: string;
display_name: string;
description: string;
status: string;
}
export function HeavyPluginList() {
const { t } = useTranslation();
const [plugins, setPlugins] = useState<HeavyPlugin[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchPlugins = async () => {
setLoading(true);
try {
const resp = await apiClient.get('/api/v1/plugin/list');
setPlugins(resp.data.plugins || []);
setError('');
} catch (err) {
console.error('Failed to fetch heavy plugins:', err);
setError(t('plugin.heavyPluginLoadFailed'));
setPlugins([]);
} finally {
setLoading(false);
}
};
fetchPlugins();
}, [t]);
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h3 className="text-lg font-bold text-text-primary">{t('plugin.heavyPluginManagement')}</h3>
<p className="text-sm text-text-muted mt-0.5">{t('plugin.heavyPluginDesc')}</p>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Loader2 size={24} className="animate-spin mb-3" />
<span className="text-sm">{t('common.loading')}</span>
</div>
) : error ? (
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
) : plugins.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 bg-bg-card rounded-2xl border border-border-primary border-dashed text-text-muted">
<Boxes size={32} className="mb-3 opacity-40" />
<span className="text-sm">{t('plugin.heavyPluginEmpty')}</span>
<span className="text-[11px] mt-1.5 opacity-70">{t('plugin.heavyPluginEmptyHint')}</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{plugins.map((p) => (
<div key={p.name} className="bg-bg-card border border-border-primary rounded-2xl p-5 card-hover">
<div className="flex items-start gap-3 mb-3">
<div className="w-9 h-9 rounded-xl bg-bg-secondary border border-border-secondary flex items-center justify-center text-accent shrink-0">
<Package size={18} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-text-primary truncate">{p.display_name || p.name}</h4>
<span className="text-[10px] text-text-muted font-mono">{p.name}</span>
</div>
<span className={`flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-lg border ${
p.status === 'running'
? 'bg-success-bg text-success border-success/20'
: 'bg-bg-secondary text-text-muted border-border-primary'
}`}>
{p.status === 'running' && <span className="w-1 h-1 rounded-full bg-success" />}
{p.status}
</span>
</div>
{p.description && (
<p className="text-xs text-text-secondary leading-relaxed">{p.description}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles, Plug } from 'lucide-react';
import { Sparkles, Plug, Boxes } from 'lucide-react';
import { SkillSettings } from './SkillSettings';
import { MCPSettings } from './MCPSettings';
import { HeavyPluginList } from './HeavyPluginList';
type PluginTab = 'skill' | 'mcp';
type PluginTab = 'skill' | 'mcp' | 'heavy';
export function PluginLayout() {
const { t } = useTranslation();
@@ -13,6 +14,7 @@ export function PluginLayout() {
const tabs: { key: PluginTab; label: string; icon: typeof Sparkles }[] = [
{ key: 'skill', label: t('plugin.skillTab'), icon: Sparkles },
{ key: 'mcp', label: t('plugin.mcpTab'), icon: Plug },
{ key: 'heavy', label: t('plugin.heavyTab'), icon: Boxes },
];
return (
@@ -39,6 +41,7 @@ export function PluginLayout() {
<div className="flex-1 overflow-y-auto p-8">
{tab === 'skill' && <SkillSettings />}
{tab === 'mcp' && <MCPSettings />}
{tab === 'heavy' && <HeavyPluginList />}
</div>
</div>
);
+15 -1
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": {
@@ -251,7 +255,11 @@
"providerAdvanced": "Parameter Settings",
"providerCustomModels": "Custom Model List",
"providerCustomModelsPlaceholder": "Comma separated, e.g. gpt-4o, gpt-4o-mini",
"providerCustomModelsHint": "Optional. Leave empty to auto-fetch model list from provider."
"providerCustomModelsHint": "Optional. Leave empty to auto-fetch model list from provider.",
"providerModelSettings": "Model Call Parameters",
"providerModelSettingsHint": "JSON object. Keys are model_id or __default__, values are ModelSettings (temperature/max_tokens/thinking etc.). __default__ is the fallback; specific model_id overrides it.",
"providerModelSettingsPlaceholder": "{\n \"__default__\": {\"temperature\": 0.7, \"max_tokens\": 4096},\n \"o1\": {\"thinking\": \"high\"}\n}",
"providerModelSettingsInvalid": "Invalid JSON. Please check the syntax."
},
"plugin": {
"toolManagement": "Toolset Center",
@@ -284,6 +292,12 @@
"install": "Install",
"skillTab": "Skills",
"mcpTab": "MCP Servers",
"heavyTab": "Heavy Plugins",
"heavyPluginManagement": "Heavy Plugin Management",
"heavyPluginDesc": "Heavy plugins are extension modules with UI and multi-agent collaboration. Loaded from data/plugin/ directory.",
"heavyPluginEmpty": "No heavy plugins loaded",
"heavyPluginEmptyHint": "Place plugin directories in data/plugin/ and they will load on startup",
"heavyPluginLoadFailed": "Failed to load heavy plugin list",
"mcpManagement": "MCP Server Management",
"mcpDesc": "Manage Model Context Protocol servers to extend agent tools",
"mcpAdd": "Add MCP Server",
+15 -1
View File
@@ -9,6 +9,7 @@
"chat": "对话",
"workflow": "工作流",
"plugin": "插件",
"heavyPlugin": "重型插件",
"agents": "智能体",
"toolsets": "工具集",
"config": "配置",
@@ -50,6 +51,9 @@
"writeCode": "写代码",
"summarize": "总结文档",
"search": "查找资料"
},
"plugins": {
"title": "重型插件"
}
},
"workflow": {
@@ -251,7 +255,11 @@
"providerAdvanced": "参数设置",
"providerCustomModels": "自定义模型列表",
"providerCustomModelsPlaceholder": "用逗号分隔,如:gpt-4o, gpt-4o-mini",
"providerCustomModelsHint": "可选,留空则自动从供应商拉取模型清单"
"providerCustomModelsHint": "可选,留空则自动从供应商拉取模型清单",
"providerModelSettings": "模型调用参数",
"providerModelSettingsHint": "JSON 对象,键是 model_id 或 __default__,值是 ModelSettings(temperature/max_tokens/thinking 等)。__default__ 是兜底参数,具体 model_id 会覆盖之。",
"providerModelSettingsPlaceholder": "{\n \"__default__\": {\"temperature\": 0.7, \"max_tokens\": 4096},\n \"o1\": {\"thinking\": \"high\"}\n}",
"providerModelSettingsInvalid": "JSON 格式错误,请检查"
},
"plugin": {
"toolManagement": "工具集中心",
@@ -284,6 +292,12 @@
"install": "安装",
"skillTab": "技能",
"mcpTab": "MCP 服务",
"heavyTab": "重型插件",
"heavyPluginManagement": "重型插件管理",
"heavyPluginDesc": "重型插件是带 UI 与多 Agent 协作能力的扩展模块,从 data/plugin/ 目录加载",
"heavyPluginEmpty": "暂无已加载的重型插件",
"heavyPluginEmptyHint": "把插件目录放进 data/plugin/,启动时会自动加载",
"heavyPluginLoadFailed": "加载重型插件列表失败",
"mcpManagement": "MCP 服务管理",
"mcpDesc": "管理 Model Context Protocol 服务器,扩展 agent 工具能力",
"mcpAdd": "添加 MCP 服务",
+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 同一个 module(customElements 也不允许重复 define)
const loadedJs = new Set<string>();
export function HeavyPluginShell({ name }: Props) {
const setActiveHeavyPlugin = useAppStore((s) => s.setActiveHeavyPlugin);
const meta = useAppStore((s) => s.installedHeavyPlugins.find((p) => p.name === name));
const resolvedTheme = useAppStore((s) => s.resolvedTheme);
const [manifest, setManifest] = useState<UiManifest | null>(null);
const [error, setError] = useState<string | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
// 取 token:和 apiClient 拦截器同源(localStorage 'token',详见 frontend/src/api/client.ts)
const token =
typeof window !== 'undefined' ? localStorage.getItem('token') ?? '' : '';
// 1. 拉取 ui-manifest 并按需注入 JS(CSS 在 Web Component 内部 inline 注入,这里不管)
useEffect(() => {
let canceled = false;
setManifest(null);
setError(null);
(async () => {
try {
const resp = await apiClient.get<UiManifest>(`/api/v1/plugin/${name}/ui-manifest`);
const m = resp.data;
if (!loadedJs.has(m.js)) {
// 动态 import 插件 ESM bundle —— @vite-ignore 避免 vite 把这个 URL 当成构建期资源
await import(/* @vite-ignore */ m.js);
loadedJs.add(m.js);
}
if (!canceled) setManifest(m);
} catch (e: unknown) {
if (canceled) return;
const msg =
(e as { response?: { data?: { detail?: string } } }).response?.data?.detail ||
(e as Error).message ||
'failed to load plugin UI';
setError(msg);
}
})();
return () => {
canceled = true;
};
}, [name]);
// 2. 把 token / api-base / data-theme 三个属性写到自定义元素上
// 创建/复用一个元素 placeholder:每次 manifest 切换时清空 host 重新挂载
useEffect(() => {
const host = hostRef.current;
if (!host || !manifest) return;
host.innerHTML = '';
const el = document.createElement(manifest.tag);
el.setAttribute('token', token);
el.setAttribute('api-base', '');
el.setAttribute('data-theme', resolvedTheme);
(el as HTMLElement).style.cssText = 'display:block;width:100%;height:100%';
host.appendChild(el);
return () => {
host.contains(el) && host.removeChild(el);
};
}, [manifest, token, resolvedTheme]);
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-primary bg-bg-card">
<button
onClick={() => setActiveHeavyPlugin(null)}
className="flex items-center gap-1 text-xs text-text-muted hover:text-accent transition-all"
>
<ChevronLeft size={14} />
返回会话
</button>
<span className="ml-2 text-sm font-medium text-text-primary">
{meta?.display_name || manifest?.display_name || name}
</span>
</div>
<div className="flex-1 overflow-hidden relative">
{error ? (
<div className="p-8 text-danger text-sm">
插件 <code className="text-accent">{name}</code> 加载失败:{error}
</div>
) : !manifest ? (
<div className="absolute inset-0 flex items-center justify-center text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : (
<div ref={hostRef} className="w-full h-full" />
)}
</div>
</div>
);
}
+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',
+2
View File
@@ -22,6 +22,7 @@ export interface Provider {
provider_status?: string;
status?: string;
model?: string;
model_settings?: Record<string, Record<string, unknown>>;
}
export interface ProviderRegisterRequest {
@@ -29,6 +30,7 @@ export interface ProviderRegisterRequest {
provider_title: string;
provider_url: string;
provider_apikey: string;
model_settings?: Record<string, Record<string, unknown>>;
}
export interface ProviderListResponse {
+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'),
},
},
})
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Sequence, Any
from typing import Sequence, Any, Dict
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
@@ -126,3 +126,19 @@ class AgentFactory:
toolsets=toolsets or [],
)
return agent
@staticmethod
def resolve_model_settings(provider: Provider, model_id: str) -> Dict[str, Any]:
"""合并 provider.model_settings 中 ``__default__`` 与具体 model_id 的参数。
- ``__default__`` 是全 Provider 的兜底参数
- ``model_id`` 键覆盖 default 中相同 key
- 都缺省时返回空 dict(``agent.run(model_settings={})`` 等效于不传)
"""
settings = getattr(provider, "model_settings", None) or {}
if not isinstance(settings, dict):
return {}
default = settings.get("__default__", {}) or {}
specific = settings.get(model_id, {}) or {}
merged: Dict[str, Any] = {**default, **specific}
return merged
+37
View File
@@ -36,6 +36,7 @@ from .resource import resource_router
from .workflow import workflow_router
from .chat import chat_router
from .plugin import plugin_router
from .task import task_router
from kilostar.utils.error import (
KiloStarError,
BusinessError,
@@ -105,6 +106,18 @@ app.include_router(agent_router) # agent路径
app.include_router(workflow_router) # workflow路径
app.include_router(chat_router) # chat路径
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)
@@ -147,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 返回 403(plugin_owned slot 例外)。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
if not worker:
raise HTTPException(status_code=404, detail="Agent not found")
if worker.owner_id != token_data.user_id:
if not getattr(worker, "plugin_owned", None) and worker.owner_id != token_data.user_id:
raise HTTPException(
status_code=403, detail="Forbidden: You do not own this agent"
)
@@ -227,7 +237,8 @@ async def update_worker_individual(
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
if not worker:
raise HTTPException(status_code=404, detail="Agent not found")
if worker.owner_id != token_data.user_id:
# plugin_owned slot:任何登录用户都能装配 provider/model;普通 worker 仅 owner 可改
if not getattr(worker, "plugin_owned", None) and worker.owner_id != token_data.user_id:
raise HTTPException(
status_code=403, detail="Forbidden: You do not own this agent"
)
@@ -243,6 +254,14 @@ async def update_worker_individual(
except Exception:
pass
# plugin_owned 时顺带触发对应插件的 reload,让新 provider/model 立刻生效
if getattr(worker, "plugin_owned", None):
try:
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
await pm.reload.remote(worker.plugin_owned)
except Exception:
pass
return {"message": "success", "worker": updated_worker}
+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,
}
+4 -2
View File
@@ -13,8 +13,8 @@
# limitations under the License.
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Any, Dict, Literal
from pydantic import BaseModel, Field
from typing import Any, Dict, Literal, Optional
from kilostar.utils.access import TokenData, Accessor, RoleChecker
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
@@ -30,6 +30,7 @@ class ProviderRegister(BaseModel):
provider_title: str
provider_url: str
provider_apikey: str
model_settings: Optional[Dict[str, Dict[str, Any]]] = Field(default=None)
@provider_router.post("")
@@ -45,6 +46,7 @@ async def create_provider(
provider_url=provider_register.provider_url,
provider_apikey=provider_register.provider_apikey,
provider_owner=token_data.user_id,
model_settings=provider_register.model_settings or {},
)
+55
View File
@@ -0,0 +1,55 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
"""Task API:管控节点短任务的查询接口。
task 由 regulatory_node 在完成短任务时内部建立,因此本路由只暴露读取,
不开放对外创建。
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.ray_hook import ray_actor_hook
task_router = APIRouter(prefix="/api/v1/task", tags=["task"])
@task_router.get("/list")
async def list_tasks(
status: Optional[str] = None,
limit: int = 20,
offset: int = 0,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""列出当前用户的所有短任务,按时间倒序。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
tasks = await postgres_database.list_tasks_by_user.remote(
user_id=token_data.user_id,
status=status,
limit=limit,
offset=offset,
)
return {"tasks": tasks, "total": len(tasks)}
@task_router.get("/{task_id}")
async def get_task(
task_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""按 task_id 读取一条 task 详情。仅 owner 可访问。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
task = await postgres_database.get_task.remote(task_id)
if not task:
raise HTTPException(status_code=404, detail="task not found")
if task.get("user_id") != token_data.user_id:
raise HTTPException(status_code=403, detail="forbidden")
return task
@@ -184,6 +184,7 @@ class GlobalStateMachine:
provider_url,
provider_apikey,
provider_owner,
model_settings=None,
):
"""新增一个模型 Provider:内存注册 + 数据库持久化一并完成。"""
result = await self._global_provider_manager.add_provider(
@@ -193,6 +194,7 @@ class GlobalStateMachine:
provider_apikey=provider_apikey,
provider_owner=provider_owner,
postgres_database=self.postgres_database,
model_settings=model_settings or {},
)
self._publish_snapshot()
return result
@@ -13,8 +13,8 @@
# limitations under the License.
from abc import ABC, abstractmethod
from pydantic import BaseModel
from typing import List
from pydantic import BaseModel, Field
from typing import Any, Dict, List
from enum import Enum
@@ -35,6 +35,7 @@ class Provider(BaseModel):
provider_type: str
provider_owner: str | None = None
provider_status: ProviderStatus = ProviderStatus.UP
model_settings: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
class ProviderArgs(BaseModel):
@@ -44,6 +45,7 @@ class ProviderArgs(BaseModel):
provider_url: str
provider_apikey: str
provider_owner: str
model_settings: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
class BaseProvider(ABC):
@@ -78,4 +78,5 @@ class ClaudeProvider(BaseProvider):
provider_url=provider_args.provider_url,
provider_models=provider_models,
provider_type="claude",
model_settings=provider_args.model_settings,
)
@@ -81,4 +81,5 @@ class DeepseekProvider(BaseProvider):
provider_url=provider_args.provider_url,
provider_models=provider_models,
provider_type="deepseek",
model_settings=provider_args.model_settings,
)
@@ -78,4 +78,5 @@ class GeminiProvider(BaseProvider):
provider_url=provider_args.provider_url,
provider_models=provider_models,
provider_type="gemini",
model_settings=provider_args.model_settings,
)
@@ -81,4 +81,5 @@ class OpenAIProvider(BaseProvider):
provider_url=provider_args.provider_url,
provider_models=provider_models,
provider_type="openai",
model_settings=provider_args.model_settings,
)
@@ -58,6 +58,7 @@ class ProviderManager:
provider_apikey,
provider_owner,
postgres_database,
model_settings=None,
) -> None:
"""新增并落库一个 Provider:
@@ -77,6 +78,7 @@ class ProviderManager:
provider_url=provider_url,
provider_apikey=provider_apikey,
provider_owner=provider_owner,
model_settings=model_settings or {},
)
try:
import ulid
@@ -96,6 +98,7 @@ class ProviderManager:
provider_models=provider.provider_models,
provider_type=provider.provider_type,
provider_owner=provider.provider_owner,
model_settings=provider.model_settings,
)
logger.info(f"已添加适配器{provider_title}")
@@ -40,6 +40,7 @@ class ConsciousnessNode:
self.logger = get_logger("consciousness_node")
self.agent: None | Agent = None
self.locale: str = "zh"
self._model_settings: dict = {}
async def create_agent(
self,
@@ -73,6 +74,7 @@ class ConsciousnessNode:
tools=tools,
toolsets=toolsets,
)
self._model_settings = AgentFactory.resolve_model_settings(provider, model_id)
@self.agent.system_prompt
async def dynamic_prompt(ctx: RunContext[ConsciousnessNodeDeps]):
@@ -221,7 +223,7 @@ class ConsciousnessNode:
)
self.logger.debug("ConsciousnessNode: 开始生成工作流 (原生重试开启)")
prompt = "根据original_command制定严密的可执行workflow"
result = await self.agent.run(prompt, deps=deps)
result = await self.agent.run(prompt, deps=deps, model_settings=self._model_settings or None)
return result.output
elif isinstance(payload, ForWorkflowInput):
@@ -236,6 +238,7 @@ class ConsciousnessNode:
result = await self.agent.run(
f"处理此工作流步骤信息:\n{payload.workflow_step.model_dump_json()}",
deps=deps,
model_settings=self._model_settings or None,
)
return result.output
@@ -251,6 +254,7 @@ class ConsciousnessNode:
result = await self.agent.run(
f"基于以下工作流的执行记录,生成技术报告:\n{payload.workflow.model_dump_json()}",
deps=deps,
model_settings=self._model_settings or None,
)
return result.output
except Exception as e:
@@ -27,10 +27,12 @@ from kilostar.utils.prompts import agent_prompt
@actor_class
class ControlNode:
"""ControlNode(控制节点):工作流中具体子任务的执行 Actor。
"""ControlNode(控制节点):**已废弃**——名字保留给未来的远程探针/系统控制节点。
它把 ConsciousnessNode 编排出的 ``workflow_step`` 拿来当作输入,借助
pydantic-ai Agent + 已绑定的工具集合产出 ``ForWorkflow`` 结构化输出。
历史:早期设计里它是工作流的"单步执行 actor",但 workflow_engine 的 Dispatch
最终只识别 ``consciousness_node`` 和 ``skill_individual``,本类从未真正被调用过。
保留目录与类壳子,避免改名带来的 git 历史断层;**不要新增对它的依赖**。
待远程探针/监控流子项目启动时,本目录将被重写为远程机器控制节点。
"""
def __init__(self):
@@ -38,6 +40,7 @@ class ControlNode:
self.logger = get_logger("control_node")
self.agent: Agent | None = None
self._model_settings: dict = {}
async def create_agent(
self,
@@ -87,6 +90,7 @@ class ControlNode:
tools=callables,
toolsets=toolsets,
)
self._model_settings = AgentFactory.resolve_model_settings(provider, model_id)
@self.agent.system_prompt
async def dynamic_prompt(ctx: RunContext[ControlNodeDeps]):
@@ -121,6 +125,7 @@ class ControlNode:
result = await self.agent.run(
f"请根据提供的 workflow_step 上下文,执行此步骤并输出结果。\n详细指令或附加数据:{payload.workflow_step.model_dump_json()}",
deps=deps,
model_settings=self._model_settings or None,
)
return result.output
except Exception as e:
@@ -40,6 +40,7 @@ class RegulatoryNode:
self.logger = get_logger("regulatory_node")
self.agent: None | Agent = None
self._model_settings: dict = {}
async def create_agent(
self,
@@ -88,6 +89,7 @@ class RegulatoryNode:
tools=tools,
toolsets=toolsets,
)
self._model_settings = AgentFactory.resolve_model_settings(provider, model_id)
@self.agent.system_prompt
async def dynamic_prompt(ctx: RunContext[RegulatoryNodeDeps]):
@@ -178,6 +180,7 @@ class RegulatoryNode:
instructions=self._CHAT_INSTRUCTIONS,
event_stream_handler=_stream_handler,
message_history=message_history,
model_settings=self._model_settings or None,
)
except Exception as e:
self.logger.exception(f"RegulatoryNode.stream_working failed: {e}")
@@ -204,11 +207,47 @@ class RegulatoryNode:
user_prompt=message,
deps=deps,
message_history=message_history,
model_settings=self._model_settings or None,
)
response: MessageResponse = agent_response.output
response.platform = platform
response.platform_id = payload.platform_id
await self._maybe_persist_task(payload, response)
return response
except Exception as e:
self.logger.exception(f"RegulatoryNode._run failed: {e}")
return None
return None
async def _maybe_persist_task(
self, payload: MessageRequest, response: MessageResponse
) -> None:
"""LLM 自标注 task_action=create_task 时落一条 task 记录。
失败不抛错——task 表是辅助元数据,不能拖垮主回复链路。
"""
if response.task_action != "create_task":
return
if not response.task_title or not response.task_summary:
self.logger.warning(
"task_action=create_task 但 title/summary 为空,跳过落库"
)
return
try:
import uuid
from kilostar.utils.ray_hook import ray_actor_hook
postgres_database = ray_actor_hook("postgres_database").postgres_database
task_id = uuid.uuid4().hex
chat_id = payload.platform_id if payload.platform == "client" else None
await postgres_database.create_task.remote(
task_id=task_id,
user_id=payload.user_name,
command=payload.message,
title=response.task_title,
chat_id=chat_id,
status="completed",
result_summary=response.task_summary,
artifact_refs=None,
)
except Exception as e:
self.logger.warning(f"persist task failed (non-fatal): {e}")
@@ -62,3 +62,15 @@ class MessageResponse(RegulatoryNodeResponse):
platform: Optional[Literal["client", "onebot"]] = Field(description="系统自动填入的platform")
platform_id: Optional[str] = Field(description="系统自动填入的platform_id")
reply_message: str = Field(...,description="模型回复的消息")
task_action: Optional[Literal["create_task"]] = Field(
default=None,
description="本次回复是否完成了一个值得记录的短任务。生成文件/出报告/查询整理等填 'create_task',闲聊或简单问答留空。",
)
task_title: Optional[str] = Field(
default=None,
description="task 的简短标题(task_action=create_task 时必填,<=80 字)",
)
task_summary: Optional[str] = Field(
default=None,
description="task 的结果摘要(task_action=create_task 时必填,描述产出与去向)",
)
@@ -37,6 +37,7 @@ from kilostar.core.postgres_database.model.system_event_log import SystemEventLo
from kilostar.core.postgres_database.model.persona_template import PersonaTemplate
from kilostar.core.postgres_database.model.org_task import OrgTask
from kilostar.core.postgres_database.model.org_task_event import OrgTaskEvent
from kilostar.core.postgres_database.model.task import Task
# 兼容旧代码的别名
Provider = ProviderModel
@@ -69,5 +70,6 @@ __all__ = [
"PersonaTemplate",
"OrgTask",
"OrgTaskEvent",
"Task",
"AgentType",
]
@@ -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"}
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import List, Optional
from typing import Any, Dict, List, Optional
from sqlalchemy import String, Text, Boolean, text
from sqlalchemy.dialects.postgresql import JSONB # 针对供应商模型列表优化
from sqlalchemy.orm import Mapped, mapped_column
@@ -41,3 +41,8 @@ class ProviderModel(BaseDataModel):
server_default=text("true"),
comment="该服务商节点是否在线/启用",
)
model_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(
JSONB,
nullable=True,
comment="模型调用参数:{model_id 或 __default__: ModelSettings dict}",
)
@@ -0,0 +1,54 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
"""Task:管控节点(regulatory_node)完成的短任务记录。
与 workflow 不同,task 是上下文内能完成的轻量任务(写文件/查询/出报告等),
表里只存最终元数据 + 结果摘要 + 关联 artifact,不入库执行过程。
"""
from sqlalchemy import String, DateTime, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB
from .base import BaseDataModel
class Task(BaseDataModel):
__tablename__ = "task"
task_id: Mapped[str] = mapped_column(
String(64), primary_key=True, comment="任务唯一 ID(UUID)"
)
user_id: Mapped[str] = mapped_column(
String(64), index=True, comment="所属用户 ID"
)
chat_id: Mapped[str | None] = mapped_column(
String(64), index=True, nullable=True, comment="所属对话(如有)"
)
command: Mapped[str] = mapped_column(
Text, comment="用户原始指令"
)
title: Mapped[str] = mapped_column(
String(255), comment="任务简短标题(LLM 生成)"
)
status: Mapped[str] = mapped_column(
String(20), index=True, default="completed",
comment="running / completed / failed",
)
result_summary: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="完成后的结果摘要"
)
artifact_refs: Mapped[list | None] = mapped_column(
JSONB, nullable=True, comment="关联的 artifact url 列表"
)
created_at: Mapped[str] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
updated_at: Mapped[str] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
@@ -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)
@@ -69,6 +69,7 @@ class ProviderDatabase:
provider_type=provider.provider_type,
provider_owner=provider.provider_owner,
is_active=provider.is_active,
model_settings=provider.model_settings,
)
for provider in results
]
@@ -0,0 +1,104 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
"""Task DAO:管控节点短任务的最小持久化层。"""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy import select, desc, update
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from kilostar.core.postgres_database.model.task import Task
from kilostar.core.postgres_database.database_exception import database_exception
class TaskDatabase:
def __init__(self, async_session_maker: async_sessionmaker[AsyncSession]):
self.async_session_maker = async_session_maker
@database_exception
async def create_task(
self,
task_id: str,
user_id: str,
command: str,
title: str,
chat_id: Optional[str] = None,
status: str = "completed",
result_summary: Optional[str] = None,
artifact_refs: Optional[list] = None,
) -> None:
async with self.async_session_maker() as session:
row = Task(
task_id=task_id,
user_id=user_id,
chat_id=chat_id,
command=command,
title=title,
status=status,
result_summary=result_summary,
artifact_refs=artifact_refs,
)
session.add(row)
await session.commit()
@database_exception
async def update_status(
self,
task_id: str,
status: str,
result_summary: Optional[str] = None,
) -> None:
async with self.async_session_maker() as session:
values = {"status": status}
if result_summary is not None:
values["result_summary"] = result_summary
stmt = update(Task).where(Task.task_id == task_id).values(**values)
await session.execute(stmt)
await session.commit()
@database_exception
async def get_task(self, task_id: str) -> Optional[dict]:
async with self.async_session_maker() as session:
stmt = select(Task).where(Task.task_id == task_id)
row = (await session.execute(stmt)).scalar_one_or_none()
if not row:
return None
return _row_to_dict(row)
@database_exception
async def list_tasks_by_user(
self,
user_id: str,
status: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> List[dict]:
async with self.async_session_maker() as session:
stmt = select(Task).where(Task.user_id == user_id)
if status:
stmt = stmt.where(Task.status == status)
stmt = stmt.order_by(desc(Task.created_at)).offset(offset).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [_row_to_dict(r) for r in rows]
def _row_to_dict(row: Task) -> dict:
return {
"task_id": row.task_id,
"user_id": row.user_id,
"chat_id": row.chat_id,
"command": row.command,
"title": row.title,
"status": row.status,
"result_summary": row.result_summary,
"artifact_refs": row.artifact_refs or [],
"created_at": str(row.created_at) if row.created_at else None,
"updated_at": str(row.updated_at) if row.updated_at else None,
}
@@ -59,6 +59,7 @@ from .module.custom_toolset import CustomToolsetDatabase
from .module.system_event_log import SystemEventLogDatabase
from .module.persona_template import PersonaTemplateDatabase
from .module.org_task import OrgTaskDatabase
from .module.task import TaskDatabase
@actor_class
@@ -93,6 +94,7 @@ class PostgresDatabase:
self._system_event_log_database = SystemEventLogDatabase(self.async_session_maker)
self._persona_template_database = PersonaTemplateDatabase(self.async_session_maker)
self._org_task_database = OrgTaskDatabase(self.async_session_maker)
self._task_database = TaskDatabase(self.async_session_maker)
self.ready_event = asyncio.Event()
@@ -244,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
@@ -487,3 +519,49 @@ class PostgresDatabase:
async def query_org_events(self, task_id: str, limit=200):
await self.ready_event.wait()
return await self._org_task_database.query_events(task_id, limit)
# Task Methods(管控节点短任务)
async def create_task(
self,
task_id: str,
user_id: str,
command: str,
title: str,
chat_id: str | None = None,
status: str = "completed",
result_summary: str | None = None,
artifact_refs: list | None = None,
):
await self.ready_event.wait()
return await self._task_database.create_task(
task_id=task_id,
user_id=user_id,
command=command,
title=title,
chat_id=chat_id,
status=status,
result_summary=result_summary,
artifact_refs=artifact_refs,
)
async def update_task_status(
self, task_id: str, status: str, result_summary: str | None = None
):
await self.ready_event.wait()
return await self._task_database.update_status(task_id, status, result_summary)
async def get_task(self, task_id: str):
await self.ready_event.wait()
return await self._task_database.get_task(task_id)
async def list_tasks_by_user(
self,
user_id: str,
status: str | None = None,
limit: int = 20,
offset: int = 0,
):
await self.ready_event.wait()
return await self._task_database.list_tasks_by_user(
user_id=user_id, status=status, limit=limit, offset=offset
)
+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-effort,DB 不可用时静默跳过)
await self._register_plugin_slots(name, agents_dict)
await instance.setup()
# 注册到 ray_actor_hook 命名空间
@@ -135,3 +158,47 @@ class GlobalPluginManager:
"actor_name": actor_name,
}
logger.info(f"loaded plugin: {name} (actor={actor_name})")
async def _register_plugin_slots(self, name: str, agents_dict: Dict[str, Any]) -> None:
"""把插件 agents.json 中的每个 agent upsert 为一行 plugin_owned slot。
只刷新 description/node_affinity;用户在前端配置的 provider/model 不被覆盖。
DB 不可用时静默跳过(standalone 启动早期 / 单测场景)。
"""
try:
from kilostar.utils.ray_hook import ray_actor_hook
pg = ray_actor_hook("postgres_database").postgres_database
for adef in agents_dict.get("agents", []):
slot_name = adef.get("name")
if not slot_name:
continue
description = adef.get("role") or adef.get("system_prompt") or slot_name
await pg.upsert_plugin_slot.remote(
plugin_name=name,
slot_name=slot_name,
description=description,
)
except Exception as e:
logger.debug(f"register_plugin_slots skipped for {name}: {e}")
async def cleanup_orphan_plugin_slots(self) -> None:
"""启动期兜底:DB 中存在但目录已不在的 plugin_owned slot 全部清掉。"""
try:
from kilostar.utils.ray_hook import ray_actor_hook
pg = ray_actor_hook("postgres_database").postgres_database
recorded: List[str] = await pg.list_plugin_owned_names.remote() or []
except Exception as e:
logger.debug(f"cleanup_orphan_plugin_slots skipped: {e}")
return
plugin_root = get_plugin_dir()
present = {p.name for p in plugin_root.iterdir() if p.is_dir()} if plugin_root.exists() else set()
for plugin_name in recorded:
if plugin_name not in present:
try:
n = await pg.delete_plugin_slots.remote(plugin_name)
logger.info(f"cleaned {n} orphan slots for missing plugin {plugin_name!r}")
except Exception as e:
logger.warning(f"failed to clean orphan slots for {plugin_name}: {e}")

Some files were not shown because too many files have changed in this diff Show More