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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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 位 hex(uuid4 前缀),防止穿越
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user