feat: 工具系统迁移 + 重型插件骨架 + 前端交互增强

- 工具系统从 kilostar/plugin/tool_plugin/ 迁移到 data/toolset/(manifest.json 声明式)
- 新增 plugin_runtime 模块:BaseOrganization / GlobalPluginManager / loader / tool_bridge
- 新增 org_task + org_task_event 表及 DAO(alembic 0009)
- 新增 /api/v1/plugin 路由(submit/status/stream/install/reload)
- 新增 data/plugin/example_dept 示例重型插件
- regulatory_node 支持聊天历史上下文注入
- send_file 改为 artifact 存盘 + SSE 推送下载链接
- 前端 WorkflowFileCard 组件 + ToolSettings README 渲染
- utils 整理:合并 access/role_check、standalone_proxy→ray_compat、删除废弃模块
- 项目结构文档移至 docs/STRUCTURE.md 并详细展开

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 05:20:00 +00:00
parent 9b73ae4db4
commit 6d658b4f4d
74 changed files with 2591 additions and 1308 deletions
+3 -1
View File
@@ -20,7 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from kilostar.utils.standalone_proxy import _STANDALONE
from kilostar.utils.ray_compat import _STANDALONE
from kilostar.utils.settings import get_settings
if not _STANDALONE:
@@ -35,6 +35,7 @@ from .provider import provider_router
from .resource import resource_router
from .workflow import workflow_router
from .chat import chat_router
from .plugin import plugin_router
from kilostar.utils.error import (
KiloStarError,
BusinessError,
@@ -103,6 +104,7 @@ app.include_router(resource_router) # 资源路径
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.exception_handler(BusinessError)
+1 -2
View File
@@ -17,11 +17,10 @@ from typing import Union
from kilostar.utils.ray_hook import ray_actor_hook
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, field_validator
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.access import Accessor, TokenData, RoleChecker
from kilostar.core.postgres_database.model import AgentType
from fastapi import HTTPException
from typing import Optional, List, Dict
from kilostar.utils.check_user.role_check import RoleChecker
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.utils.mcp_helper import get_all_tools_and_toolsets_for_scope
from kilostar.utils.i18n import t
+1 -2
View File
@@ -15,10 +15,9 @@
from fastapi import APIRouter, Request
from fastapi import Depends
from pydantic import BaseModel
from kilostar.utils.access import Accessor, TokenData
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.check_user.role_check import RoleChecker
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.utils.error import UserNotExistError
from kilostar.utils.rate_limit import register_limiter, login_limiter
+46 -6
View File
@@ -26,6 +26,40 @@ from kilostar.core.individual.regulatory_node.template import (
chat_router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
# 单次注入历史的最大轮数(user+assistant 算一轮),防止 token 爆炸。
_HISTORY_MAX_TURNS = 20
def _build_message_history(rows) -> list:
"""把 DB 中的 ChatHistoryMessage 列表转成 pydantic-ai message_history 格式。
历史按时间升序,截取末尾最多 _HISTORY_MAX_TURNS*2 条;user 消息映射为
``ModelRequest(parts=[UserPromptPart])``assistant``regulatory_node``)映射为
``ModelResponse(parts=[TextPart])``。其它 owner 跳过。
"""
from pydantic_ai.messages import (
ModelRequest, ModelResponse, UserPromptPart, TextPart,
)
trimmed = rows[-(_HISTORY_MAX_TURNS * 2):]
history: list = []
for row in trimmed:
owner = row.message_owner
text = row.message
if not text:
continue
if owner == "user":
history.append(ModelRequest(parts=[UserPromptPart(content=text)]))
elif owner == "regulatory_node":
history.append(ModelResponse(parts=[TextPart(content=text)]))
return history
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)
return _build_message_history(rows or [])
def _extract_reply(resp: MessageResponse | None) -> str | None:
"""从 RegulatoryNode.working 的输出里取出对用户的回复文本。
@@ -39,7 +73,7 @@ def _extract_reply(resp: MessageResponse | None) -> str | None:
async def _ask_regulatory(
*, user_id: str, chat_id: str, message: str
*, 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
@@ -49,7 +83,9 @@ async def _ask_regulatory(
platform_id=chat_id,
message=message,
)
resp: MessageResponse | None = await regulatory_node.working.remote(payload)
resp: MessageResponse | None = await regulatory_node.working.remote(
payload, message_history
)
return _extract_reply(resp)
@@ -120,7 +156,8 @@ async def send_chat_message(
token_data: TokenData = Depends(Accessor.get_current_user),
):
postgres_database = ray_actor_hook("postgres_database").postgres_database
# 存用户消息
# 先取历史(不含当前输入),再写入用户消息,避免历史里出现重复
message_history = await _load_message_history(chat_id)
await postgres_database.add_chat_message.remote(
chat_id=chat_id, message=request.message, message_owner="user"
)
@@ -130,6 +167,7 @@ async def send_chat_message(
user_id=token_data.user_id,
chat_id=chat_id,
message=request.message,
message_history=message_history,
)
# 存回复
@@ -164,10 +202,12 @@ async def stream_chat_message(
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""SSE 流式聊天端点:standalone 模式下逐 token 流式输出;distributed 模式 fallback 到整段回复。"""
from kilostar.utils.standalone_proxy import _STANDALONE
from kilostar.utils.ray_compat import _STANDALONE
postgres_database = ray_actor_hook("postgres_database").postgres_database
message_history = await _load_message_history(chat_id)
await postgres_database.add_chat_message.remote(
chat_id=chat_id, message=request_body.message, message_owner="user"
)
@@ -183,7 +223,7 @@ async def stream_chat_message(
if not _STANDALONE:
async def fallback_generator():
resp = await regulatory_node.working.remote(payload)
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(
@@ -195,7 +235,7 @@ async def stream_chat_message(
return StreamingResponse(fallback_generator(), media_type="text/event-stream")
token_queue = asyncio.Queue()
stream_task = regulatory_node.stream_working.remote(payload, token_queue)
stream_task = regulatory_node.stream_working.remote(payload, token_queue, message_history)
async def event_generator():
full_response = ""
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
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
plugin_router = APIRouter(prefix="/api/v1/plugin", tags=["plugin"])
class SubmitRequest(BaseModel):
org_name: str
task_description: str
context: Optional[dict] = None
@plugin_router.post("/submit")
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:
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}")
ctx = req.context or {}
ctx["user"] = token_data.username
task_id = await org.submit.remote(req.task_description, ctx)
return {"task_id": task_id}
@plugin_router.get("/task/{task_id}")
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)
if not task:
raise HTTPException(404, "Task not found")
return task
@plugin_router.get("/task/{task_id}/events")
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)
return {"events": events}
@plugin_router.get("/task/{task_id}/stream")
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)
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}")
async def _generate():
async for event in await org.stream.remote(task_id):
yield f"data: {event}\n\n"
return StreamingResponse(_generate(), media_type="text/event-stream")
@plugin_router.get("/list")
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()
return {"plugins": plugins}
@plugin_router.post("/install")
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)
return {"status": "ok", "name": name}
@plugin_router.post("/reload/{name}")
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)
return {"status": "ok", "name": name}
+1 -2
View File
@@ -15,8 +15,7 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Any, Dict, Literal
from kilostar.utils.access import TokenData, Accessor
from kilostar.utils.check_user.role_check import RoleChecker
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
+80 -8
View File
@@ -17,10 +17,11 @@ from pydantic import BaseModel
import viceroy
from kilostar.utils.ray_hook import ray_actor_hook
from fastapi import APIRouter, Depends, HTTPException
from kilostar.utils.access import TokenData
from kilostar.utils.check_user.role_check import RoleChecker
from fastapi.responses import FileResponse
from kilostar.utils.access import TokenData, RoleChecker, Accessor
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.utils.mcp_helper import list_mcp_tools_from_gsm
from kilostar.utils.settings import get_artifact_dir
resource_router = APIRouter(prefix="/api/v1/resource")
@@ -48,13 +49,12 @@ class MCPServerConfig(BaseModel):
async def install_skill(
skill: Skill, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))
):
"""通过 viceroy 把 skill 仓库克隆到 ``plugin/skill``,并在状态机中登记。"""
"""通过 viceroy 把 skill 仓库克隆到 ``data/plugin/skill``,并在状态机中登记。"""
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
import os
from kilostar.utils.settings import get_plugin_dir
skill_output_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "plugin", "skill")
)
skill_output_dir = str(get_plugin_dir() / "skill")
os.makedirs(skill_output_dir, exist_ok=True)
await viceroy.install_skill_async(
url=skill.repo_url, path=skill.path, output=skill_output_dir
@@ -133,6 +133,78 @@ async def delete_mcp_server(
return {"message": "success"}
# ─── Workflow Artifact 下载(agent send_file 投递的文件)───
@resource_router.get("/artifact/{trace_id}/{artifact_id}")
async def download_artifact(
trace_id: str,
artifact_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""下载某个 trace 名下的 agent 产物文件。
路径校验三件套:
1. trace 必须存在且属于当前用户
2. ``artifact_id`` 限定为 12 位 hexuuid4 前缀),防止穿越
3. 解析后的最终路径必须仍然落在 ``<artifact_dir>/<trace_id>/`` 之内
"""
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)
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")
trace_dir = (get_artifact_dir() / trace_id).resolve()
if not trace_dir.exists() or not trace_dir.is_dir():
raise HTTPException(status_code=404, detail="Artifact not found")
matches = list(trace_dir.glob(f"{artifact_id}_*"))
if not matches:
raise HTTPException(status_code=404, detail="Artifact not found")
target = matches[0].resolve()
if not str(target).startswith(str(trace_dir) + "/"):
raise HTTPException(status_code=400, detail="invalid path")
filename = target.name.split("_", 1)[-1]
return FileResponse(
path=str(target),
filename=filename,
media_type="application/octet-stream",
)
# ─── Toolset Packages(磁盘工具包:插件单元)───
@resource_router.get("/toolset-package")
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()
return {"packages": packages}
@resource_router.get("/toolset-package/{name}/readme")
async def get_toolset_package_readme(
name: str,
_: 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)
if content is None:
raise HTTPException(status_code=404, detail="README not found")
return {"name": name, "content": content}
# ─── Tool Management ───
@resource_router.get("/tool")
@@ -256,7 +328,7 @@ async def _assert_toolset_owner_or_admin(
toolset: Dict[str, Any], token_data: TokenData
) -> None:
"""校验 toolset 归属:非 owner 且非管理员则抛 403。"""
from kilostar.utils.check_user.role_check import get_authority
from kilostar.utils.access import get_authority
if toolset.get("owner_id") == token_data.user_id:
return
@@ -294,7 +366,7 @@ async def list_custom_toolsets(
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER)),
):
"""列出工具组:支持按 category 过滤。USER 只能看到自己的+系统的;ADMIN 看全部。"""
from kilostar.utils.check_user.role_check import get_authority
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()
+1 -2
View File
@@ -25,8 +25,7 @@ from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.check_user.role_check import RoleChecker
from kilostar.utils.access import Accessor, TokenData, RoleChecker
from kilostar.core.postgres_database.model import UserAuthority
from kilostar.utils.config_loader import (
get_workflow_config,
+1 -2
View File
@@ -18,8 +18,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ulid import ULID
import asyncio
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.check_user.role_check import RoleChecker
from kilostar.utils.access import Accessor, TokenData, RoleChecker
from kilostar.core.postgres_database.model import UserAuthority
workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"])