Compare commits

..
4 Commits
Author SHA1 Message Date
zhaoxi 1a914a2517 chore: 迁移服务器前同步本地修改 2026-08-05 21:26:13 +08:00
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
156 changed files with 7136 additions and 2063 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/
+17 -3
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,9 +48,11 @@ 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
# Expose FastAPI port
EXPOSE 8000
# Start the application
CMD ["uv", "run", "python", "main.py"]
+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.actor import get_actor
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 get_actor("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(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(
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(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(
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(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(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(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(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,93 @@
"""ray_submit:把分析脚本提交到子进程执行。
名字沿用历史(原设计走 Ray);当前后端为本地子进程,未来要接远程算力只需替换
执行器,工具签名不变。凭证以 ``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.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 脚本到子进程执行。
脚本中可直接 ``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)"
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.actor import get_actor
async def approval(message: str, trace_id: str) -> str:
"""当任务存在某些高风险操作或者计划需要让用户审批,发送请求给用户等待用户审批。
Args:
message: 发送给用户的请求
trace_id: 当前工作流的 trace_id
Returns:
用户的审批结果
"""
gwm = get_actor("global_workflow_manager")
await gwm.put_pending(trace_id, message)
reply = await gwm.get_received(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.actor import get_actor
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 = get_actor("postgres_database")
rows: List[Dict[str, Any]] = await pg.list_tasks_by_user(
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.actor import get_actor
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 = get_actor("postgres_database")
workflow = await pg.get_workflow(trace_id)
events = await pg.query_event_logs(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.actor import get_actor
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,
)
gwm = get_actor("global_workflow_manager")
await gwm.put_pending(
trace_id, f"__FILE__{payload}"
)
return f"已发送文件: {safe_name}"
-2
View File
@@ -19,7 +19,6 @@ services:
container_name: kilostar_test
ports:
- "8000:8000"
- "8265:8265"
depends_on:
db:
condition: service_healthy
@@ -31,5 +30,4 @@ services:
POSTGRES_DB: kilostar
SECRET_KEY: test-secret-key-not-for-production
KILOSTAR_SECRET_KEY: test-secret-key-not-for-production
KILOSTAR_MODE: standalone
KILOSTAR_ENV: dev
+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)
+18 -3
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" />
{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>
+49 -4
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,7 +171,8 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
})
)
) : (
loadingWorkflows ? (
<>
{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">
@@ -190,9 +202,42 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
</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
@@ -24,8 +24,9 @@ from pydantic_ai.providers.deepseek import DeepSeekProvider
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.toolsets import AbstractToolset
from pydantic import BaseModel
from kilostar.core.global_state_machine.model_provider import Provider
from kilostar.utils.agent_model import ResponseModel, DepsModel
from kilostar.utils.error import ModelNotExistError
@@ -65,9 +66,9 @@ class AgentFactory:
self,
provider: Provider,
model_id: str,
output_type: ResponseModel,
output_type: type[BaseModel],
system_prompt: str,
deps_type: DepsModel,
deps_type: type[BaseModel],
agent_name: str,
tools: list = None,
toolsets: Sequence[AbstractToolset[Any]] = None,
@@ -126,3 +127,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
+38 -14
View File
@@ -13,19 +13,14 @@
# limitations under the License.
import os
from typing import Dict
from fastapi import FastAPI, WebSocket, Request
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from kilostar.utils.ray_compat import _STANDALONE
from kilostar.utils.settings import get_settings
if not _STANDALONE:
from ray import serve
from .agent import agent_router
from .auth import auth_router
from .system import system_router, system_api_router
@@ -36,6 +31,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 +101,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 +155,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",
@@ -184,11 +216,3 @@ else:
)
if not _STANDALONE:
@serve.deployment
@serve.ingress(app)
class KiloStarGateway:
gateway: Dict[str, WebSocket]
def __init__(self):
self.gateway = {}
+64 -45
View File
@@ -14,7 +14,7 @@
from typing import Union
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, field_validator
from kilostar.utils.access import Accessor, TokenData, RoleChecker
@@ -52,8 +52,8 @@ async def get_system_nodes(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""返回两大系统节点(regulatory/consciousness)当前的持久化配置。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
configs = await postgres_database.get_all_system_node_configs.remote()
postgres_database = get_actor("postgres_database")
configs = await postgres_database.get_all_system_node_configs()
return {"system_nodes": configs}
@@ -64,8 +64,8 @@ async def load_agent(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""加载/重载某个系统节点的 Agent:先持久化配置,再调用对应节点 Actor 的 ``create_agent``。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
postgres_database = ray_actor_hook("postgres_database").postgres_database
global_state_machine = get_actor("global_state_machine")
postgres_database = get_actor("postgres_database")
accept_lang = request.headers.get("accept-language", "")
if isinstance(agent_register, AgentLocalRegister):
@@ -73,7 +73,7 @@ async def load_agent(
elif isinstance(agent_register, AgentRegister):
try:
await postgres_database.upsert_system_node_config.remote(
await postgres_database.upsert_system_node_config(
agent_register.individual_name,
agent_register.provider_title,
agent_register.model_id,
@@ -90,14 +90,14 @@ async def load_agent(
# Resolve persona system_prompt from DB
persona_prompt = None
if agent_register.persona_id:
tpl = await postgres_database.get_template.remote(agent_register.persona_id)
tpl = await postgres_database.get_template(agent_register.persona_id)
if tpl:
persona_prompt = tpl.system_prompt
match scope:
case "regulatory_node":
node = ray_actor_hook("regulatory_node").regulatory_node
await node.create_agent.remote(
node = get_actor("regulatory_node")
await node.create_agent(
global_state_machine,
agent_register.provider_title,
agent_register.model_id,
@@ -107,8 +107,8 @@ async def load_agent(
persona_prompt,
)
case "consciousness_node":
node = ray_actor_hook("consciousness_node").consciousness_node
await node.create_agent.remote(
node = get_actor("consciousness_node")
await node.create_agent(
global_state_machine,
agent_register.provider_title,
agent_register.model_id,
@@ -181,10 +181,10 @@ async def create_worker_individual(
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""创建一个 Worker Agent,``owner_id`` 自动绑定为当前登录用户。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
postgres_database = get_actor("postgres_database")
data_dict = worker_data.model_dump()
data_dict["owner_id"] = token_data.user_id
worker = await postgres_database.add_worker_individual.remote(**data_dict)
worker = await postgres_database.add_worker_individual(**data_dict)
return {"message": "success", "agent_id": worker.agent_id}
@@ -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。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
workers = await postgres_database.get_worker_individual_list.remote(
"""列出当前登录用户名下的全部 Worker Agent,并附加所有 plugin_owned slot。
plugin_owned slot 是插件登记的"占位 agent",所有用户共享同一份配置,
在前端展示时会用徽标标记,并允许任何登录用户装配 provider/model。
"""
postgres_database = get_actor("postgres_database")
workers = await postgres_database.get_worker_individual_list(
owner_id=token_data.user_id
)
return {"workers": workers}
) or []
all_workers = await postgres_database.get_all_worker_individual() 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。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
"""按 ``agent_id`` 查询 Worker Agent;非本人的 Agent 返回 403(plugin_owned slot 例外)。"""
postgres_database = get_actor("postgres_database")
worker = await postgres_database.get_worker_individual(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"
)
@@ -223,23 +233,32 @@ async def update_worker_individual(
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""局部更新 Worker Agent 配置;同时把状态机里的旧实例移除等待懒加载。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
postgres_database = get_actor("postgres_database")
worker = await postgres_database.get_worker_individual(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"
)
update_data = worker_data.model_dump(exclude_unset=True)
updated_worker = await postgres_database.update_worker_individual.remote(
updated_worker = await postgres_database.update_worker_individual(
agent_id=agent_id, **update_data
)
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
global_state_machine = get_actor("global_state_machine")
try:
await global_state_machine.remove_individual.remote(agent_id)
await global_state_machine.remove_individual(agent_id)
except Exception:
pass
# plugin_owned 时顺带触发对应插件的 reload,让新 provider/model 立刻生效
if getattr(worker, "plugin_owned", None):
try:
pm = get_actor("global_plugin_manager")
await pm.reload(worker.plugin_owned)
except Exception:
pass
@@ -251,8 +270,8 @@ async def reload_worker_individual(
agent_id: str, token_data: TokenData = Depends(Accessor.get_current_user)
):
"""强制把 Worker 从内存池中卸载,下次调用时按最新配置重新加载。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
postgres_database = get_actor("postgres_database")
worker = await postgres_database.get_worker_individual(agent_id=agent_id)
if not worker:
raise HTTPException(status_code=404, detail="Agent not found")
if worker.owner_id != token_data.user_id:
@@ -260,8 +279,8 @@ async def reload_worker_individual(
status_code=403, detail="Forbidden: You do not own this agent"
)
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.remove_individual.remote(agent_id)
global_state_machine = get_actor("global_state_machine")
await global_state_machine.remove_individual(agent_id)
return {"message": "Worker will be reloaded on next use"}
@@ -271,15 +290,15 @@ async def delete_worker_individual(
agent_id: str, token_data: TokenData = Depends(Accessor.get_current_user)
):
"""删除 Worker Agent;非本人 Agent 返回 403。"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
postgres_database = get_actor("postgres_database")
worker = await postgres_database.get_worker_individual(agent_id=agent_id)
if not worker:
raise HTTPException(status_code=404, detail="Agent not found")
if worker.owner_id != token_data.user_id:
raise HTTPException(
status_code=403, detail="Forbidden: You do not own this agent"
)
await postgres_database.delete_worker_individual.remote(agent_id=agent_id)
await postgres_database.delete_worker_individual(agent_id=agent_id)
return {"message": "success"}
@@ -299,8 +318,8 @@ class PersonaTemplateUpdate(BaseModel):
async def list_templates(
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
templates = await postgres_database.list_templates.remote(
postgres_database = get_actor("postgres_database")
templates = await postgres_database.list_templates(
owner_id=token_data.user_id
)
return {"templates": templates}
@@ -311,8 +330,8 @@ async def create_template(
data: PersonaTemplateCreate,
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
tpl = await postgres_database.add_template.remote(
postgres_database = get_actor("postgres_database")
tpl = await postgres_database.add_template(
name=data.name,
system_prompt=data.system_prompt,
owner_id=token_data.user_id,
@@ -326,13 +345,13 @@ async def update_template(
data: PersonaTemplateUpdate,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
tpl = await postgres_database.get_template.remote(template_id)
postgres_database = get_actor("postgres_database")
tpl = await postgres_database.get_template(template_id)
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
if tpl.owner_id != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
updated = await postgres_database.update_template.remote(
updated = await postgres_database.update_template(
template_id, **data.model_dump(exclude_unset=True)
)
return {"message": "success", "template": updated}
@@ -343,11 +362,11 @@ async def delete_template(
template_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
tpl = await postgres_database.get_template.remote(template_id)
postgres_database = get_actor("postgres_database")
tpl = await postgres_database.get_template(template_id)
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
if tpl.owner_id != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
await postgres_database.delete_template.remote(template_id)
await postgres_database.delete_template(template_id)
return {"message": "success"}
+11 -11
View File
@@ -17,7 +17,7 @@ from fastapi import Depends
from pydantic import BaseModel
from kilostar.utils.access import Accessor, TokenData, RoleChecker
from fastapi.concurrency import run_in_threadpool
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.utils.error import UserNotExistError
from kilostar.utils.rate_limit import register_limiter, login_limiter
@@ -36,7 +36,7 @@ class UserRegister(BaseModel):
async def create_user(user_register: UserRegister, request: Request):
"""注册新用户:异步线程池里做 argon2 哈希,再交由 PostgresDatabase Actor 落库。"""
register_limiter.check(request)
postgres_database = ray_actor_hook("postgres_database").postgres_database
postgres_database = get_actor("postgres_database")
try:
hashed_password = await run_in_threadpool(
Accessor.hash_password, user_register.password
@@ -47,7 +47,7 @@ async def create_user(user_register: UserRegister, request: Request):
status_code=400,
content={"code": "password_invalid", "message": str(e)},
)
user = await postgres_database.add_user.remote(
user = await postgres_database.add_user(
user_register.user_name, hashed_password
)
return {"message": "success", "user_id": user.user_id}
@@ -64,8 +64,8 @@ class UserLogin(BaseModel):
async def login_user(user_login: UserLogin, request: Request):
"""用户登录:查询用户后在线程池中校验口令,校验成功则签发 JWT。"""
login_limiter.check(request)
postgres_database = ray_actor_hook("postgres_database").postgres_database
user = await postgres_database.login_user.remote(user_login.user_name)
postgres_database = get_actor("postgres_database")
user = await postgres_database.login_user(user_login.user_name)
if not user:
raise UserNotExistError()
tokens = await run_in_threadpool(
@@ -113,8 +113,8 @@ async def change_authority(
"""
Update a user's authority level. Only accessible by SUPER_ADMINISTRATOR.
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
user = await postgres_database.change_user_authority.remote(
postgres_database = get_actor("postgres_database")
user = await postgres_database.change_user_authority(
user_id=request.user_id, new_authority=request.new_authority
)
return {
@@ -133,8 +133,8 @@ async def get_user_list(
"""
Get a list of all users. Only accessible by SUPER_ADMINISTRATOR.
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
users = await postgres_database.get_all_users.remote()
postgres_database = get_actor("postgres_database")
users = await postgres_database.get_all_users()
return {
"users": [
{"user_id": u.user_id, "user_name": u.user_name, "role": u.user_authority}
@@ -153,6 +153,6 @@ async def delete_user(
"""
Delete a user. Only accessible by SUPER_ADMINISTRATOR.
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
await postgres_database.delete_user_by_id.remote(user_id=user_id)
postgres_database = get_actor("postgres_database")
await postgres_database.delete_user_by_id(user_id=user_id)
return {"message": "success"}
+27 -40
View File
@@ -17,7 +17,7 @@ import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
from kilostar.utils.access import Accessor, TokenData
from kilostar.core.individual.regulatory_node.template import (
MessageRequest,
@@ -56,8 +56,8 @@ def _build_message_history(rows) -> list:
async def _load_message_history(chat_id: str) -> list:
postgres_database = ray_actor_hook("postgres_database").postgres_database
rows = await postgres_database.list_chat_messages.remote(chat_id=chat_id)
postgres_database = get_actor("postgres_database")
rows = await postgres_database.list_chat_messages(chat_id=chat_id)
return _build_message_history(rows or [])
@@ -76,14 +76,14 @@ async def _ask_regulatory(
*, user_id: str, chat_id: str, message: str, message_history: list | None = None
) -> str | None:
"""统一封装 chat 入口对 RegulatoryNode 的调用。"""
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
regulatory_node = get_actor("regulatory_node")
payload = MessageRequest(
platform="client",
user_name=user_id,
platform_id=chat_id,
message=message,
)
resp: MessageResponse | None = await regulatory_node.working.remote(
resp: MessageResponse | None = await regulatory_node.working(
payload, message_history
)
return _extract_reply(resp)
@@ -103,13 +103,13 @@ async def create_chat_session(
request: CreateChatRequest,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
chat = await postgres_database.create_chat_session.remote(
postgres_database = get_actor("postgres_database")
chat = await postgres_database.create_chat_session(
user_id=token_data.user_id, title=request.title
)
# 存入用户消息
await postgres_database.add_chat_message.remote(
await postgres_database.add_chat_message(
chat_id=chat.chat_id, message=request.initial_message, message_owner="user"
)
@@ -122,7 +122,7 @@ async def create_chat_session(
# 存入回复消息
if response_msg:
await postgres_database.add_chat_message.remote(
await postgres_database.add_chat_message(
chat_id=chat.chat_id, message=response_msg, message_owner="regulatory_node"
)
@@ -133,8 +133,8 @@ async def create_chat_session(
async def list_chat_sessions(
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
sessions = await postgres_database.list_chat_sessions.remote(
postgres_database = get_actor("postgres_database")
sessions = await postgres_database.list_chat_sessions(
user_id=token_data.user_id
)
return {"sessions": sessions}
@@ -144,8 +144,8 @@ async def list_chat_sessions(
async def get_chat_history(
chat_id: str, token_data: TokenData = Depends(Accessor.get_current_user)
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
messages = await postgres_database.list_chat_messages.remote(chat_id=chat_id)
postgres_database = get_actor("postgres_database")
messages = await postgres_database.list_chat_messages(chat_id=chat_id)
return {"messages": messages}
@@ -155,10 +155,10 @@ async def send_chat_message(
request: SendMessageRequest,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
postgres_database = get_actor("postgres_database")
# 先取历史(不含当前输入),再写入用户消息,避免历史里出现重复
message_history = await _load_message_history(chat_id)
await postgres_database.add_chat_message.remote(
await postgres_database.add_chat_message(
chat_id=chat_id, message=request.message, message_owner="user"
)
@@ -172,7 +172,7 @@ async def send_chat_message(
# 存回复
if response_msg:
await postgres_database.add_chat_message.remote(
await postgres_database.add_chat_message(
chat_id=chat_id, message=response_msg, message_owner="regulatory_node"
)
@@ -184,13 +184,13 @@ async def delete_chat_session(
chat_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
session = await postgres_database.get_chat_session.remote(chat_id=chat_id)
postgres_database = get_actor("postgres_database")
session = await postgres_database.get_chat_session(chat_id=chat_id)
if not session:
raise HTTPException(status_code=404, detail="Chat session not found")
if session.user_id != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
await postgres_database.delete_chat_session.remote(chat_id=chat_id)
await postgres_database.delete_chat_session(chat_id=chat_id)
return {"message": "success"}
@@ -201,14 +201,12 @@ async def stream_chat_message(
request: Request,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""SSE 流式聊天端点:standalone 模式下逐 token 流式输出;distributed 模式 fallback 到整段回复。"""
from kilostar.utils.ray_compat import _STANDALONE
postgres_database = ray_actor_hook("postgres_database").postgres_database
"""SSE 流式聊天端点:逐 token 流式输出。"""
postgres_database = get_actor("postgres_database")
message_history = await _load_message_history(chat_id)
await postgres_database.add_chat_message.remote(
await postgres_database.add_chat_message(
chat_id=chat_id, message=request_body.message, message_owner="user"
)
@@ -219,23 +217,12 @@ async def stream_chat_message(
message=request_body.message,
)
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
if not _STANDALONE:
async def fallback_generator():
resp = await regulatory_node.working.remote(payload, message_history)
full_response = resp.reply_message if resp else ""
if full_response:
await postgres_database.add_chat_message.remote(
chat_id=chat_id, message=full_response, message_owner="regulatory_node"
)
yield f"data: {json.dumps({'token': full_response})}\n\n"
yield f"data: {json.dumps({'done': True, 'full_message': full_response})}\n\n"
return StreamingResponse(fallback_generator(), media_type="text/event-stream")
regulatory_node = get_actor("regulatory_node")
token_queue = asyncio.Queue()
stream_task = regulatory_node.stream_working.remote(payload, token_queue, message_history)
stream_task = asyncio.create_task(
regulatory_node.stream_working(payload, token_queue, message_history)
)
async def event_generator():
full_response = ""
@@ -260,7 +247,7 @@ async def stream_chat_message(
yield f"data: {json.dumps({'token': full_response})}\n\n"
if full_response:
await postgres_database.add_chat_message.remote(
await postgres_database.add_chat_message(
chat_id=chat_id,
message=full_response,
message_owner="regulatory_node",
+3 -3
View File
@@ -15,7 +15,7 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
from kilostar.core.individual.regulatory_node.template import (
MessageRequest,
MessageResponse,
@@ -42,14 +42,14 @@ async def create_message(
"""把前端消息转交给 RegulatoryNode 处理,并把回复透传给前端。"""
logger.info("收到消息,来源:客户端")
logger.debug(f"消息内容:{message.message}")
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
regulatory_node = get_actor("regulatory_node")
msg_request = MessageRequest(
platform="client",
user_name=token_data.username,
platform_id=token_data.user_id,
message=message.message,
)
result = await regulatory_node.working.remote(msg_request)
result = await regulatory_node.working(msg_request)
if isinstance(result, MessageResponse):
return {"message": result.reply_message}
if isinstance(result, str):
+3 -3
View File
@@ -39,7 +39,7 @@ from kilostar.core.individual.regulatory_node.template import (
MessageResponse,
)
from kilostar.utils.logger import get_logger
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
logger = get_logger("onebot")
@@ -129,8 +129,8 @@ async def _dispatch_event(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
)
try:
regulatory_node = ray_actor_hook("regulatory_node").regulatory_node
result = await regulatory_node.working.remote(msg_request)
regulatory_node = get_actor("regulatory_node")
result = await regulatory_node.working(msg_request)
except Exception as e:
logger.exception(f"[OneBot] RegulatoryNode 调用失败: {e}")
return None
+77 -23
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.actor import get_actor
from kilostar.utils.settings import get_plugin_dir
plugin_router = APIRouter(prefix="/api/v1/plugin", tags=["plugin"])
@@ -22,15 +25,16 @@ async def submit_task(
req: SubmitRequest,
token_data: TokenData = Depends(Accessor.get_current_user),
):
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
plugins = await pm.list_plugins.remote()
if req.org_name not in plugins:
pm = get_actor("global_plugin_manager")
plugins = await pm.list_plugins()
# list_plugins 返回 [{"name": ..., ...}],按 name 判断插件是否已加载
if req.org_name not in {p["name"] for p in plugins}:
raise HTTPException(404, f"Plugin '{req.org_name}' not found")
org = ray_actor_hook(f"org_{req.org_name}").get(f"org_{req.org_name}")
org = get_actor(f"org_{req.org_name}")
ctx = req.context or {}
ctx["user"] = token_data.username
task_id = await org.submit.remote(req.task_description, ctx)
task_id = await org.submit(req.task_description, ctx)
return {"task_id": task_id}
@@ -39,8 +43,8 @@ async def get_task_status(
task_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
db = ray_actor_hook("postgres_database").postgres_database
task = await db.get_org_task.remote(task_id)
db = get_actor("postgres_database")
task = await db.get_org_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
return task
@@ -51,8 +55,8 @@ async def get_task_events(
task_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
db = ray_actor_hook("postgres_database").postgres_database
events = await db.query_org_events.remote(task_id)
db = get_actor("postgres_database")
events = await db.query_org_events(task_id)
return {"events": events}
@@ -61,19 +65,16 @@ async def stream_task(
task_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
import asyncio
org_name = None
db = ray_actor_hook("postgres_database").postgres_database
task = await db.get_org_task.remote(task_id)
db = get_actor("postgres_database")
task = await db.get_org_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
org_name = task["org_name"]
org = ray_actor_hook(f"org_{org_name}").get(f"org_{org_name}")
org = get_actor(f"org_{org_name}")
async def _generate():
async for event in await org.stream.remote(task_id):
async for event in await org.stream(task_id):
yield f"data: {event}\n\n"
return StreamingResponse(_generate(), media_type="text/event-stream")
@@ -83,8 +84,8 @@ async def stream_task(
async def list_plugins(
token_data: TokenData = Depends(Accessor.get_current_user),
):
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
plugins = await pm.list_plugins.remote()
pm = get_actor("global_plugin_manager")
plugins = await pm.list_plugins()
return {"plugins": plugins}
@@ -93,8 +94,8 @@ async def install_plugin(
name: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
await pm.install.remote(name)
pm = get_actor("global_plugin_manager")
await pm.install(name)
return {"status": "ok", "name": name}
@@ -103,6 +104,59 @@ async def reload_plugin(
name: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
pm = ray_actor_hook("global_plugin_manager").global_plugin_manager
await pm.reload.remote(name)
pm = get_actor("global_plugin_manager")
await pm.reload(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,
}
+11 -9
View File
@@ -13,12 +13,12 @@
# 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
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
provider_router = APIRouter(prefix="/api/v1/provider", tags=["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("")
@@ -38,13 +39,14 @@ async def create_provider(
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
) -> None:
"""注册一个 Provider;owner 为当前登录用户的 ``user_id``。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.add_provider_wrap.remote(
global_state_machine = get_actor("global_state_machine")
await global_state_machine.add_provider_wrap(
provider_type=provider_register.provider_type,
provider_title=provider_register.provider_title,
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 {},
)
@@ -59,10 +61,10 @@ async def get_provider_list(
_: TokenData = Depends(Accessor.get_current_user),
) -> Dict[str, Any]:
"""返回当前所有已注册的 Provider,前端用以展示模型清单。apikey 脱敏。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
global_state_machine = get_actor("global_state_machine")
provider_list: Dict[
str, Provider
] = await global_state_machine.get_provider_list.remote()
] = await global_state_machine.get_provider_list()
masked = {}
for title, p in provider_list.items():
d = p.model_dump() if hasattr(p, "model_dump") else dict(p)
@@ -136,6 +138,6 @@ async def delete_provider(
),
) -> dict:
"""删除指定 ``provider_title`` 的 Provider;仅超管可调用。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.delete_provider.remote(provider_title=provider_title)
global_state_machine = get_actor("global_state_machine")
await global_state_machine.delete_provider(provider_title=provider_title)
return {"message": "success"}
+42 -42
View File
@@ -15,7 +15,7 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
import viceroy
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from kilostar.utils.access import TokenData, RoleChecker, Accessor
@@ -50,7 +50,7 @@ async def install_skill(
skill: Skill, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))
):
"""通过 viceroy 把 skill 仓库克隆到 ``data/plugin/skill``,并在状态机中登记。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
global_state_machine = get_actor("global_state_machine")
import os
from kilostar.utils.settings import get_plugin_dir
@@ -63,7 +63,7 @@ async def install_skill(
skill_name = skill.path.split("/")[-1]
else:
skill_name = skill.repo_url.split("/")[-1]
await global_state_machine.add_skill.remote(skill_name)
await global_state_machine.add_skill(skill_name)
return {"message": "创建成功"}
@@ -72,8 +72,8 @@ async def get_skills(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""返回当前状态机中已登记的所有 skill 名称列表。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
skills = await global_state_machine.get_skill_list.remote()
global_state_machine = get_actor("global_state_machine")
skills = await global_state_machine.get_skill_list()
return {"skills": skills}
@@ -85,8 +85,8 @@ async def delete_skill(
),
):
"""从状态机中移除 skill 注册项;不会删除磁盘上的代码文件。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.remove_skill.remote(skill_name)
global_state_machine = get_actor("global_state_machine")
await global_state_machine.remove_skill(skill_name)
return {"message": "success"}
@@ -98,12 +98,12 @@ async def add_mcp_server(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""注册一个 MCP 服务器到全局状态机。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
global_state_machine = get_actor("global_state_machine")
import uuid
server_id = str(uuid.uuid4())[:8]
cfg_dict = config.model_dump(exclude_none=True)
await global_state_machine.add_mcp_server.remote(server_id, cfg_dict)
await global_state_machine.add_mcp_server(server_id, cfg_dict)
return {"server_id": server_id, "message": "MCP server registered"}
@@ -112,8 +112,8 @@ async def list_mcp_servers(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""返回已注册的全部 MCP 服务器配置;env 中的敏感字段脱敏。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
servers = await global_state_machine.list_mcp_servers.remote()
global_state_machine = get_actor("global_state_machine")
servers = await global_state_machine.list_mcp_servers()
for s in servers:
if "env" in s and isinstance(s["env"], dict):
s["env"] = _mask_config(s["env"])
@@ -126,8 +126,8 @@ async def delete_mcp_server(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""从状态机中移除一个 MCP 服务器配置。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
ok = await global_state_machine.delete_mcp_server.remote(server_id)
global_state_machine = get_actor("global_state_machine")
ok = await global_state_machine.delete_mcp_server(server_id)
if not ok:
raise HTTPException(status_code=404, detail="MCP server not found")
return {"message": "success"}
@@ -152,8 +152,8 @@ async def download_artifact(
if not artifact_id.isalnum() or len(artifact_id) > 32:
raise HTTPException(status_code=400, detail="invalid artifact id")
postgres_database = ray_actor_hook("postgres_database").postgres_database
wf = await postgres_database.get_workflow.remote(trace_id)
postgres_database = get_actor("postgres_database")
wf = await postgres_database.get_workflow(trace_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if getattr(wf, "user_id", None) != token_data.user_id:
@@ -187,8 +187,8 @@ async def list_toolset_packages(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""列出所有磁盘上的工具包(``data/toolset/<name>/`` 单元)。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
packages = await global_state_machine.list_toolset_packages.remote()
global_state_machine = get_actor("global_state_machine")
packages = await global_state_machine.list_toolset_packages()
return {"packages": packages}
@@ -198,8 +198,8 @@ async def get_toolset_package_readme(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""返回指定工具包的 README.md 内容(markdown 文本)。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
content = await global_state_machine.get_toolset_package_readme.remote(name)
global_state_machine = get_actor("global_state_machine")
content = await global_state_machine.get_toolset_package_readme(name)
if content is None:
raise HTTPException(status_code=404, detail="README not found")
return {"name": name, "content": content}
@@ -216,9 +216,9 @@ async def get_tools(
其中 ``mcp_servers`` 会现场尝试连接每个已注册的 MCP 服务器并列出它们暴露的
工具名,便于前端展示;任意一台 MCP server 不可达不影响其他工具的返回。
"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
tool_mapper = await global_state_machine.get_tool_mapper.remote()
categories = await global_state_machine.get_tool_categories.remote()
global_state_machine = get_actor("global_state_machine")
tool_mapper = await global_state_machine.get_tool_mapper()
categories = await global_state_machine.get_tool_categories()
all_tool_names = set()
for scope_tools in tool_mapper.values():
@@ -266,8 +266,8 @@ async def list_tool_configs(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""列出所有工具运行期配置;敏感字段会被脱敏。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
raw = await global_state_machine.list_tool_configs.remote()
global_state_machine = get_actor("global_state_machine")
raw = await global_state_machine.list_tool_configs()
return {
"configs": {name: _mask_config(cfg) for name, cfg in raw.items()},
}
@@ -279,8 +279,8 @@ async def get_tool_config(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""按工具名取出脱敏后的配置。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
raw = await global_state_machine.get_tool_config.remote(tool_name)
global_state_machine = get_actor("global_state_machine")
raw = await global_state_machine.get_tool_config(tool_name)
return {"tool_name": tool_name, "config": _mask_config(raw)}
@@ -291,8 +291,8 @@ async def set_tool_config(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""写入/覆盖某工具的运行期配置(如 ``tavily_search`` 的 ``api_key``)。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.set_tool_config.remote(tool_name, body.config)
global_state_machine = get_actor("global_state_machine")
await global_state_machine.set_tool_config(tool_name, body.config)
return {"message": "success"}
@@ -302,8 +302,8 @@ async def delete_tool_config(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR)),
):
"""删除某工具的运行期配置。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
ok = await global_state_machine.delete_tool_config.remote(tool_name)
global_state_machine = get_actor("global_state_machine")
ok = await global_state_machine.delete_tool_config(tool_name)
if not ok:
raise HTTPException(status_code=404, detail="Tool config not found")
return {"message": "success"}
@@ -343,12 +343,12 @@ async def create_custom_toolset(
body: CustomToolsetCreate,
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
global_state_machine = get_actor("global_state_machine")
import uuid
toolset_id = str(uuid.uuid4())[:8]
try:
saved = await global_state_machine.add_custom_toolset.remote(
saved = await global_state_machine.add_custom_toolset(
toolset_id=toolset_id,
name=body.name,
tools=body.tools,
@@ -368,8 +368,8 @@ async def list_custom_toolsets(
"""列出工具组:支持按 category 过滤。USER 只能看到自己的+系统的;ADMIN 看全部。"""
from kilostar.utils.access import get_authority
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
toolsets = await global_state_machine.list_custom_toolsets.remote()
global_state_machine = get_actor("global_state_machine")
toolsets = await global_state_machine.list_custom_toolsets()
authority = await get_authority(token_data.user_id)
if authority < UserAuthority.ADMINISTRATOR:
toolsets = [
@@ -386,8 +386,8 @@ async def get_custom_toolset(
toolset_id: str,
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
ts = await global_state_machine.get_custom_toolset.remote(toolset_id)
global_state_machine = get_actor("global_state_machine")
ts = await global_state_machine.get_custom_toolset(toolset_id)
if not ts:
raise HTTPException(status_code=404, detail="Custom toolset not found")
await _assert_toolset_owner_or_admin(ts, token_data)
@@ -400,8 +400,8 @@ async def update_custom_toolset(
body: CustomToolsetUpdate,
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
existing = await global_state_machine.get_custom_toolset.remote(toolset_id)
global_state_machine = get_actor("global_state_machine")
existing = await global_state_machine.get_custom_toolset(toolset_id)
if not existing:
raise HTTPException(status_code=404, detail="Custom toolset not found")
if existing.get("is_system"):
@@ -411,7 +411,7 @@ async def update_custom_toolset(
tools = body.tools if body.tools is not None else existing["tools"]
description = body.description if body.description is not None else existing.get("description")
try:
saved = await global_state_machine.add_custom_toolset.remote(
saved = await global_state_machine.add_custom_toolset(
toolset_id=toolset_id,
name=name,
tools=tools,
@@ -429,14 +429,14 @@ async def delete_custom_toolset(
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""删除工具组:系统预置不可删;USER 只能删自己的;ADMIN 及以上可删任意用户的。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
existing = await global_state_machine.get_custom_toolset.remote(toolset_id)
global_state_machine = get_actor("global_state_machine")
existing = await global_state_machine.get_custom_toolset(toolset_id)
if not existing:
raise HTTPException(status_code=404, detail="Custom toolset not found")
if existing.get("is_system"):
raise HTTPException(status_code=403, detail="系统预置工具集不可删除")
await _assert_toolset_owner_or_admin(existing, token_data)
ok = await global_state_machine.delete_custom_toolset.remote(toolset_id)
ok = await global_state_machine.delete_custom_toolset(toolset_id)
if not ok:
raise HTTPException(status_code=404, detail="Custom toolset not found")
return {"message": "success"}
+7 -7
View File
@@ -24,7 +24,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor
from kilostar.utils.access import Accessor, TokenData, RoleChecker
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.utils.config_loader import (
@@ -49,15 +49,15 @@ async def readiness():
checks = {"postgres": False, "global_state_machine": False}
try:
postgres_database = ray_actor_hook("postgres_database").postgres_database
await postgres_database.ping.remote()
postgres_database = get_actor("postgres_database")
await postgres_database.ping()
checks["postgres"] = True
except Exception:
pass
try:
gsm = ray_actor_hook("global_state_machine").global_state_machine
await gsm.get_skill_list.remote()
gsm = get_actor("global_state_machine")
await gsm.get_skill_list()
checks["global_state_machine"] = True
except Exception:
pass
@@ -95,8 +95,8 @@ async def query_system_logs(
offset: int = 0,
_: TokenData = Depends(Accessor.get_current_user),
):
pg = ray_actor_hook("postgres_database").postgres_database
logs = await pg.query_event_logs.remote(
pg = get_actor("postgres_database")
logs = await pg.query_event_logs(
trace_id=trace_id,
event_type=event_type,
level=level,
+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.actor import get_actor
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 = get_actor("postgres_database")
tasks = await postgres_database.list_tasks_by_user(
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 = get_actor("postgres_database")
task = await postgres_database.get_task(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
+33 -38
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_actor, spawn_background
from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
@@ -34,22 +34,23 @@ async def create_workflow(
request: CreateWorkflowRequest,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
postgres_database = get_actor("postgres_database")
trace_id = str(ULID())
await postgres_database.create_workflow.remote(
await postgres_database.create_workflow(
trace_id=trace_id,
user_id=token_data.user_id,
title=request.title,
command=request.command,
)
global_workflow_manager = ray_actor_hook(
"global_workflow_manager"
).global_workflow_manager
await global_workflow_manager.create_trace.remote(trace_id)
global_workflow_manager = get_actor("global_workflow_manager")
await global_workflow_manager.create_trace(trace_id)
consciousness_node = ray_actor_hook("consciousness_node").consciousness_node
consciousness_node.start_workflow_design.remote(trace_id, request.command)
consciousness_node = get_actor("consciousness_node")
# fire-and-forget:设计过程可能跑很久,不能阻塞 HTTP 响应
spawn_background(
consciousness_node.start_workflow_design(trace_id, request.command)
)
return {"trace_id": trace_id, "status": "creating"}
@@ -57,8 +58,8 @@ async def create_workflow(
@workflow_router.get("/list")
async def get_workflow_list(
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
postgres_database = ray_actor_hook("postgres_database").postgres_database
workflows = await postgres_database.list_workflows.remote(
postgres_database = get_actor("postgres_database")
workflows = await postgres_database.list_workflows(
user_id=token_data.user_id
)
return {"workflows": workflows}
@@ -75,23 +76,21 @@ async def get_workflow_sse(
鉴权走标准 ``Authorization: Bearer`` 头(前端用 fetch-based SSE,
token 不进 URL)。校验该 trace_id 属于当前用户。
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
wf = await postgres_database.get_workflow.remote(trace_id)
postgres_database = get_actor("postgres_database")
wf = await postgres_database.get_workflow(trace_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if getattr(wf, "user_id", None) != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
global_workflow_manager = ray_actor_hook(
"global_workflow_manager"
).global_workflow_manager
global_workflow_manager = get_actor("global_workflow_manager")
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
message = await global_workflow_manager.get_pending.remote(trace_id)
message = await global_workflow_manager.get_pending(trace_id)
if message:
yield f"data: {message}\n\n"
else:
@@ -108,8 +107,8 @@ async def post_workflow_reply(
request: Request,
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
wf = await postgres_database.get_workflow.remote(trace_id)
postgres_database = get_actor("postgres_database")
wf = await postgres_database.get_workflow(trace_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if getattr(wf, "user_id", None) != token_data.user_id:
@@ -117,10 +116,8 @@ async def post_workflow_reply(
data = await request.json()
reply_msg = data.get("message", "")
global_workflow_manager = ray_actor_hook(
"global_workflow_manager"
).global_workflow_manager
await global_workflow_manager.put_received.remote(trace_id, reply_msg)
global_workflow_manager = get_actor("global_workflow_manager")
await global_workflow_manager.put_received(trace_id, reply_msg)
return {"status": "ok"}
@@ -128,14 +125,14 @@ async def post_workflow_reply(
async def get_workflow_detail(
trace_id: str, token_data: TokenData = Depends(Accessor.get_current_user)
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
wf = await postgres_database.get_workflow.remote(trace_id)
postgres_database = get_actor("postgres_database")
wf = await postgres_database.get_workflow(trace_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if getattr(wf, "user_id", None) != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
context = await postgres_database.get_workflow_context.remote(trace_id)
context = await postgres_database.get_workflow_context(trace_id)
work_link = (
context.work_link if context and hasattr(context, "work_link") else []
@@ -209,32 +206,30 @@ async def resume_workflow(
):
"""从 ``workflow_graph_state`` 持久化恢复一个被中断/挂起的工作流。
新 fire 一个 ray task,task 入口的 ``hydrate`` 检查会自动走 resume 路径
新 fire 一个后台任务,入口的 ``hydrate`` 检查会自动走 resume 路径
把剩余节点跑完。
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
wf = await postgres_database.get_workflow.remote(trace_id)
postgres_database = get_actor("postgres_database")
wf = await postgres_database.get_workflow(trace_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if getattr(wf, "user_id", None) != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
record = await postgres_database.get_workflow_graph_state.remote(trace_id)
record = await postgres_database.get_workflow_graph_state(trace_id)
if record is None:
raise HTTPException(
status_code=409, detail="该工作流没有可恢复的图持久化记录"
)
global_workflow_manager = ray_actor_hook(
"global_workflow_manager"
).global_workflow_manager
await global_workflow_manager.create_trace.remote(trace_id)
global_workflow_manager = get_actor("global_workflow_manager")
await global_workflow_manager.create_trace(trace_id)
from kilostar.core.work.workflow.workflow_engine import run_workflow_task
# resume_only=True:task 入口 hydrate 失败会 fail-fast,绝不 fall through
# 到"全新模式空跑"。workflow_data 在 resume 路径上不会被使用,传空 dict 占位。
run_workflow_task.remote({}, trace_id, resume_only=True)
spawn_background(run_workflow_task({}, trace_id, resume_only=True))
return {"trace_id": trace_id, "status": "resuming"}
@@ -251,15 +246,15 @@ async def get_workflow_graph_mermaid(
"""
from kilostar.core.work.workflow.workflow_engine import workflow_graph
postgres_database = ray_actor_hook("postgres_database").postgres_database
wf = await postgres_database.get_workflow.remote(trace_id)
postgres_database = get_actor("postgres_database")
wf = await postgres_database.get_workflow(trace_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if getattr(wf, "user_id", None) != token_data.user_id:
raise HTTPException(status_code=403, detail="Forbidden")
visited: list[str] = []
record = await postgres_database.get_workflow_graph_state.remote(trace_id)
record = await postgres_database.get_workflow_graph_state(trace_id)
if record is not None:
history = getattr(record, "history", None) or []
# history 里每条 NodeSnapshot.id 形如 "ClassName:hash",截前缀作为 NodeIdent
@@ -13,10 +13,6 @@
# limitations under the License.
from typing import Any, Dict, List, Optional, Tuple
from kilostar.utils.ray_compat import actor_class, _STANDALONE
if not _STANDALONE:
import ray
from kilostar.core.global_state_machine.individual_manager import (
GlobalIndividualManager,
@@ -28,7 +24,6 @@ from kilostar.core.global_state_machine.gsm_snapshot import GSMSnapshot
from kilostar.core.postgres_database import PostgresDatabase
@actor_class
class GlobalStateMachine:
"""全局状态机 Actor,统一持有 Provider/Tool/Skill/Individual/MCP/CustomToolset 注册表。
@@ -62,13 +57,13 @@ class GlobalStateMachine:
self.postgres_database
)
# MCP servers
rows = await self.postgres_database.list_mcp_servers_db.remote()
rows = await self.postgres_database.list_mcp_servers_db()
self._mcp_servers = {row["server_id"]: row for row in rows}
# Tool configs
cfg_rows = await self.postgres_database.list_tool_configs_db.remote()
cfg_rows = await self.postgres_database.list_tool_configs_db()
self._tool_configs = {row["tool_name"]: row["config"] for row in cfg_rows}
# Custom toolsets(含系统预置)
ts_rows = await self.postgres_database.list_custom_toolsets.remote()
ts_rows = await self.postgres_database.list_custom_toolsets()
self._custom_toolsets = {row["toolset_id"]: row for row in ts_rows}
# 补种系统预置工具集(首次启动或 DB 被 create_all 重建后)
await self._seed_system_toolsets()
@@ -96,12 +91,12 @@ class GlobalStateMachine:
continue
if tid in wanted_ids:
continue
await self.postgres_database.delete_custom_toolset.remote(tid)
await self.postgres_database.delete_custom_toolset(tid)
self._custom_toolsets.pop(tid, None)
for name, pkg in packages.items():
tid = f"system::{name}"
saved = await self.postgres_database.upsert_custom_toolset.remote(
saved = await self.postgres_database.upsert_custom_toolset(
toolset_id=tid,
name=pkg.get("display_name") or name,
tools=list(pkg.get("tools", [])),
@@ -155,18 +150,10 @@ class GlobalStateMachine:
def _publish_snapshot(self) -> None:
"""版本号 +1 并发布当前状态快照。"""
self._config_version += 1
snapshot = self._build_snapshot()
if _STANDALONE:
self._current_ref = snapshot
else:
self._current_ref = ray.put(snapshot)
self._current_ref = self._build_snapshot()
async def current_config_ref(self) -> Tuple[int, Any]:
"""返回 ``(version, ObjectRef 或 snapshot)``。
分布式模式返回 ObjectRef,调用方用 ``ray.get`` 自取;
单机模式直接返回 snapshot 对象。
"""
"""返回 ``(version, snapshot)``。进程内直接持有 snapshot 对象。"""
if self._current_ref is None:
self._publish_snapshot()
return self._config_version, self._current_ref
@@ -184,6 +171,7 @@ class GlobalStateMachine:
provider_url,
provider_apikey,
provider_owner,
model_settings=None,
):
"""新增一个模型 Provider:内存注册 + 数据库持久化一并完成。"""
result = await self._global_provider_manager.add_provider(
@@ -193,15 +181,16 @@ 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
def get_provider_list(self):
async def get_provider_list(self):
"""返回内存中已登记的全部 Provider。"""
return self._global_provider_manager.get_provider_list()
def get_provider(self, provider_title):
async def get_provider(self, provider_title):
"""按 provider_title 取出单个 Provider 实例。"""
return self._global_provider_manager.get_provider(provider_title)
@@ -215,17 +204,17 @@ class GlobalStateMachine:
# ─── Tool / Toolset ────────────────────────────────────────
def get_tool_mapper(self):
async def get_tool_mapper(self):
"""返回 agent_name -> {tool_name: callable} 的全量映射。"""
return self._global_tool_manager.tool_mapper
def get_tool_list(self, agent_name: str):
async def get_tool_list(self, agent_name: str):
"""返回某个 agent 可用的工具集(其专属工具与 default 工具的并集)。"""
tools = self._global_tool_manager.tool_mapper.get(agent_name, {})
default_tools = self._global_tool_manager.tool_mapper.get("default", {})
return {**default_tools, **tools}
def get_tool_categories(self):
async def get_tool_categories(self):
"""返回工具按分类聚合的完整信息。"""
return {
"system": self._global_tool_manager.get_system_tools(),
@@ -239,22 +228,22 @@ class GlobalStateMachine:
"all": self._global_tool_manager.get_all_tools(),
}
def get_toolsets_for_scope(self, scope: str) -> List[Any]:
async def get_toolsets_for_scope(self, scope: str) -> List[Any]:
"""返回某个 scope 下的"系统 + 自定义工具组"toolset 列表(不含 MCP)。"""
return self._global_tool_manager.get_toolsets_for_scope(scope)
def get_retrieval_toolsets_for_scope(self, scope: str) -> List[Any]:
async def get_retrieval_toolsets_for_scope(self, scope: str) -> List[Any]:
"""仅返回 retrieval 工具集(system_node 专用,不包含 generation 工具)。"""
return self._global_tool_manager.get_retrieval_toolsets_for_scope(scope)
def list_toolset_packages(self) -> List[Dict[str, Any]]:
async def list_toolset_packages(self) -> List[Dict[str, Any]]:
"""列出所有磁盘工具包(前端"工具插件"页面卡片即由此渲染)。"""
return [
{k: v for k, v in pkg.items() if k != "readme_path"}
for pkg in self._global_tool_manager.toolset_packages.values()
]
def get_toolset_package_readme(self, name: str) -> Optional[str]:
async def get_toolset_package_readme(self, name: str) -> Optional[str]:
"""读取指定工具包的 README.md 内容;不存在返回 None。"""
pkg = self._global_tool_manager.toolset_packages.get(name)
if not pkg or not pkg.get("readme_path"):
@@ -269,26 +258,26 @@ class GlobalStateMachine:
async def add_mcp_server(self, server_id: str, config: Dict[str, Any]) -> bool:
"""注册一个 MCP 服务器配置(写库 → 写内存)。"""
saved = await self.postgres_database.upsert_mcp_server.remote(server_id, config)
saved = await self.postgres_database.upsert_mcp_server(server_id, config)
self._mcp_servers[server_id] = saved
self._publish_snapshot()
return True
def get_mcp_server(self, server_id: str) -> Optional[Dict[str, Any]]:
async def get_mcp_server(self, server_id: str) -> Optional[Dict[str, Any]]:
return self._mcp_servers.get(server_id)
def list_mcp_servers(self) -> List[Dict[str, Any]]:
async def list_mcp_servers(self) -> List[Dict[str, Any]]:
return [
{"server_id": sid, **cfg} for sid, cfg in self._mcp_servers.items()
]
async def delete_mcp_server(self, server_id: str) -> bool:
ok = await self.postgres_database.delete_mcp_server_db.remote(server_id)
ok = await self.postgres_database.delete_mcp_server_db(server_id)
self._mcp_servers.pop(server_id, None)
self._publish_snapshot()
return bool(ok)
def get_mcp_server_configs(self) -> Dict[str, Dict[str, Any]]:
async def get_mcp_server_configs(self) -> Dict[str, Dict[str, Any]]:
"""返回原始 MCP 服务器配置字典(供节点创建 toolsets 时使用)。"""
return dict(self._mcp_servers)
@@ -296,23 +285,23 @@ class GlobalStateMachine:
async def set_tool_config(self, tool_name: str, config: Dict[str, Any]) -> bool:
"""整体覆盖某工具的运行期配置(敏感字段在 DAO 内自动加密)。"""
saved = await self.postgres_database.upsert_tool_config.remote(
saved = await self.postgres_database.upsert_tool_config(
tool_name, config
)
self._tool_configs[tool_name] = saved["config"]
self._publish_snapshot()
return True
def get_tool_config(self, tool_name: str) -> Dict[str, Any]:
async def get_tool_config(self, tool_name: str) -> Dict[str, Any]:
"""按工具名取出配置;不存在则返回空字典。"""
return dict(self._tool_configs.get(tool_name, {}))
def list_tool_configs(self) -> Dict[str, Dict[str, Any]]:
async def list_tool_configs(self) -> Dict[str, Dict[str, Any]]:
"""返回全部已配置工具的配置(包含敏感字段,调用方需自行脱敏)。"""
return dict(self._tool_configs)
async def delete_tool_config(self, tool_name: str) -> bool:
ok = await self.postgres_database.delete_tool_config_db.remote(tool_name)
ok = await self.postgres_database.delete_tool_config_db(tool_name)
self._tool_configs.pop(tool_name, None)
self._publish_snapshot()
return bool(ok)
@@ -343,7 +332,7 @@ class GlobalStateMachine:
raise ValueError(
f"用户工具集只允许包含第三方工具,以下不合法:{invalid}"
)
saved = await self.postgres_database.upsert_custom_toolset.remote(
saved = await self.postgres_database.upsert_custom_toolset(
toolset_id=toolset_id,
name=name,
tools=list(tools),
@@ -357,14 +346,14 @@ class GlobalStateMachine:
self._publish_snapshot()
return saved
def list_custom_toolsets(self) -> List[Dict[str, Any]]:
async def list_custom_toolsets(self) -> List[Dict[str, Any]]:
return list(self._custom_toolsets.values())
def get_custom_toolset(self, toolset_id: str) -> Optional[Dict[str, Any]]:
async def get_custom_toolset(self, toolset_id: str) -> Optional[Dict[str, Any]]:
return self._custom_toolsets.get(toolset_id)
async def delete_custom_toolset(self, toolset_id: str) -> bool:
ok = await self.postgres_database.delete_custom_toolset.remote(toolset_id)
ok = await self.postgres_database.delete_custom_toolset(toolset_id)
self._custom_toolsets.pop(toolset_id, None)
self._global_tool_manager.rebuild_custom_toolsets(self._custom_toolsets)
self._publish_snapshot()
@@ -372,33 +361,33 @@ class GlobalStateMachine:
# ─── Skill ────────────────────────────────────────────────
def add_skill(self, skill_name: str):
async def add_skill(self, skill_name: str):
result = self._global_skill_manager.add_skill(skill_name)
self._publish_snapshot()
return result
def get_skill_list(self):
async def get_skill_list(self):
return self._global_skill_manager.get_skill_list()
def remove_skill(self, skill_name: str):
async def remove_skill(self, skill_name: str):
result = self._global_skill_manager.remove_skill(skill_name)
self._publish_snapshot()
return result
# ─── Individual ───────────────────────────────────────────
def add_individual(self, agent_id: str, config):
async def add_individual(self, agent_id: str, config):
result = self._global_individual_manager.add_individual(agent_id, config)
self._publish_snapshot()
return result
def get_individual(self, agent_id: str):
async def get_individual(self, agent_id: str):
return self._global_individual_manager.get_individual(agent_id)
def remove_individual(self, agent_id: str):
async def remove_individual(self, agent_id: str):
result = self._global_individual_manager.remove_individual(agent_id)
self._publish_snapshot()
return result
def list_individuals(self):
async def list_individuals(self):
return self._global_individual_manager.list_individuals()
@@ -14,14 +14,14 @@
"""GSM 快照对象与客户端拉取工具。
设计动机:把 GSM actor 内存里的"读路径配置"打包成可放进 Ray Object Store
的不可变快照。读端不再走 actor RPC,而是 ``fetch_snapshot()`` 一次拿到全量
当前配置,亚毫秒级共享内存读,绕开单 actor 的吞吐瓶颈。
设计动机:把 GSM 内存里的"读路径配置"打包成一个不可变快照对象。读端不逐字段
走 GSM 方法,而是 ``fetch_snapshot()`` 一次拿到全量当前配置,进程内直接引用,
配合版本号做进程内缓存失效。
GSM 仍然是 source of truth + 写入串行化器,但读路径解耦:
- 写入 → GSM 内存更新 → ``ray.put(snapshot)`` 拿到新 ObjectRef → 版本号 +1
- 读取 → ``current_config_ref()`` 拿 (version, ref) → ``ray.get(ref)`` 直读
- 写入 → GSM 内存更新 → 重建 snapshot 对象 → 版本号 +1
- 读取 → ``current_version()`` 校验缓存新鲜度 → ``current_config_ref()`` 拿快照
旧的 ``get_provider / get_individual / ...`` 接口保留不动,是低频路径的兜底;
新代码(特别是 skill task 这种高并发热路径)应优先走 ``fetch_snapshot``。
@@ -29,15 +29,9 @@ GSM 仍然是 source of truth + 写入串行化器,但读路径解耦:
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from kilostar.utils.ray_compat import _STANDALONE
if not _STANDALONE:
import ray
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
from kilostar.utils.logger import get_logger
@@ -73,7 +67,6 @@ class GSMSnapshot:
_local_cache: Dict[str, Any] = {"version": -1, "snapshot": None}
_cache_lock = asyncio.Lock()
async def fetch_snapshot(
@@ -83,55 +76,35 @@ async def fetch_snapshot(
) -> GSMSnapshot:
"""拉取当前 GSM 快照。
优先走"版本号检查 + ObjectRef 直读"路径:
单进程 asyncio 模型下,写入路径原子发布快照对象,读端不需要加锁:
1. 调 ``gsm.current_version.remote()`` 看本地缓存是否还新(一次轻量 RPC)
2. 若本地缓存版本号一致,直接返回缓存(亚毫秒,零网络)
3. 否则调 ``gsm.current_config_ref.remote()`` 拿 ref,``ray.get`` 解出
4. 更新本地缓存
Args:
use_cache: 默认开启进程内 LRU 缓存(实际是单槽位,持有当前版本);
测试或诊断场景可关掉强制重拉。
gsm_actor: 可选传入 GSM actor handle;省略时通过 ``ray_actor_hook`` 获取。
Note:
本函数在 task / actor 进程内多次调用是廉价的;建议每次需要 config 时
现取,不要把 snapshot 长期持有跨任务边界(避免 ObjectRef 阻碍回收)。
1. 先看本地缓存版本号是否与 ``current_version()`` 一致;
2. 一致则直接返回缓存对象(少一次全量数据传递);
3. 不一致则调 ``current_config_ref()`` 拿最新快照并更新缓存。
"""
if gsm_actor is None:
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.actor import get_gsm
gsm_actor = ray_actor_hook("global_state_machine").global_state_machine
gsm_actor = get_gsm()
if use_cache:
async with _cache_lock:
try:
latest_version = await gsm_actor.current_version.remote()
latest_version = await gsm_actor.current_version()
except Exception:
latest_version = None
if (
latest_version is not None
and _local_cache.get("version") == latest_version
and _local_cache.get("snapshot") is not None
and _local_cache["version"] == latest_version
and _local_cache["snapshot"] is not None
):
return _local_cache["snapshot"]
version, ref_or_snapshot = await gsm_actor.current_config_ref.remote()
if _STANDALONE:
snapshot = ref_or_snapshot
else:
snapshot = ray.get(ref_or_snapshot)
version, snapshot = await gsm_actor.current_config_ref()
_local_cache["version"] = version
_local_cache["snapshot"] = snapshot
return snapshot
version, ref_or_snapshot = await gsm_actor.current_config_ref.remote()
if _STANDALONE:
return ref_or_snapshot
return ray.get(ref_or_snapshot)
def reset_local_cache() -> None:
"""清空本进程内的快照缓存(测试用)。"""
@@ -32,7 +32,7 @@ class GlobalIndividualManager:
"""
try:
try:
individuals = await postgres.get_all_worker_individual.remote()
individuals = await postgres.get_all_worker_individual()
for ind in individuals:
agent_id = getattr(ind, "agent_id", None)
if agent_id:
@@ -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,
)
@@ -46,7 +46,7 @@ class ProviderManager:
async def init_provider_register(self, postgres) -> None:
"""从 Postgres 读取已存的 Provider 列表,按 provider_title 装入内存注册表。"""
providers = await postgres.get_provider.remote()
providers = await postgres.get_provider()
for provider in providers:
self.provider_register[provider.provider_title] = provider
@@ -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
@@ -88,7 +90,7 @@ class ProviderManager:
provider: Provider = await provider_class.create_provider(provider_args)
provider.provider_owner = provider_owner
self.provider_register[provider_title] = provider
await postgres_database.add_provider_db.remote(
await postgres_database.add_provider_db(
provider_id=str(ulid.ULID()),
provider_title=provider.provider_title,
provider_url=provider.provider_url,
@@ -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}")
@@ -122,7 +125,7 @@ class ProviderManager:
async def delete_provider(self, provider_title: str, postgres_database) -> None:
"""从内存注册表 + Postgres 中一并删除指定 Provider;不存在时静默返回。"""
if provider_title in self.provider_register:
await postgres_database.delete_provider_by_title.remote(
await postgres_database.delete_provider_by_title(
provider_title=provider_title
)
del self.provider_register[provider_title]
@@ -1,7 +1,5 @@
import asyncio
from typing import Dict
from kilostar.utils.ray_compat import actor_class
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.logger import get_logger
@@ -11,7 +9,6 @@ class TraceQueues:
self.receive: asyncio.Queue[str] = asyncio.Queue()
@actor_class
class GlobalWorkflowManager:
def __init__(self):
self._traces: Dict[str, TraceQueues] = {}
@@ -14,7 +14,6 @@
from typing import Union, overload
from kilostar.utils.ray_compat import actor_class
from kilostar.core.individual.consciousness_node.template import (
ConsciousnessNodeDeps,
ForregulatoryNode,
@@ -28,11 +27,9 @@ from pydantic_ai import Agent, RunContext
from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.prompts import agent_prompt
@actor_class
class ConsciousnessNode:
def __init__(self) -> None:
from kilostar.utils.logger import get_logger
@@ -40,6 +37,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 +71,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]):
@@ -132,8 +131,10 @@ class ConsciousnessNode:
f"ConsciousnessNode: 开始为 trace_id {trace_id} 设计工作流。原始命令:{command}"
)
# 获取可用技能 (示例)
postgres_database = ray_actor_hook("postgres_database").postgres_database
skills_entities = await postgres_database.get_all_worker_individual.remote()
from kilostar.utils.actor import get_postgres
postgres_database = get_postgres()
skills_entities = await postgres_database.get_all_worker_individual()
available_skills = []
if skills_entities:
for skill in skills_entities:
@@ -149,10 +150,10 @@ class ConsciousnessNode:
original_command=command, available_skills=available_skills
)
global_workflow_manager = ray_actor_hook(
"global_workflow_manager"
).global_workflow_manager
await global_workflow_manager.put_pending.remote(
from kilostar.utils.actor import get_gwm
global_workflow_manager = get_gwm()
await global_workflow_manager.put_pending(
trace_id, "正在为您构建并规划工作流任务节点,请稍候..."
)
@@ -162,20 +163,21 @@ class ConsciousnessNode:
workflow = result.workflow
workflow.trace_id = trace_id
await global_workflow_manager.put_pending.remote(
await global_workflow_manager.put_pending(
trace_id, "工作流构建完成,即将开始执行!"
)
# 直接以 ray task 形式 fire workflow,不再经过 WorkflowRunningEngine 这层中转:
# workflow 是一次性、有头有尾的执行,task 语义比常驻 actor 更贴。
# 以后台任务形式 fire workflow:workflow 是一次性、有头有尾的
# 执行,跑完即结束;持久化交给 PostgresStatePersistence 保证可恢复。
from kilostar.utils.actor import spawn_background
from kilostar.core.work.workflow.workflow_engine import run_workflow_task
run_workflow_task.remote(workflow.model_dump(), trace_id)
spawn_background(run_workflow_task(workflow.model_dump(), trace_id))
else:
await global_workflow_manager.put_pending.remote(
await global_workflow_manager.put_pending(
trace_id, "很抱歉,工作流生成失败。"
)
await postgres_database.update_workflow_status.remote(trace_id, "failed")
await postgres_database.update_workflow_status(trace_id, "failed")
async def working(
self,
@@ -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:
@@ -14,24 +14,23 @@
from kilostar.core.work.workflow.workflow import KiloStarWorkflow, WorkflowStep
from kilostar.utils.agent_model import ResponseModel, DepsModel, RequestModel
from pydantic import Field
from pydantic import BaseModel, Field
from typing import Optional, List
# 意识节点回复类
class ConsciousnessNodeResponse(ResponseModel):
class ConsciousnessNodeResponse(BaseModel):
"""Consciousness response model,是意识节点所有回复类型的父类"""
pass
class ConsciousnessNodeDeps(DepsModel):
class ConsciousnessNodeDeps(BaseModel):
"""ConsciousnessNode 在 pydantic-ai Agent 中使用的依赖:原始指令、当前指令以及可用 Skill 列表。"""
original_command: str
command: str
available_skills: Optional[List[dict]] = None
locale: str = "zh"
class ConsciousnessNodeInput(RequestModel):
class ConsciousnessNodeInput(BaseModel):
"""ConsciousnessNode 各类入参的共同基类,仅用于打 schema 标签。"""
pass
@@ -1,17 +0,0 @@
# 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
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .control_node import ControlNode
__all__ = ["ControlNode"]
@@ -1,130 +0,0 @@
# 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
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from pydantic_ai import Agent, RunContext
from kilostar.utils.ray_compat import actor_class
from kilostar.core.global_state_machine.global_state_machine import GlobalStateMachine
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
from kilostar.core.individual.control_node.template import (
ForWorkflow,
ForWorkflowInput,
ControlNodeDeps,
)
from kilostar.utils.prompts import agent_prompt
@actor_class
class ControlNode:
"""ControlNode(控制节点):工作流中具体子任务的执行 Actor。
它把 ConsciousnessNode 编排出的 ``workflow_step`` 拿来当作输入,借助
pydantic-ai Agent + 已绑定的工具集合产出 ``ForWorkflow`` 结构化输出。
"""
def __init__(self):
from kilostar.utils.logger import get_logger
self.logger = get_logger("control_node")
self.agent: Agent | None = None
async def create_agent(
self,
global_state_machine: GlobalStateMachine,
provider_title: str,
model_id: str,
tools_list: list[str] = None,
toolsets=None,
locale: str | None = None,
custom_system_prompt: str | None = None,
) -> None:
"""
create_agent方法,将agent对象装配到Control的属性内
该方法通过provider_title从global_state_machine中获取provider对象,然后从provider对象中取出供应商形象,装配为pydantic_ai的
Agent实例,
并挂载到self.agent属性
Args:
global_state_machine: 全局状态机
provider_title: 供应商名
model_id: 模型id
locale: 语言代码(zh/en),控制system prompt语言
custom_system_prompt: 管理员自定义追加提示词(可选)
Returns:
无返回
"""
system_prompt: str = agent_prompt("control_node", locale=locale, custom_system_prompt=custom_system_prompt)
output_type = ForWorkflow
from kilostar.utils.get_tool import load_tools_from_list
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
snapshot = await fetch_snapshot(gsm_actor=global_state_machine)
provider: Provider = snapshot.providers.get(provider_title)
if provider is None:
from kilostar.utils.i18n import t
raise ValueError(t("provider_not_registered", locale=locale, provider_title=provider_title))
agent_factory = AgentFactory()
callables = load_tools_from_list(tools_list)
self.agent = agent_factory.create_agent(
provider=provider,
model_id=model_id,
output_type=output_type,
system_prompt=system_prompt,
deps_type=ControlNodeDeps,
agent_name="control_node",
tools=callables,
toolsets=toolsets,
)
@self.agent.system_prompt
async def dynamic_prompt(ctx: RunContext[ControlNodeDeps]):
"""运行期动态拼接 system prompt:把当前 workflow_step 的关键字段塞进去。"""
prompt = system_prompt + "\n\n"
prompt += (
f"=== 当前任务步骤上下文 ===\n"
f"- 步骤名称 (Name): {ctx.deps.workflow_step.name}\n"
f"- 步骤目标/描述 (Description): {ctx.deps.workflow_step.desc}\n"
f"- 前置输入(input): {ctx.deps.workflow_step.inputs}\n"
)
return prompt
async def working(self, payload: ForWorkflowInput) -> str:
"""对外入口:执行一次步骤,吞掉异常并返回 ``None`` 以避免拖垮上游 Workflow。"""
try:
result: ForWorkflow = await self._run(payload)
return result
except Exception:
self.logger.exception("ControlNode在执行working时发生严重错误")
return None
async def _run(self, payload: ForWorkflowInput) -> ForWorkflow:
"""实际执行步骤:组装 ``ControlNodeDeps``、调用 Agent,最终把 ``ForWorkflow`` 输出取出。"""
try:
self.agent.retries = 3
deps = ControlNodeDeps(workflow_step=payload.workflow_step)
self.logger.debug(
f"ControlNode: 开始执行工作流节点 [{payload.workflow_step.name}] (原生重试开启)"
)
result = await self.agent.run(
f"请根据提供的 workflow_step 上下文,执行此步骤并输出结果。\n详细指令或附加数据:{payload.workflow_step.model_dump_json()}",
deps=deps,
)
return result.output
except Exception as e:
self.logger.exception(
f"ControlNode 在执行步骤 [{payload.workflow_step.name}] 时最终失败: {str(e)}"
)
raise RuntimeError(f"ControlNode 执行步骤失败: {str(e)}") from e
@@ -1,51 +0,0 @@
# 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
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from pydantic import Field
from kilostar.core.work.workflow.workflow import WorkflowStep
from kilostar.utils.agent_model import ResponseModel, RequestModel, DepsModel
class ControlNodeResponse(ResponseModel):
"""控制节点回复的基类。"""
pass
class ControlNodeInput(RequestModel):
"""控制节点输入的基类,承载一次调度所需的入参。"""
pass
class ControlNodeDeps(DepsModel):
"""控制节点运行期依赖,注入到 pydantic-ai Agent 的 RunContext。"""
workflow_step: WorkflowStep
# In the future, this can be dynamically populated with tools specific to the current task execution
class ForWorkflow(ControlNodeResponse):
"""控制节点执行单个工作流步骤的输出模型。"""
output: str = Field(
..., description="控制节点执行特定工作流步骤的结果。包含执行细节和输出数据。"
)
class ForWorkflowInput(ControlNodeInput):
"""控制节点针对工作流步骤的输入模型。"""
workflow_step: WorkflowStep

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