Compare commits
4
Commits
6d658b4f4d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a914a2517 | ||
|
|
aa47a19e98 | ||
|
|
4aa1dab283 | ||
|
|
005ce566a8 |
+21
-14
@@ -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
@@ -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
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""data_analytics 重型插件包。"""
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "analyst",
|
||||
"role": "数据分析师",
|
||||
"system_prompt": "你是一位严谨、克制的数据分析师。任务进来后:1) 先用 s3_list_objects/s3_peek 看几行了解结构;2) 决定用 python_executor(小数据,单机 pandas)或 ray_submit(大数据,分布式);3) 执行分析、得出明确结论,必要时给出图表链接或样例数据。注意:你只能读取 S3,**不能写入**。如果用户让你上传/删除/修改对象,请明确告知做不到。",
|
||||
"tools": ["s3_list_objects", "s3_peek", "s3_get_object", "ray_submit", "python_executor"],
|
||||
"skills": [],
|
||||
"peers": []
|
||||
}
|
||||
],
|
||||
"orchestration": {
|
||||
"type": "react",
|
||||
"entry": "analyst"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""data_analytics 插件 API:凭证 CRUD + 分析任务提交/查询/事件流。
|
||||
|
||||
挂载后路径前缀为 /api/v1/plugin/data_analytics/...,跟核心 API 完全独立。
|
||||
所有数据库读写都走 organization actor 的代理方法(确保分布式模式下不跨 actor
|
||||
共享 SQLAlchemy session)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.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 实现。"""
|
||||
@@ -0,0 +1,235 @@
|
||||
"""data_analytics 插件本地 SQLite 表与 DAO。
|
||||
|
||||
注意:本插件用的 ``DeclarativeBase`` 跟核心 PG 完全独立,避免元数据空间串场。
|
||||
所有数据落到 ``data/plugin/data_analytics/_data/data_analytics.db``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from kilostar.utils.crypto import decrypt_dict_secrets, encrypt_dict_secrets
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""data_analytics 插件私有的元数据空间,跟核心 PG 隔离。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class S3Credential(Base):
|
||||
__tablename__ = "s3_credential"
|
||||
|
||||
cred_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
endpoint_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
region: Mapped[str] = mapped_column(String(50), default="us-east-1")
|
||||
access_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
secret_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class AnalysisJob(Base):
|
||||
__tablename__ = "analysis_job"
|
||||
|
||||
job_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
cred_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
org_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
result: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False, index=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class CredentialDAO:
|
||||
"""S3 凭证 DAO:写入时自动加密,读取时自动解密。"""
|
||||
|
||||
SENSITIVE_KEYS = ("access_key", "secret_key")
|
||||
|
||||
def __init__(self, sm: async_sessionmaker[AsyncSession]):
|
||||
self._sm = sm
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: S3Credential, *, include_secrets: bool) -> dict:
|
||||
d = {
|
||||
"cred_id": row.cred_id,
|
||||
"user_id": row.user_id,
|
||||
"display_name": row.display_name,
|
||||
"endpoint_url": row.endpoint_url,
|
||||
"region": row.region,
|
||||
"access_key": row.access_key,
|
||||
"secret_key": row.secret_key,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
if not include_secrets:
|
||||
ak = decrypt_dict_secrets({"access_key": d["access_key"]}).get("access_key", "")
|
||||
d["access_key"] = (ak[:4] + "***" + ak[-2:]) if len(ak) > 6 else "***"
|
||||
d.pop("secret_key", None)
|
||||
return d
|
||||
# include_secrets=True 用于工具内部,返回明文给 boto3
|
||||
return decrypt_dict_secrets(d)
|
||||
|
||||
async def list_by_user(self, user_id: str) -> List[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.user_id == user_id)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r, include_secrets=False) for r in rows]
|
||||
|
||||
async def get(self, cred_id: str, *, include_secrets: bool = False) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_dict(row, include_secrets=include_secrets)
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
cred_id: str,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
endpoint_url: Optional[str] = None,
|
||||
region: str = "us-east-1",
|
||||
) -> dict:
|
||||
encrypted = encrypt_dict_secrets(
|
||||
{"access_key": access_key, "secret_key": secret_key}
|
||||
)
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
|
||||
existing = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.display_name = display_name
|
||||
existing.endpoint_url = endpoint_url
|
||||
existing.region = region
|
||||
existing.access_key = encrypted["access_key"]
|
||||
existing.secret_key = encrypted["secret_key"]
|
||||
s.add(existing)
|
||||
await s.commit()
|
||||
await s.refresh(existing)
|
||||
return self._row_to_dict(existing, include_secrets=False)
|
||||
row = S3Credential(
|
||||
cred_id=cred_id,
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
endpoint_url=endpoint_url,
|
||||
region=region,
|
||||
access_key=encrypted["access_key"],
|
||||
secret_key=encrypted["secret_key"],
|
||||
)
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row, include_secrets=False)
|
||||
|
||||
async def delete(self, cred_id: str, user_id: str) -> bool:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(
|
||||
S3Credential.cred_id == cred_id, S3Credential.user_id == user_id
|
||||
)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
await s.delete(row)
|
||||
await s.commit()
|
||||
return True
|
||||
|
||||
|
||||
class JobDAO:
|
||||
"""分析任务记录 DAO。"""
|
||||
|
||||
def __init__(self, sm: async_sessionmaker[AsyncSession]):
|
||||
self._sm = sm
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: AnalysisJob) -> dict:
|
||||
return {
|
||||
"job_id": row.job_id,
|
||||
"user_id": row.user_id,
|
||||
"cred_id": row.cred_id,
|
||||
"description": row.description,
|
||||
"status": row.status,
|
||||
"org_task_id": row.org_task_id,
|
||||
"result": row.result,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
description: str,
|
||||
cred_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
async with self._sm() as s:
|
||||
row = AnalysisJob(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
description=description,
|
||||
cred_id=cred_id,
|
||||
)
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
result: Optional[str] = None,
|
||||
org_task_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if status is not None:
|
||||
row.status = status
|
||||
if result is not None:
|
||||
row.result = result
|
||||
if org_task_id is not None:
|
||||
row.org_task_id = org_task_id
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list_by_user(self, user_id: str, limit: int = 50) -> List[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = (
|
||||
select(AnalysisJob)
|
||||
.where(AnalysisJob.user_id == user_id)
|
||||
.order_by(AnalysisJob.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r) for r in rows]
|
||||
|
||||
async def get(self, job_id: str) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
@@ -0,0 +1,135 @@
|
||||
"""data_analytics organization:管理本插件的 SQLite 元数据 + 注入凭证 ctx。
|
||||
|
||||
凭证经由 ``S3_CREDS_VAR`` ContextVar 传给工具,避免污染 agent tool signature
|
||||
(agent 看到的工具不带 cred 参数,模型不会误传)。
|
||||
|
||||
API 层通过本类暴露的 ``cred_*`` / ``job_*`` 代理方法跨 actor 调 DAO,
|
||||
保证分布式模式下 actor 之间不直接共享 SQLAlchemy session。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from kilostar.plugin_runtime.base_organization import BaseOrganization
|
||||
from kilostar.plugin_runtime.event import OrgEvent
|
||||
|
||||
from .db import Base, CredentialDAO, JobDAO
|
||||
|
||||
# 当前任务的 S3 凭证(明文):工具内部读 .get() 拿
|
||||
S3_CREDS_VAR: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
|
||||
"data_analytics_s3_creds", default=None
|
||||
)
|
||||
|
||||
|
||||
class DataAnalyticsOrganization(BaseOrganization):
|
||||
"""对接 S3 的数据分析组织。"""
|
||||
|
||||
async def setup(self) -> None:
|
||||
await super().setup()
|
||||
await self.init_local_db([Base])
|
||||
# 跨工具/跨 API 共享的 DAO 实例
|
||||
self.cred_dao = CredentialDAO(self._session_maker)
|
||||
self.job_dao = JobDAO(self._session_maker)
|
||||
|
||||
async def on_first_install(self) -> None:
|
||||
self.logger.info(
|
||||
"data_analytics installed; configure S3 credentials in dashboard."
|
||||
)
|
||||
|
||||
async def react(
|
||||
self,
|
||||
task_description: str,
|
||||
ctx: Dict[str, Any],
|
||||
emit: Callable[[OrgEvent], Any],
|
||||
) -> Any:
|
||||
cred_id = ctx.get("cred_id")
|
||||
if cred_id and getattr(self, "cred_dao", None) is not None:
|
||||
cred = await self.cred_dao.get(cred_id, include_secrets=True)
|
||||
if cred is None:
|
||||
raise RuntimeError(f"S3 凭证 {cred_id} 不存在")
|
||||
S3_CREDS_VAR.set(cred)
|
||||
ctx["s3_cred_display"] = cred.get("display_name")
|
||||
else:
|
||||
S3_CREDS_VAR.set(None)
|
||||
return await super().react(task_description, ctx, emit)
|
||||
|
||||
# ─── 凭证代理(API 层调用) ─────────────────────────────────────
|
||||
|
||||
async def cred_list(self, user_id: str) -> List[dict]:
|
||||
return await self.cred_dao.list_by_user(user_id)
|
||||
|
||||
async def cred_create(
|
||||
self,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
endpoint_url: Optional[str] = None,
|
||||
region: str = "us-east-1",
|
||||
) -> dict:
|
||||
cred_id = uuid.uuid4().hex
|
||||
return await self.cred_dao.upsert(
|
||||
cred_id=cred_id,
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
endpoint_url=endpoint_url,
|
||||
region=region,
|
||||
)
|
||||
|
||||
async def cred_delete(self, cred_id: str, user_id: str) -> bool:
|
||||
return await self.cred_dao.delete(cred_id, user_id)
|
||||
|
||||
# ─── 任务代理 ──────────────────────────────────────────────────
|
||||
|
||||
async def job_create(
|
||||
self, user_id: str, cred_id: str, description: str
|
||||
) -> dict:
|
||||
# 校验凭证归属
|
||||
cred = await self.cred_dao.get(cred_id, include_secrets=False)
|
||||
if cred is None or cred.get("user_id") != user_id:
|
||||
raise ValueError("凭证不存在或不属于当前用户")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
await self.job_dao.create(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
description=description,
|
||||
cred_id=cred_id,
|
||||
)
|
||||
# 投递 organization 任务(拿 task_id 回填,便于前端拉事件流)
|
||||
task_id = await self.submit(
|
||||
description, {"user_id": user_id, "cred_id": cred_id, "job_id": job_id}
|
||||
)
|
||||
await self.job_dao.update(job_id, status="running", org_task_id=task_id)
|
||||
return {"job_id": job_id, "task_id": task_id, "status": "running"}
|
||||
|
||||
async def job_list(self, user_id: str) -> List[dict]:
|
||||
return await self.job_dao.list_by_user(user_id)
|
||||
|
||||
async def job_get(self, job_id: str, user_id: str) -> Optional[dict]:
|
||||
row = await self.job_dao.get(job_id)
|
||||
if row is None or row.get("user_id") != user_id:
|
||||
return None
|
||||
# 附带最新 organization 状态
|
||||
org_task_id = row.get("org_task_id")
|
||||
if org_task_id:
|
||||
ts = await self.status(org_task_id)
|
||||
if ts is not None:
|
||||
row["task_status"] = ts.get("status")
|
||||
row["task_result"] = ts.get("result")
|
||||
row["task_error"] = ts.get("error")
|
||||
# 任务终态时把结果回写 SQLite,方便重启后查询
|
||||
if ts.get("status") in ("completed", "failed") and row.get("status") != ts.get("status"):
|
||||
result_payload = ts.get("result") if ts.get("status") == "completed" else ts.get("error")
|
||||
await self.job_dao.update(
|
||||
job_id,
|
||||
status=ts.get("status"),
|
||||
result=str(result_payload) if result_payload is not None else None,
|
||||
)
|
||||
row["status"] = ts.get("status")
|
||||
return row
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Loader2, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
loading: boolean;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function CredentialPanel({ credentials, loading, onChanged }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
display_name: '',
|
||||
endpoint_url: '',
|
||||
region: 'us-east-1',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setForm({ display_name: '', endpoint_url: '', region: 'us-east-1', access_key: '', secret_key: '' });
|
||||
setError('');
|
||||
setShowSecret(false);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.display_name.trim() || !form.access_key.trim() || !form.secret_key.trim()) {
|
||||
setError('显示名 / Access Key / Secret Key 必填');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/credentials`, {
|
||||
display_name: form.display_name.trim(),
|
||||
endpoint_url: form.endpoint_url.trim() || null,
|
||||
region: form.region.trim() || 'us-east-1',
|
||||
access_key: form.access_key,
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
reset();
|
||||
setShowForm(false);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (cred_id: string) => {
|
||||
if (!confirm('确定删除该凭证?删除后该凭证下的任务将无法继续运行。')) return;
|
||||
try {
|
||||
await client.delete(`${API_BASE}/credentials/${cred_id}`);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Key size={16} className="text-accent" /> S3 凭证
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">访问密钥加密存储于本地 SQLite。</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5"
|
||||
onClick={() => { setShowForm((s) => !s); setError(''); }}
|
||||
>
|
||||
<Plus size={14} /> {showForm ? '取消' : '新增'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="space-y-3 mb-4 p-4 bg-bg-secondary rounded-xl border border-border-secondary">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="显示名(如 prod-aws)"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Endpoint URL(可选,自托管 S3 / MinIO 填写)"
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => setForm({ ...form, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Region(默认 us-east-1)"
|
||||
value={form.region}
|
||||
onChange={(e) => setForm({ ...form, region: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Access Key"
|
||||
value={form.access_key}
|
||||
onChange={(e) => setForm({ ...form, access_key: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Secret Key"
|
||||
value={form.secret_key}
|
||||
onChange={(e) => setForm({ ...form, secret_key: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
||||
onClick={() => setShowSecret((s) => !s)}
|
||||
>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||||
还没有凭证,点右上角「新增」开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{credentials.map((c) => (
|
||||
<div
|
||||
key={c.cred_id}
|
||||
className="flex items-center justify-between p-3 bg-bg-secondary rounded-xl border border-border-secondary"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text-primary truncate">{c.display_name}</div>
|
||||
<div className="text-[11px] text-text-muted font-mono mt-0.5">
|
||||
{c.endpoint_url || 'aws-s3'} · {c.region} · {c.access_key}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-1.5 text-text-muted hover:text-danger transition"
|
||||
onClick={() => remove(c.cred_id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BarChart3, Plus, Loader2, ListChecks } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import { CredentialPanel } from './CredentialPanel';
|
||||
import { NewJobDialog } from './NewJobDialog';
|
||||
import { JobDetail } from './JobDetail';
|
||||
import type { S3Credential, AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export function Dashboard({ pluginName }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credentials, setCredentials] = useState<S3Credential[]>([]);
|
||||
const [credLoading, setCredLoading] = useState(true);
|
||||
const [jobs, setJobs] = useState<AnalysisJob[]>([]);
|
||||
const [jobLoading, setJobLoading] = useState(true);
|
||||
const [showNewJob, setShowNewJob] = useState(false);
|
||||
const [openJobId, setOpenJobId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadCredentials = useCallback(async () => {
|
||||
setCredLoading(true);
|
||||
try {
|
||||
const resp = await client.get<{ credentials: S3Credential[] }>(`${API_BASE}/credentials`);
|
||||
setCredentials(resp.data.credentials || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '加载凭证失败');
|
||||
} finally {
|
||||
setCredLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setJobLoading(true);
|
||||
try {
|
||||
const resp = await client.get<{ jobs: AnalysisJob[] }>(`${API_BASE}/jobs`);
|
||||
setJobs(resp.data.jobs || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '加载任务失败');
|
||||
} finally {
|
||||
setJobLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCredentials();
|
||||
loadJobs();
|
||||
}, [loadCredentials, loadJobs]);
|
||||
|
||||
// 轮询任务列表,方便看状态变化
|
||||
useEffect(() => {
|
||||
const t = setInterval(loadJobs, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadJobs]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-bg-base">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 pb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-light text-accent flex items-center justify-center">
|
||||
<BarChart3 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-primary">数据分析</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
对接 S3,让 agent 自主决定分析路径(python_executor / ray_submit)。{' '}
|
||||
<span className="font-mono text-[10px] opacity-70">{pluginName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
|
||||
)}
|
||||
|
||||
<CredentialPanel credentials={credentials} loading={credLoading} onChanged={loadCredentials} />
|
||||
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<ListChecks size={16} className="text-accent" /> 分析任务
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">点行查看详情和事件流。每 5 秒自动刷新。</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5 disabled:opacity-50"
|
||||
onClick={() => setShowNewJob(true)}
|
||||
disabled={credentials.length === 0}
|
||||
title={credentials.length === 0 ? '请先添加凭证' : ''}
|
||||
>
|
||||
<Plus size={14} /> 新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobLoading && jobs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||||
还没有分析任务。点右上角「新建任务」开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((j) => (
|
||||
<button
|
||||
key={j.job_id}
|
||||
className="w-full text-left p-3 bg-bg-secondary rounded-xl border border-border-secondary hover:border-accent transition flex items-center justify-between gap-3"
|
||||
onClick={() => setOpenJobId(j.job_id)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-text-primary truncate">{j.description}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 font-mono">
|
||||
{j.job_id.slice(0, 8)} · {j.created_at?.slice(0, 19).replace('T', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={j.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewJob && (
|
||||
<NewJobDialog
|
||||
credentials={credentials}
|
||||
onClose={() => setShowNewJob(false)}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
)}
|
||||
{openJobId && <JobDetail jobId={openJobId} onClose={() => setOpenJobId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-bg-base text-text-muted border-border-primary',
|
||||
running: 'bg-warning-bg text-warning border-warning/20',
|
||||
completed: 'bg-success-bg text-success border-success/20',
|
||||
failed: 'bg-danger-bg text-danger border-danger/20',
|
||||
};
|
||||
const cls = map[status] || map.pending;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium px-2 py-1 rounded-lg border ${cls} shrink-0`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, X, Activity } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
jobId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
type?: string;
|
||||
ts?: number;
|
||||
payload?: unknown;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export function JobDetail({ jobId, onClose }: Props) {
|
||||
const { client, token, apiBase } = usePluginContext();
|
||||
const [job, setJob] = useState<AnalysisJob | null>(null);
|
||||
const [events, setEvents] = useState<StreamEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const eventBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 初次加载 + 后台轮询
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchJob = async () => {
|
||||
try {
|
||||
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
|
||||
if (!cancelled) setJob(resp.data);
|
||||
} catch (e) {
|
||||
console.error('fetch job failed', e);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchJob();
|
||||
const t = setInterval(fetchJob, 4000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [client, jobId]);
|
||||
|
||||
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const run = async () => {
|
||||
try {
|
||||
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.body) return;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buf = '';
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split('\n\n');
|
||||
buf = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
try {
|
||||
setEvents((prev) => [...prev, JSON.parse(payload)]);
|
||||
} catch {
|
||||
setEvents((prev) => [...prev, { raw: payload }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => controller.abort();
|
||||
}, [apiBase, jobId, token]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (eventBoxRef.current) {
|
||||
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Activity size={16} className="text-accent" />
|
||||
任务详情
|
||||
</h3>
|
||||
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
|
||||
</div>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : job ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Field label="状态" value={job.task_status || job.status} />
|
||||
<Field label="描述" value={job.description} />
|
||||
{job.task_error && <Field label="错误" value={job.task_error} danger />}
|
||||
</div>
|
||||
|
||||
{job.task_result !== undefined && job.task_result !== null && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">执行结果</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
|
||||
{typeof job.task_result === 'string'
|
||||
? job.task_result
|
||||
: JSON.stringify(job.task_result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">事件流(SSE)</div>
|
||||
<div
|
||||
ref={eventBoxRef}
|
||||
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<span className="text-text-muted">(等待事件…)</span>
|
||||
) : (
|
||||
events.map((e, i) => (
|
||||
<div key={i} className="text-text-secondary">
|
||||
<span className="text-accent">{e.type || 'event'}</span>{' '}
|
||||
{e.payload !== undefined ? (
|
||||
<span>{JSON.stringify(e.payload)}</span>
|
||||
) : e.raw ? (
|
||||
<span>{e.raw}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted text-center py-8">任务不存在或已被删除</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
|
||||
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Send, X } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function NewJobDialog({ credentials, onClose, onCreated }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credId, setCredId] = useState(credentials[0]?.cred_id || '');
|
||||
const [description, setDescription] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = async () => {
|
||||
if (!credId) {
|
||||
setError('请选择 S3 凭证');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setError('请描述要做的分析');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/jobs`, {
|
||||
cred_id: credId,
|
||||
description: description.trim(),
|
||||
});
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '提交失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-lg bg-bg-card rounded-2xl border border-border-primary shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<h3 className="font-semibold text-text-primary">新建分析任务</h3>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">S3 凭证</label>
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-xs text-warning bg-warning-bg/50 border border-warning/20 rounded-lg p-2">
|
||||
请先在上方添加 S3 凭证。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
value={credId}
|
||||
onChange={(e) => setCredId(e.target.value)}
|
||||
>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.cred_id} value={c.cred_id}>
|
||||
{c.display_name} · {c.region}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">任务描述</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent min-h-[120px] resize-y"
|
||||
placeholder="例如:分析 s3://my-bucket/sales/2026-q1/ 的销售趋势,输出按月汇总"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
Agent 会先用 s3_peek/s3_list_objects 探查数据,然后选择 python_executor 或 ray_submit 执行分析。
|
||||
</p>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border-primary">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-text-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center gap-1.5"
|
||||
onClick={submit}
|
||||
disabled={busy || credentials.length === 0}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// vite lib 模式 build 完后写一份 wc-manifest.json,给后端 /ui-manifest 端点读
|
||||
const distDir = 'dist';
|
||||
if (!existsSync(distDir)) {
|
||||
console.error('dist/ not found; run vite build first');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(distDir);
|
||||
const js = files.find((f) => f.endsWith('.js')) || 'plugin-element.js';
|
||||
const css = files.filter((f) => f.endsWith('.css'));
|
||||
|
||||
const manifest = {
|
||||
tag: 'plugin-data-analytics',
|
||||
js,
|
||||
css,
|
||||
};
|
||||
|
||||
writeFileSync(join(distDir, 'wc-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
console.log(`wrote dist/wc-manifest.json: ${JSON.stringify(manifest)}`);
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface PluginContextValue {
|
||||
client: AxiosInstance;
|
||||
token: string;
|
||||
apiBase: string;
|
||||
}
|
||||
|
||||
export const PluginContext = createContext<PluginContextValue | null>(null);
|
||||
|
||||
export function usePluginContext(): PluginContextValue {
|
||||
const ctx = useContext(PluginContext);
|
||||
if (!ctx) throw new Error('PluginContext missing — Web Component not initialized');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function makeClient(token: string, apiBase: string): AxiosInstance {
|
||||
const c = axios.create({
|
||||
baseURL: apiBase || undefined,
|
||||
});
|
||||
c.interceptors.request.use((cfg) => {
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
(cfg.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
return c;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { PluginContext, makeClient } from './client';
|
||||
|
||||
// 把 build 出来的 CSS 当字符串收入,作为 ConstructableStyleSheet 注入到 shadow root,
|
||||
// 既能享受 shadow DOM 的样式隔离,也不需要额外的 fetch 步骤。
|
||||
import css from './styles.css?inline';
|
||||
|
||||
const TAG = 'plugin-data-analytics';
|
||||
|
||||
class DataAnalyticsElement extends HTMLElement {
|
||||
private root?: Root;
|
||||
private mount?: HTMLDivElement;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['token', 'api-base'];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
|
||||
// 用 <style> 注入 CSS(ConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
shadow.appendChild(style);
|
||||
|
||||
this.mount = document.createElement('div');
|
||||
this.mount.style.cssText = 'height:100%;width:100%';
|
||||
shadow.appendChild(this.mount);
|
||||
|
||||
this.root = createRoot(this.mount);
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.root) this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.root?.unmount();
|
||||
this.root = undefined;
|
||||
}
|
||||
|
||||
private render() {
|
||||
const token = this.getAttribute('token') ?? '';
|
||||
const apiBase = this.getAttribute('api-base') ?? '';
|
||||
const client = makeClient(token, apiBase);
|
||||
this.root!.render(
|
||||
<React.StrictMode>
|
||||
<PluginContext.Provider value={{ client, token, apiBase }}>
|
||||
<Dashboard pluginName="data_analytics" />
|
||||
</PluginContext.Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get(TAG)) {
|
||||
customElements.define(TAG, DataAnalyticsElement);
|
||||
}
|
||||
+1744
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "plugin-data-analytics",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build && node build-manifest.mjs",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 在 shadow DOM 内 :root 不匹配,用 :host 给 Web Component 自身定义主题 token。
|
||||
颜色名称跟主前端 frontend/src/index.css 保持一致——这样组件里的 bg-bg-card / text-accent
|
||||
等类名在插件 build 时也能解析到对应的 var()。 */
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-card: var(--bg-card);
|
||||
--color-bg-sidebar: var(--bg-sidebar);
|
||||
--color-bg-input: var(--bg-input);
|
||||
--color-bg-hover: var(--bg-hover);
|
||||
--color-bg-active: var(--bg-active);
|
||||
--color-bg-base: var(--bg-base);
|
||||
--color-border-primary: var(--border-primary);
|
||||
--color-border-secondary: var(--border-secondary);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-light: var(--accent-light);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-bg: var(--danger-bg);
|
||||
--color-success: var(--success);
|
||||
--color-success-bg: var(--success-bg);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-bg: var(--warning-bg);
|
||||
}
|
||||
|
||||
:host {
|
||||
/* light theme defaults — 跟主前端保持一致 */
|
||||
--bg-primary: #f2f0ed;
|
||||
--bg-secondary: #eae8e4;
|
||||
--bg-tertiary: #e0ddd8;
|
||||
--bg-card: #faf9f7;
|
||||
--bg-sidebar: #eae8e4;
|
||||
--bg-input: #f2f0ed;
|
||||
--bg-hover: rgba(255, 255, 255, 0.4);
|
||||
--bg-active: rgba(156, 175, 136, 0.08);
|
||||
--bg-base: #f2f0ed;
|
||||
--border-primary: #e0ddd8;
|
||||
--border-secondary: #eae8e4;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #5a5a5a;
|
||||
--text-tertiary: #8c8680;
|
||||
--text-muted: #b5afa8;
|
||||
--accent: #9caf88;
|
||||
--accent-hover: #8a9e78;
|
||||
--accent-light: rgba(156, 175, 136, 0.12);
|
||||
--danger: #c4917a;
|
||||
--danger-bg: rgba(196, 145, 122, 0.08);
|
||||
--success: #7a8e6a;
|
||||
--success-bg: rgba(122, 142, 106, 0.08);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.08);
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 跟随系统/主前端的暗色主题:宿主元素加 [data-theme="dark"] 时切换 */
|
||||
:host([data-theme="dark"]) {
|
||||
--bg-primary: #1c1b19;
|
||||
--bg-secondary: #232220;
|
||||
--bg-tertiary: #2d2b28;
|
||||
--bg-card: #252421;
|
||||
--bg-sidebar: #1e1d1b;
|
||||
--bg-input: #2d2b28;
|
||||
--bg-hover: rgba(255, 255, 255, 0.04);
|
||||
--bg-active: rgba(156, 175, 136, 0.1);
|
||||
--bg-base: #1c1b19;
|
||||
--border-primary: rgba(255, 255, 255, 0.06);
|
||||
--border-secondary: rgba(255, 255, 255, 0.03);
|
||||
--text-primary: #e8e6e3;
|
||||
--text-secondary: #c8c5c0;
|
||||
--text-tertiary: #a09c96;
|
||||
--text-muted: #7a7772;
|
||||
--accent: #a8bc94;
|
||||
--accent-hover: #b8caa6;
|
||||
--accent-light: rgba(156, 175, 136, 0.15);
|
||||
--danger: #d4a894;
|
||||
--danger-bg: rgba(196, 145, 122, 0.1);
|
||||
--success: #9caf88;
|
||||
--success-bg: rgba(156, 175, 136, 0.1);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["*.ts", "*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface S3Credential {
|
||||
cred_id: string;
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
endpoint_url: string | null;
|
||||
region: string;
|
||||
access_key: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AnalysisJob {
|
||||
job_id: string;
|
||||
user_id: string;
|
||||
cred_id: string | null;
|
||||
description: string;
|
||||
status: string;
|
||||
org_task_id: string | null;
|
||||
result: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
task_status?: string;
|
||||
task_result?: unknown;
|
||||
task_error?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'index.tsx',
|
||||
formats: ['es'],
|
||||
fileName: () => 'plugin-element.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (info) => {
|
||||
if (info.name && info.name.endsWith('.css')) return 'plugin-element.css';
|
||||
return 'assets/[name]-[hash][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "data_analytics",
|
||||
"version": "0.1.0",
|
||||
"display_name": "数据分析",
|
||||
"description": "对接 S3 对象存储,由 agent 自主决定使用 python_executor 或 ray_submit 跑分析。仅读不写。",
|
||||
"entry": "core.organization:DataAnalyticsOrganization",
|
||||
"concurrency": "queue",
|
||||
"node_affinity": "cpu",
|
||||
"api_prefix": "/api/v1/plugin/data_analytics",
|
||||
"capabilities": ["data_analysis", "s3_readonly"],
|
||||
"dependencies": {
|
||||
"python": [],
|
||||
"plugins": []
|
||||
},
|
||||
"ui": {
|
||||
"entry": "frontend/index.tsx",
|
||||
"icon": "BarChart3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""data_analytics 插件本地工具集。
|
||||
|
||||
agent 看到这些工具时不带凭证参数,凭证由 organization 通过 ContextVar 注入。
|
||||
"""
|
||||
|
||||
from .s3_list_objects import s3_list_objects
|
||||
from .s3_peek import s3_peek
|
||||
from .s3_get_object import s3_get_object
|
||||
from .ray_submit import ray_submit
|
||||
|
||||
__all__ = [
|
||||
"s3_list_objects",
|
||||
"s3_peek",
|
||||
"s3_get_object",
|
||||
"ray_submit",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""S3 工具共用辅助:从 ContextVar 拿凭证 + 解析 URI。
|
||||
|
||||
所有 s3_* 工具都依赖这个模块,把"明文凭证"的取用集中在一处。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
def get_s3_creds_or_raise() -> Dict[str, Any]:
|
||||
"""从 organization 注入的 ContextVar 中取出明文凭证;未注入则抛错。"""
|
||||
# 延迟 import 避免循环;这里走 organization 子类被加载时注入的虚拟包路径
|
||||
from ..core.organization import S3_CREDS_VAR
|
||||
|
||||
creds = S3_CREDS_VAR.get()
|
||||
if not creds:
|
||||
raise RuntimeError(
|
||||
"未提供 S3 凭证:本任务上下文中没有 cred_id,请在创建 job 时选择凭证。"
|
||||
)
|
||||
return creds
|
||||
|
||||
|
||||
def parse_s3_uri(uri: str) -> Tuple[str, str]:
|
||||
"""解析 ``s3://bucket/key`` → ``(bucket, key)``;非法格式抛 ValueError。"""
|
||||
m = re.match(r"^s3://([^/]+)/(.+)$", uri.strip())
|
||||
if not m:
|
||||
raise ValueError(f"非法 S3 URI:{uri!r}(期待 s3://bucket/key 形式)")
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def make_session_kwargs(creds: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""转 boto3/aiobotocore client 调用所需的 kwargs。"""
|
||||
kw: Dict[str, Any] = {
|
||||
"aws_access_key_id": creds["access_key"],
|
||||
"aws_secret_access_key": creds["secret_key"],
|
||||
"region_name": creds.get("region") or "us-east-1",
|
||||
}
|
||||
endpoint = creds.get("endpoint_url")
|
||||
if endpoint:
|
||||
kw["endpoint_url"] = endpoint
|
||||
return kw
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "data_analytics_internal",
|
||||
"version": "0.1.0",
|
||||
"description": "data_analytics 插件内部工具:S3 只读 + Ray 提交。仅限本插件内部 agent 调用。",
|
||||
"tools": [
|
||||
{
|
||||
"name": "s3_list_objects",
|
||||
"file": "s3_list_objects.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "s3_peek",
|
||||
"file": "s3_peek.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "s3_get_object",
|
||||
"file": "s3_get_object.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "ray_submit",
|
||||
"file": "ray_submit.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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()}"
|
||||
@@ -0,0 +1,16 @@
|
||||
# 示例部门 (Example Dept)
|
||||
|
||||
演示用的重型插件骨架。包含两个平级 agent(analyst + executor),
|
||||
可作为开发新组织插件的模板。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
example_dept/
|
||||
├── manifest.json # 插件元数据
|
||||
├── agents.json # agent 定义
|
||||
├── core/ # 业务逻辑
|
||||
├── toolset/ # 本地工具
|
||||
├── skills/ # 本地技能
|
||||
└── dashboard/ # 前端面板(占位)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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` 会修改本地文件系统,注意权限范围
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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)}"
|
||||
@@ -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)
|
||||
@@ -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)}"
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 服务",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
interface UiManifest {
|
||||
name: string;
|
||||
tag: string;
|
||||
js: string;
|
||||
css?: string[];
|
||||
display_name?: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 已加载过的插件 JS:避免切换插件时重复 import 同一个 module(customElements 也不允许重复 define)
|
||||
const loadedJs = new Set<string>();
|
||||
|
||||
export function HeavyPluginShell({ name }: Props) {
|
||||
const setActiveHeavyPlugin = useAppStore((s) => s.setActiveHeavyPlugin);
|
||||
const meta = useAppStore((s) => s.installedHeavyPlugins.find((p) => p.name === name));
|
||||
const resolvedTheme = useAppStore((s) => s.resolvedTheme);
|
||||
const [manifest, setManifest] = useState<UiManifest | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 取 token:和 apiClient 拦截器同源(localStorage 'token',详见 frontend/src/api/client.ts)
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('token') ?? '' : '';
|
||||
|
||||
// 1. 拉取 ui-manifest 并按需注入 JS(CSS 在 Web Component 内部 inline 注入,这里不管)
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setManifest(null);
|
||||
setError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await apiClient.get<UiManifest>(`/api/v1/plugin/${name}/ui-manifest`);
|
||||
const m = resp.data;
|
||||
if (!loadedJs.has(m.js)) {
|
||||
// 动态 import 插件 ESM bundle —— @vite-ignore 避免 vite 把这个 URL 当成构建期资源
|
||||
await import(/* @vite-ignore */ m.js);
|
||||
loadedJs.add(m.js);
|
||||
}
|
||||
if (!canceled) setManifest(m);
|
||||
} catch (e: unknown) {
|
||||
if (canceled) return;
|
||||
const msg =
|
||||
(e as { response?: { data?: { detail?: string } } }).response?.data?.detail ||
|
||||
(e as Error).message ||
|
||||
'failed to load plugin UI';
|
||||
setError(msg);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
// 2. 把 token / api-base / data-theme 三个属性写到自定义元素上
|
||||
// 创建/复用一个元素 placeholder:每次 manifest 切换时清空 host 重新挂载
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !manifest) return;
|
||||
host.innerHTML = '';
|
||||
const el = document.createElement(manifest.tag);
|
||||
el.setAttribute('token', token);
|
||||
el.setAttribute('api-base', '');
|
||||
el.setAttribute('data-theme', resolvedTheme);
|
||||
(el as HTMLElement).style.cssText = 'display:block;width:100%;height:100%';
|
||||
host.appendChild(el);
|
||||
return () => {
|
||||
host.contains(el) && host.removeChild(el);
|
||||
};
|
||||
}, [manifest, token, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-primary bg-bg-card">
|
||||
<button
|
||||
onClick={() => setActiveHeavyPlugin(null)}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-accent transition-all"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
返回会话
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-medium text-text-primary">
|
||||
{meta?.display_name || manifest?.display_name || name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{error ? (
|
||||
<div className="p-8 text-danger text-sm">
|
||||
插件 <code className="text-accent">{name}</code> 加载失败:{error}
|
||||
</div>
|
||||
) : !manifest ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div ref={hostRef} className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,15 @@ type WorkTab = 'chat' | 'workflow';
|
||||
type AgentTab = 'plugin' | 'agents' | 'toolsets' | 'config' | 'logs';
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface InstalledPlugin {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
has_ui?: boolean;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Auth
|
||||
isAuthenticated: boolean;
|
||||
@@ -44,6 +53,12 @@ interface AppState {
|
||||
// Locale
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
|
||||
// Heavy plugin (workspace dashboards)
|
||||
activeHeavyPlugin: string | null;
|
||||
setActiveHeavyPlugin: (name: string | null) => void;
|
||||
installedHeavyPlugins: InstalledPlugin[];
|
||||
loadInstalledPlugins: () => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeMode): 'light' | 'dark' {
|
||||
@@ -107,6 +122,21 @@ export const useAppStore = create<AppState>()(
|
||||
// 同步到 i18next,确保刷新后语言一致
|
||||
import('i18next').then((i18n) => i18n.default.changeLanguage(locale));
|
||||
},
|
||||
|
||||
activeHeavyPlugin: null,
|
||||
setActiveHeavyPlugin: (activeHeavyPlugin) => set({ activeHeavyPlugin }),
|
||||
installedHeavyPlugins: [],
|
||||
loadInstalledPlugins: async () => {
|
||||
try {
|
||||
const apiClient = (await import('../api/client')).default;
|
||||
const resp = await apiClient.get('/api/v1/plugin/list');
|
||||
const list: InstalledPlugin[] = resp.data?.plugins || [];
|
||||
set({ installedHeavyPlugins: list.filter((p) => p.has_ui) });
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch installed plugins', e);
|
||||
set({ installedHeavyPlugins: [] });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kilostar-app-storage',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@app/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@app': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user