Compare commits

38 Commits

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 13:10:31 +00:00
zhaoxi 6d658b4f4d 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>
2026-06-17 05:20:00 +00:00
zhaoxi 9b73ae4db4 fix: 修复 5 项确定 bug + Provider UX 重做 + 文档统一
Bug fixes:
- fix(dao): AsyncSession.delete 补齐漏掉的 await(provider/user/individual 共 4 处)
- fix(worker): result.data.output → result.output.output(pydantic-ai 1.x API 适配)
- fix(api): 删除 create_worker_from_template 死端点(ORM 字段不匹配必崩)
- fix(api): /provider/test 按 provider_type 分支适配 Anthropic/Gemini/OpenAI 三种协议
- fix(chat): SSE 流式聊天在 distributed 模式 fallback 到非流式,避免 asyncio.Queue 序列化崩溃

Features (previously unstaged):
- feat(provider): Provider 管理页重做(品牌图标、5 种类型、Test Connection、编辑模式)
- feat(provider): 新增 Gemini provider_type 支持
- feat(workflow): Finalize 节点输出 blackboard 摘要 + 失败原因;步骤完成/失败实时推送 SSE
- feat(i18n): regulatory_node 提示词从路由模式改为直接对话模式(中英双语)
- feat(consciousness): dynamic_prompt 支持 locale 国际化
- feat(logs): SystemLogsView 自动刷新 + 暂停按钮

Docs:
- docs: README/README-EN 统一为"开源通用多 Agent 协作平台"口径
- docs: ROADMAP 按 v0.1.x / v0.2.x / v0.3.x 重组
- docs: project.md 重写为结构化项目介绍

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 08:49:38 +00:00
zhaoxi c0fcbe2849 feat(mcp): 完整落地 MCP 服务管理
- Dockerfile 后端 stage 加 nodejs/npm,支持 stdio MCP server
- 新增前端 MCP 服务管理 UI(MCPSettings.tsx),支持 stdio/sse/http 三种 transport
- PluginLayout 改双 tab(技能 / MCP 服务)
- i18n 补 MCP 相关 zh/en 文案
- send_file 工具的 trace_id 改可选,聊天场景退化为返回内容
- system_workflow 工具集纳入 send_file

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 04:43:08 +00:00
zhaoxi b15eeb9e74 fix(toolset): 工具传递改为展开的 tools 列表,不再用 FunctionToolset 包装
前端/DB 仍用 toolset 做逻辑分组管理,但传给 pydantic-ai Agent 时
把 toolset 内的 callable 展开为 tools=[] 扁平列表,MCP server 等
需要 toolset 语义的单独走 toolsets=[] 参数。解决工具"存在但调不了"的问题。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 19:05:59 +00:00
zhaoxi 78d03388c0 fix(toolset): 空 toolset_ids 列表视为未配置,回退到返回全部工具集
system_node_config.tools 在 DB 中为 [],导致 regulatory_node 间歇性
无工具可用。将空列表等同于 None 处理,确保节点始终获得默认工具集。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:45:00 +00:00
zhaoxi 6792ad5485 fix(frontend): 将 toolsets 加入 AgentTab 类型联合,修复 TS2367 编译错误
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:29:16 +00:00
zhaoxi ac363bc6bc fix(toolset): 启动时自动补种系统工具集,工具集页面移至智能体侧边栏
- init_state_machine 启动时检查 DB 并补种 system_basic/system_chat/system_workflow
- 修复 postgres facade 缺少 is_system/category 参数的问题
- 前端工具集从"插件"独立为侧边栏"工具集"tab,位于智能体和插件之间

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:25:43 +00:00
zhaoxi 0e57c5cf16 feat(toolset): 工具系统重构为 toolset 统一管理,新增系统预置工具集
将工具管理从"agent 挂单个 tool"改为"agent 挂 toolset"模式:
- 三个系统预置工具集(system_basic/system_chat/system_workflow)入 DB
- 新增 send_file 工具(系统对话工具集)、修复 approval actor 调用 bug
- 后端 agent 加载全部走 toolset 链路,移除 load_tools_from_list
- 前端工具集中心卡片展示 + agent 配置改为 toolset 多选
- resource API 增加 category 过滤与系统 toolset 保护

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:03:49 +00:00
zhaoxi d39c80743d fix: regulatory 对话模式改用 event_stream_handler 修复工具调用截断,优化节点 prompt 和日志展示
- regulatory_node: stream_working 从 run_stream 改为 agent.run + event_stream_handler,
  解决工具调用后文本被截断的问题;添加 PartStartEvent 处理修复首字丢失
- consciousness_node: prompt 重写为三模式(生成/执行/报告),强调禁止编造 agent_id
- workflow API: _merge_runtime_status 暴露步骤输出内容(workflow_log 第三元素)
- 前端日志: 系统日志改为终端滚动样式,工作流步骤可展开查看输出

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 13:19:52 +00:00
zhaoxi ad5da2a118 fix: 工作流引擎接受 ForregulatoryNode 类型返回,修复最后一步失败
consciousness_node 在总结步骤返回 ForregulatoryNode 类型,但工作流引擎
只认 ForWorkflow 导致最后一步标记 failed。现在两种类型都被正确处理。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 09:18:40 +00:00
zhaoxi b61524e5d9 fix: chat stream 走 regulatory agent 支持工具调用,修复 workflow ValidationError
1. chat.py stream 端点改为调用 regulatory_node.stream_working()(pydantic-ai
   run_stream),支持工具调用 + 逐 token 流式输出
2. regulatory_node 新增 stream_working 方法,通过 asyncio.Queue 推送 token
3. ConsciousnessNodeDeps.available_skills 加默认值 None,修复 ForWorkflowInput/
   ForregulatoryInput 路径的 ValidationError

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 07:37:59 +00:00
zhaoxi 6f1bc27101 feat: v0.1.1 迭代——人设外键重构、Chat UI优化、意识节点防幻觉、日志双视图
1. 人设外键重构:persona_template 成为 system_prompt 唯一权威来源,
   agent/系统节点通过 persona_id FK 引用,含数据迁移脚本
2. Chat UI:去掉底部AI提示、加号改为弹出菜单、新建对话乐观跳转
3. 意识节点:无可用worker时禁止编造agent_id,只能自行完成或拒绝
4. 日志页面:双tab布局(系统日志 + 工作流日志列表选择)
5. 其他:SSE流式聊天、对话删除/重命名、standalone模式修复

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 06:18:47 +00:00
zhaoxi e3b8686d45 chore: 将 stardomain 加入主项目依赖
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:10:46 +00:00
zhaoxi 0bbd08638c feat(stardomain): 实现完整的沙箱执行引擎(local + Docker 双模式)
local 模式通过 tokio::process 真实执行命令,Docker 模式通过 bollard 创建容器运行,
两种模式均支持超时强杀、输出捕获和策略过滤。同时提供同步和异步 Python 接口。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:07:39 +00:00
zhaoxi 32bdbe77ff feat: 新增 stardomain 沙箱子项目脚手架(Docker + Rust 过滤层)
提供统一沙箱运行时,支持 local/sandbox 两种模式切换。Rust 层负责命令和代码的策略过滤,
Docker 层负责实际的进程隔离。包含三种预设策略:agent_exec / tool_run / untrusted。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:07:30 +00:00
zhaoxi 6932294ddd chore: 将 viceroy (Skill安装工具) 合并至 subprojects 统一管理
撤销独立仓库,直接纳入 KiloStar monorepo 的 subprojects/ 目录下。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 08:23:02 +00:00
zhaoxi 8f1398c591 feat: 人设模板系统、节点调度标签、pydantic-settings收敛、错误处理增强
新增persona_template表和CRUD API,BaseIndividualModel增加node_affinity和template_origin_id字段,
WorkerCluster支持多集群Ray资源调度,环境变量收敛到pydantic-settings统一校验,
数据库异常转换为结构化BusinessError/RetryableError,系统节点支持custom_system_prompt。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 06:07:46 +00:00
zhaoxi f3a92a793e feat(agent): 移除control_node实例化,新增系统节点命名与人设管理前端
当前阶段只保留regulatory+consciousness两个系统节点,control_node代码保留但不再实例化。
系统节点新增display_name字段支持自定义显示名称,前端新增人设管理Tab支持模板CRUD。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 06:03:28 +00:00
zhaoxi 9a7a5edd6e refactor(standalone): 收敛 _STANDALONE 判断到 standalone_proxy 单一来源
各模块不再各自读 os.environ,统一从 standalone_proxy._STANDALONE 导入,
环境变量只在一处决定模式,减少散落的重复逻辑。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 01:55:40 +00:00
zhaoxi 457d12834f feat(standalone): 新增单机模式,KILOSTAR_MODE=standalone 时去掉 Ray 依赖
通过 StandaloneProxy 适配层让 .remote() 调用在单机模式下透明降级为
asyncio 协程调用,7 个 Actor 和 workflow task 均可在纯 asyncio 环境运行,
启动快、资源占用低。分布式模式行为完全不变。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 15:52:41 +00:00
zhaoxi 76a67e8237 feat(config): 统一配置加载入口,启动时校验所有YAML配置
将分散的 config.yml、workflow.yaml、sandbox.yaml 加载逻辑统一到 AppConfig 模型,
启动时一次性校验,失败则 fast-fail。sandbox.py 改为从统一配置取值,消除重复加载。
同时修复 onebot 测试并新增14个统一配置测试(总测试 285→300)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 13:52:03 +00:00
zhaoxi 80174acaae feat(security): 新增工具沙箱安全机制
为所有工具插件添加沙箱拦截层,防止危险的文件访问、Shell命令和Python代码执行。
包含配置文件、核心校验逻辑及31个单元测试。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 12:09:15 +00:00
zhaoxi a53ffebe0e feat: 新增工具插件、系统日志、workflow配置及前端优化
1. 新增工具插件(edit_file, python_executor, search_file, shell_executor, write_file)
2. 新增系统事件日志模块和API
3. 新增workflow配置文件和详情API
4. 前端增加SSE、错误边界、设置引导等组件
5. 优化认证加密、速率限制、配置加载等工具模块
6. 删除废弃的cluster和health API
7. 补充单元测试和集成测试

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:34:43 +00:00
zhaoxi f04fef916f style(frontend):优化前端效果
1.对于UI的配色和布局进行了优化
2026-06-01 03:30:30 +00:00
zhaoxi 99520c69d7 feat(system):优化后端
1.新增后端测试
2.增加了后端的加密
3.增加了i18n(国际化)
2026-05-31 15:39:34 +00:00
zhaoxi affe460180 feat(frontend):优化前端页面设计 2026-05-29 16:44:17 +00:00
zhaoxi a83c5fa5bd style(agent): 调整agent结构 2026-05-18 05:33:11 +00:00
zhaoxi 6f6879dfab feat(provider):增加google,anthropic供应商
1.增加更多的模型供应商
2026-05-15 08:06:10 +08:00
zhaoxi 78bd6adc48 feat: workflow和chat分离
1,增加了创建workflow的页面
2.删除了event
2026-05-14 15:51:28 +00:00
zhaoxi c0e4fd34ae refactor: 修复sqlmodel的问题
* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Move workflow_engine inside workflow package to keep core root clean
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Move workflow_engine inside workflow package to keep core root clean
- Replace sqlmodel with pure sqlalchemy mappings globally
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
2026-05-12 22:52:19 +08:00
zhaoxi ff1ede47a0 Refactor Workflow and Chat Architecture (#68)
* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* refactor: overhaul workflow and chat architecture

- Separate Chat and Workflow API endpoints and database models
- Use JSONB to store workflow execution context in Postgres
- Convert workflow engine to use pydantic-ai execution graphs inside a Ray task
- Update frontend React components to support standalone workflow creation
- Move workflow_engine inside workflow package to keep core root clean
- Remove obsolete and broken workflow runner tests

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
2026-05-12 15:47:17 +08:00
zhaoxi ee9bbbf676 style: 项目重构
1.项目改名为kilostar(千星)
2.后端部分进行大规模重构
3.node功能进行大规模重新设计
2026-05-11 15:29:16 +00:00
zhaoxi 2d8571dee3 style: 移动 2026-05-08 18:34:02 +08:00
zhaoxi 209ba45477 refactor(core): decouple actors and remove workflow templates (#67)
Removes the deprecated `workflow_template` concept entirely across both backend API routers, internal logic handling within the `supervisory_node` and `consciousness_node`, and front-end components. Enables `consciousness_node` to work autonomously.

Also refactors core package structure to enforce the "one python package, one Ray Actor" architectural rule. `GlobalWorkflowManager`, `WorkflowRunningEngine`, `PostgresDatabase`, and `WorkerCluster` have been moved to their own top-level decoupled package directories with properly exported `__init__.py` modules. Test suites have been relocated and import paths updated across the system.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
2026-05-06 15:05:47 +08:00
zhaoxi b3ea4cd8d9 style(api): 重构api架构 2026-05-05 20:33:18 +08:00
411 changed files with 38064 additions and 10025 deletions
+2
View File
@@ -9,3 +9,5 @@ docker-compose.yml
.env.template
.env.example
.idea
docs/
tmp/
+6
View File
@@ -0,0 +1,6 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgrespassword
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=kilostar
SECRET_KEY=mysecretkey123456789
+18 -2
View File
@@ -2,5 +2,21 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgrespassword
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=pretor
SECRET_KEY=114514
POSTGRES_DB=kilostar
# JWT 签名密钥,必须填写一个高熵随机字符串,建议生成命令:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# 留空或填 "secret" / "114514" / "changethiskey12345" 等弱值会被拒绝。
SECRET_KEY=
# 数据加密密钥(Fernet),用于加密 provider apikey / tool config 中的敏感字段。
# 与 SECRET_KEY 独立,生成命令:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
KILOSTAR_SECRET_KEY=
# CORS 允许的源(逗号分隔,默认 "*" 允许所有源但禁用 credentials)。
# 生产建议显式列出:KILOSTAR_CORS_ORIGINS=https://app.example.com,https://admin.example.com
KILOSTAR_CORS_ORIGINS=*
# 日志格式:开发默认彩色 console;生产可设为 json 以便 ELK/Loki 采集。
# KILOSTAR_LOG_FORMAT=json
# KILOSTAR_LOG_LEVEL=INFO
+24 -10
View File
@@ -1,11 +1,25 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# 项目运行时数据:默认全部忽略,仅显式开放需要纳入版本控制的子目录
data/*
!data/toolset/
!data/plugin/
data/plugin/skill/
# Virtual environments
.venv
.idea
# 插件运行时 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/
-1
View File
@@ -1 +0,0 @@
3.13
+18 -1
View File
@@ -2,21 +2,36 @@
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
# Install system dependencies (for building PostgreSQL drivers and other native extensions)
# nodejs/npm are needed for stdio-mode MCP servers (e.g. npx -y @modelcontextprotocol/...)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
git \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install uv package manager
@@ -33,6 +48,8 @@ COPY . .
# Copy the built frontend static assets from Stage 1
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
# 重型插件前端 build 产物(让 /plugin-ui/<name>/ 静态挂载有内容可挂)
COPY --from=frontend-builder /app/data/plugin /app/data/plugin
# Expose FastAPI and Ray Dashboard ports
EXPOSE 8000 8265
+25
View File
@@ -1,2 +1,27 @@
run:
uv run main.py
clean-cache:
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
# Alembic 数据库迁移:m="message" 控制 revision 描述
db-revision:
uv run alembic revision --autogenerate -m "$(m)"
db-upgrade:
uv run alembic upgrade head
db-downgrade:
uv run alembic downgrade -1
db-history:
uv run alembic history --verbose
db-stamp-head:
uv run alembic stamp head
test:
docker compose down
docker rmi kilostar-kilostar:latest
docker compose up -d --build
+204
View File
@@ -0,0 +1,204 @@
<div align="center">
# KiloStar
An open-source general-purpose multi-agent collaboration platform
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/)
[![Ray](https://img.shields.io/badge/Distributed-Ray-0288d1.svg)](https://docs.ray.io/)
[![Pydantic-AI](https://img.shields.io/badge/Framework-Pydantic--AI-ff69b4.svg)](https://ai.pydantic.dev/)
[![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE)
[中文](./README.md) | [**Changelog**](./changelogs/CHANGELOG.md) | [**Roadmap**](./changelogs/ROADMAP.md)
</div>
---
## Overview
**KiloStar** is an open-source general-purpose multi-agent collaboration platform that provides a complete capability stack covering model integration, agent orchestration, workflow execution, and plugin extension. The system uses **Ray** for distributed execution, **Pydantic-AI** for type-safe agent development, and exposes a unified API surface through **FastAPI**.
The platform supports both cloud API models and locally fine-tuned models, ships with built-in core nodes for multi-agent collaboration (Regulatory, Consciousness, Control, Growth), and provides a **heavy plugin** mechanism that lets users reshape the platform into purpose-built agent applications.
> **Current version**: `v0.1.1-alpha`
## Highlights
- **Local fine-tuned models as first-class citizens**: Built-in vLLM adapter; locally fine-tuned models are interchangeable with cloud API models at the call site, allowing different agent nodes to bind different local models.
- **Heavy plugin mechanism**: Plugins can ship their own frontend pages, tool sets, and API endpoints — turning KiloStar into specialized agent applications such as coding assistants, learning helpers, or data analysis tools.
- **Multi-agent collaboration core**: Four system node types (Regulatory / Consciousness / Control / Growth) plus dynamically spawned Worker individuals, with task decomposition, scheduling, and supervision built in.
- **standalone / distributed dual mode**: Zero-dependency single-machine startup; horizontal scaling on demand. Business code is identical across both modes.
- **Private deployment friendly**: Every component runs inside the user's own environment without mandatory third-party dependencies.
---
## ✨ Core Capabilities
### 🧠 Multi-Agent Collaboration
- **System node specialization**: Regulatory, Consciousness, Control, and Growth nodes each cover a distinct responsibility
- **Worker dynamic spawning**: On-demand creation of Ordinary / Skill / Special Worker individuals
- **Strongly-typed communication**: Pydantic-AI constrains LLM output to structured data, eliminating the unstructured-text black box in multi-agent flows
### 🚀 Distributed Execution
- **Ray Actor model**: Cross-process and cross-machine collaboration for high-concurrency workloads
- **Heterogeneous resource labels**: `kilostar_node_cpu` / `core` / `gpu` route Workers to the right physical nodes
- **Standalone mode**: Zero external dependencies for single-machine startup; shares the same business code as distributed mode
### 🔄 Workflow Engine
- **pydantic-graph driven**: Directed-graph workflow orchestration with conditional branching and loops
- **Cross-process persistence**: PostgreSQL state snapshots enable workflow resume after interruption
- **Human-in-the-Loop (HITL)**: Built-in HumanApproval node with idempotent resume semantics
### 🧩 Plugin System
- **Tool plugins**: Standard tool calls; MCP protocol support for third-party services
- **Skill (compatible with Anthropic Agent Skills spec)**: Installed and parsed via [viceroy](https://github.com/zhaoxi826/viceroy), loaded on demand at runtime
- **Heavy plugins (planned)**: Vertical application packages with dedicated UI that reshape KiloStar into specialized platforms
### 🛡️ Security
- **JWT authentication**: All API endpoints (including SSE streams) require Bearer Token auth
- **Ownership enforcement**: Workflow / chat resources are user-bound; cross-user access returns 403
- **fetch-based SSE**: Token is transmitted via the `Authorization` header, never exposed in URLs
### 📦 Companion Subprojects
| Project | Codename | Purpose | Status |
|:--|:--|:--|:--|
| [kilostar-viceroy](https://github.com/zhaoxi826/viceroy) | Viceroy | Skill installation and cluster-wide distribution | ✅ Released |
| [kilostar-stardomain](./subprojects/stardomain) | Stardomain | Sandbox execution for Skill / plugin scripts | In progress |
| [kilostar-thought](https://github.com/zhaoxi826/thought) | Thought | Augmented memory system for agents | In progress |
---
## 🚀 Quick Start
### Docker Compose (Recommended)
```yaml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: kilostar
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kilostar"]
interval: 5s
timeout: 5s
retries: 5
kilostar:
image: zhaoxi5699/kilostar:v0.1.1alpha
ports:
- "8000:8000"
- "8265:8265"
depends_on:
db:
condition: service_healthy
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgrespassword
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=kilostar
- SECRET_KEY=changethiskey12345
```
```bash
docker compose up -d
```
Once running:
- Web Console: http://localhost:8000
- Ray Dashboard: http://localhost:8265
### Local Development
```bash
# Backend
uv sync
cp config/.env.example .env # Configure database and secret key
uv run python main.py
# Frontend
cd frontend && npm install && npm run dev
```
---
## 📁 Project Structure
```
KiloStar/
├── main.py # App entrypoint (FastAPI + Ray init)
├── pyproject.toml # Python dependencies & metadata
├── Dockerfile / docker-compose.yml # Container deployment
├── alembic/ # Database migrations
├── config/ # Environment config templates
├── kilostar/ # Backend core package
│ ├── api/ # FastAPI route layer
│ │ ├── system.py # /health system health checks
│ │ ├── workflow.py # /workflow CRUD + SSE + resume
│ │ ├── chat.py # /chat session management
│ │ ├── agent.py # /agent Worker management
│ │ └── resource.py # /resource Skill/Toolset mgmt
│ ├── core/ # Core business logic
│ │ ├── individual/ # Agent node implementations
│ │ │ ├── consciousness_node/ # Task planning
│ │ │ ├── regulatory_node/ # Quality oversight
│ │ │ ├── control_node/ # Deprecated (name reserved for future remote-probe node)
│ │ │ └── growth_node/ # Capability expansion
│ │ ├── work/ # Work execution layer
│ │ │ ├── workflow/ # Workflow engine (pydantic-graph)
│ │ │ ├── chat/ # Chat processing
│ │ │ └── task/ # Single-task execution
│ │ ├── global_state_machine/ # Global state (Provider/Config)
│ │ ├── global_workflow_manager/ # Workflow message queue Actor
│ │ └── postgres_database/ # PostgreSQL DAO layer
│ ├── adapter/ # Model adapters (OpenAI/vLLM/...)
│ ├── plugin/ # Tool plugins
│ │ └── tool_plugin/ # Tavily / FileReader / Approval
│ ├── utils/ # Utilities
│ │ ├── access.py # JWT authentication
│ │ ├── ray_hook.py # Ray Actor handle retrieval
│ │ └── check_user/ # Role-based authorization
│ ├── worker_cluster/ # Worker cluster management
│ └── worker_individual/ # Worker individual lifecycle
├── frontend/ # React frontend (Vite + Tailwind)
│ └── src/
│ ├── api/ # Axios client + SSE wrapper
│ ├── components/ # UI components
│ │ ├── Chat/ # Workflow panel + live graph
│ │ ├── Agent/ # Worker/Provider management
│ │ ├── Plugin/ # Skill/Tool configuration
│ │ └── Settings/ # System settings
│ ├── i18n/ # Internationalization (zh/en)
│ ├── store/ # Zustand state management
│ └── types/ # TypeScript type definitions
├── tests/ # Test suite (249+ cases)
│ ├── unit/ # Unit tests
│ └── integration/ # Integration smoke tests
└── docs/ # Design documents
```
---
## 🧪 Testing
```bash
# Run all tests
uv run pytest tests -q
# Unit tests only
uv run pytest tests/unit -q
# Integration tests
uv run pytest tests/integration -q
```
---
## 📄 License
This project is licensed under the [Apache License 2.0](LICENSE).
+95 -52
View File
@@ -1,74 +1,95 @@
<div align="center">
# Pretor (执政官)
# KiloStar (千星)
一款基于 Python 的分布式多 Agent 协作系统
开源通用多 Agent 协作平台
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/)
[![Ray](https://img.shields.io/badge/Distributed-Ray-0288d1.svg)](https://docs.ray.io/)
[![Pydantic-AI](https://img.shields.io/badge/Framework-Pydantic--AI-ff69b4.svg)](https://ai.pydantic.dev/)
[![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE)
[**项目架构**](./docs/ARCHITECTURE.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**未来展望**](./changelogs/ROADMAP.md)
[English](./README-EN.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**项目结构**](docs/STRUCTURE.md) | [**未来展望**](./changelogs/ROADMAP.md)
</div>
---
**Pretor** 是一款基于 **Ray** 构建的下一代分布式多 Agent 协作系统。项目采用“中心监管 + 边缘执行”的异构集群模式,通过大参数 MoE 模型进行高层逻辑推理,并协同微调后的轻量化模型高效完成具体任务。借助 **Pydantic-AI** 提供的强类型约束与 FastAPI 异步网关,Pretor 实现了任务从需求拆解、资源调度到自动化执行的全链路闭环,为个人提供可靠的人工智能助手服务。
## 简介
**KiloStar** 是一个开源的通用多 Agent 协作平台,提供从模型接入、Agent 编排、工作流执行到插件扩展的完整能力栈。系统基于 **Ray** 实现分布式执行,基于 **Pydantic-AI** 提供类型安全的 Agent 开发框架,并通过 **FastAPI** 网关对外暴露统一接口。
平台同时支持云端 API 模型与本地微调模型,内置多 Agent 协作的核心节点(监管、意识、控制、生长),并通过**重型插件**机制允许使用者把平台改造成面向具体场景的专用 Agent 应用。
> **当前版本**`v0.1.1-alpha`
## 项目特色
- **本地微调小模型一等公民**:内置 vLLM 适配,支持将本地微调模型部署为系统中的 Agent 节点,与云端 API 模型在调用层面对等
- **重型插件机制**:插件可附带独立前端页面、工具组与 API 接口,将 KiloStar 改造为编程辅助、学习助手、数据分析等专用 Agent 应用
- **多 Agent 协作内核**:监管 / 意识 / 控制 / 生长四类系统节点 + 动态派生的 Worker 个体,原生支持任务拆解、调度、监督的分工模式
- **分布式与单机统一**standalone 与 distributed 双模式共享同一套代码,单机零依赖起步,集群按需横向扩展
- **私有化部署友好**:所有组件可在用户自有环境内运行,不强制依赖任何第三方服务
---
## ✨ 核心特性
### 🧠 异构协作体系
- **多智能体集群**:内置主管 (Supervisory)、意识 (Consciousness)、控制 (Control) 三大核心节点,实现比单 Agent 系统更严谨的决策链。
- **Worker 动态派生**:根据任务需求动态拉起 Ordinary 或 Skill 类型的 Worker Individual,实现资源的按需分配。
## ✨ 核心能力
### 🚀 分布式性能保障
- **Ray 驱动**:底层基于 Ray 构建,支持跨进程、跨机器的 Actor 通讯,轻松应对高并发任务流。
- **本地化优先**:深度适配 **vLLM**,支持本地私有化模型部署,在保障隐私的同时大幅降低 API 调用成本。
### 🧠 多 Agent 协作
- **核心节点分工**:监管 (Regulatory)、意识 (Consciousness)、控制 (Control)、生长 (Growth) 四类系统节点
- **Worker 动态派生**:根据任务需求拉起 Ordinary / Skill / Special 三种 Worker 个体
- **强类型通信**:基于 Pydantic-AI 将 LLM 输出约束为结构化数据,避免多 Agent 协作中的非结构化文本黑盒
### 🛠️ 工业级工程设计
- **强类型契约**:基于 Pydantic-AI 实现 Tool 与 Agent 的接口定义,确保 AI 输出的确定性与安全性。
- **自动化流**:内置工作流引擎 (Workflow Engine),实现从需求发现到自动化执行的闭环。
### 🚀 分布式执行
- **Ray Actor 模型**:跨进程、跨机器协作,支持高并发任务流
- **异构资源标签**`kilostar_node_cpu` / `core` / `gpu` 调度不同 Worker 到合适节点
- **standalone 模式**:单机零依赖起步,与分布式模式共享同一套业务代码
### 📦 Pretor 生态子项目 (Sub-projects)
### 🔄 工作流引擎
- **pydantic-graph 驱动**:基于有向图的工作流编排,支持条件分支与循环
- **跨进程持久化**:PostgreSQL 状态快照,支持 workflow 中断后恢复(resume
- **人工介入 (HITL)**:内置 HumanApproval 节点,支持审批挂起与幂等恢复
| 项目名称 | 代号 | 功能定位 | 当前状态 |
|:-----------------------------------------------------------|:--------| :--- | :--- |
| **[pretor-viceroy](https://github.com/zhaoxi826/viceroy)** | **总督** | **资源管理**:负责系统 Skill 的动态安装、元数据解析与全集群分发。 | ✅ 已发布 |
| **pretor-stardomain** | **星域** | **安全沙箱**:为 Agent 自动生成的代码提供轻量化的隔离运行环境,防止逃逸。 | 📅 规划中 |
| **pretor-explorer** | **探索者** | **网页感知**:自动化爬虫引擎,赋予智能体实时互联网信息搜索与内容抓取能力。 | 📅 规划中 |
| **pretor-pioneer** | **先驱者** | **知识增强**:RAG 检索增强引擎,管理私有知识库的向量化、索引与精准检索。 | 📅 规划中 |
### 🧩 插件体系
- **工具插件**:标准 Tool 调用,支持 MCP 协议接入第三方服务
- **Skill(兼容 Anthropic Agent Skills 标准)**:通过 [viceroy](https://github.com/zhaoxi826/viceroy) 安装解析,运行时按需加载
- **重型插件(Organization**:带独立工具集、多 Agent 团队与前端面板的垂直应用包,以"部门"身份接入系统内阁
### 🛡️ 安全设计
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
- **归属校验**workflow / chat 资源严格绑定 user_id,跨用户访问返回 403
- **fetch-based SSE**Token 走 Authorization header,不暴露在 URL 中
### 📦 生态子项目
| 项目 | 代号 | 功能 | 状态 |
|:--|:--|:--|:--|
| [kilostar-viceroy](https://github.com/zhaoxi826/viceroy) | 总督 | Skill 动态安装与全集群分发 | ✅ 已发布 |
| [kilostar-stardomain](./subprojects/stardomain) | 星域 | Skill / 插件脚本沙箱执行 | 开发中 |
| [kilostar-thought](https://github.com/zhaoxi826/thought) | 思绪 | Agent 增强记忆系统 | 开发中 |
---
## 🚀 快速开始 (Quick Start)
> **当前版本**`v0.1.0-alpha` (开发预览版)
> 本项目目前处于快速迭代阶段,欢迎提交 Issue 或 Pull Request。
## 🚀 快速开始
### 方式一:使用 Docker Compose (推荐)
这是部署 **Pretor 应用** 及其配套 **PostgreSQL 数据库** 最简单、最完整的方式。
### Docker Compose推荐
1. **准备配置文件**:在本地创建一个目录,并新建 `docker-compose.yml`
```yaml
services:
db:
image: postgres:16-alpine
container_name: pretor_db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: pretor
POSTGRES_DB: kilostar
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
test: ["CMD-SHELL", "pg_isready -U postgres -d kilostar"]
interval: 5s
timeout: 5s
retries: 5
pretor:
image: zhaoxi5699/pretor:v0.1.0alpha
container_name: pretor
kilostar:
image: zhaoxi5699/kilostar:v0.1.1alpha
ports:
- "8000:8000"
- "8265:8265"
@@ -80,31 +101,53 @@
- POSTGRES_PASSWORD=postgrespassword
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=pretor
- SECRET_KEY=changethiskey12345 # 请在生产环境中修改此密钥
- POSTGRES_DB=kilostar
- SECRET_KEY=changethiskey12345
```
2. **启动服务**
```bash
docker compose up -d
```
### 方式二:使用 Docker
1. **启动服务**
启动后访问:
- Web 控制台:http://localhost:8000
- Ray Dashboardhttp://localhost:8265
### 本地开发
```bash
docker run -d \
--name pretor \
-p 8000:8000 \
-p 8265:8265 \
-e POSTGRES_HOST=你的数据库IP \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgrespassword \
-e POSTGRES_DB=pretor \
-e SECRET_KEY=your_secret_key \
zhaoxi5699/pretor:v0.1.0alpha
# 后端
uv sync
cp config/.env.example .env # 编辑数据库和密钥配置
uv run python main.py
# 前端
cd frontend && npm install && npm run dev
```
## 🔍 访问与验证
服务启动后,可以通过以下地址进行操作:
- Web 控制台 / API 文档: http://localhost:8000
- Ray 任务仪表盘: http://localhost:8265
---
## 📁 项目结构
详见 [docs/STRUCTURE.md](docs/STRUCTURE.md)。
---
## 🧪 测试
```bash
# 全量测试
uv run pytest tests -q
# 仅单元测试
uv run pytest tests/unit -q
# 集成测试(标记 integration
uv run pytest tests/integration -q
```
---
## 📄 开源协议
本项目基于 [Apache License 2.0](LICENSE) 开源。
+58
View File
@@ -0,0 +1,58 @@
# A generic, single database configuration.
[alembic]
# 迁移脚本目录
script_location = alembic
# 默认时间戳模板
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# 时区
timezone = Asia/Shanghai
# Path to alembic version_path(多环境可以分目录)
version_locations = alembic/versions
# 数据库 URL:留空,由 env.py 从环境变量动态读取
sqlalchemy.url =
[post_write_hooks]
# 默认不做格式化;如需可启用 black
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 88 REVISION_SCRIPT_FILENAME
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+97
View File
@@ -0,0 +1,97 @@
"""Alembic 迁移环境。
设计要点:
1. 数据库 URL 从 ``POSTGRES_*`` 环境变量动态拼装,不污染 ``alembic.ini``
2. 复用项目本身的 ORM metadata``BaseDataModel.metadata`` 让 autogenerate
能识别全部表;
3. 与运行期保持一致使用 ``asyncpg`` 异步驱动 —— 通过 ``async_engine_from_config``
建立 AsyncEngine,再借 ``run_sync`` 把 alembic 的 sync migration 执行入口
挂载进去。这样无需额外引入 psycopg 等同步驱动。
"""
from __future__ import annotations
import asyncio
import os
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from kilostar.core.postgres_database.model.base import BaseDataModel # noqa: E402
from kilostar.core.postgres_database import model as _model_pkg # noqa: F401,E402
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
def _build_db_url() -> str:
"""从 ``POSTGRES_*`` 环境变量拼装一个 asyncpg URL。"""
user = os.environ.get("POSTGRES_USER", "postgres")
password = os.environ.get("POSTGRES_PASSWORD", "postgrespassword")
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
port = os.environ.get("POSTGRES_PORT", "5432")
db = os.environ.get("POSTGRES_DB", "kilostar")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}"
config.set_main_option("sqlalchemy.url", _build_db_url())
target_metadata = BaseDataModel.metadata
def run_migrations_offline() -> None:
"""离线模式:不连库,直接把 SQL 渲染到 stdout。"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def _do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def _run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(_do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(_run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,29 @@
"""initial baseline
Revision ID: 0001_initial
Revises:
Create Date: 2026-05-31 00:00:00
这是 Alembic 接入时的占位 baseline。
- 全新部署:``alembic upgrade head`` 会跑过这条 no-op
然后由后续 ``alembic revision --autogenerate`` 生成真正的建表脚本,
或在首次部署时由应用 ``Base.metadata.create_all`` 直接建表,再 ``alembic stamp head``。
- 已有数据库:直接 ``alembic stamp 0001_initial`` 标定基线,再做后续 autogenerate。
"""
from typing import Sequence, Union
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,49 @@
"""add workflow_graph_state and system_event_log tables
Revision ID: 0002_graph_and_logs
Revises: 0001_initial
Create Date: 2026-06-02 00:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0002_graph_and_logs"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"workflow_graph_state",
sa.Column("trace_id", sa.String(64), primary_key=True, comment="对应的工作流 Trace ID"),
sa.Column("history", postgresql.JSONB(), nullable=False, server_default="[]", comment="pydantic_graph history JSON"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"system_event_log",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("trace_id", sa.String(64), nullable=False, comment="关联的工作流 trace_id"),
sa.Column("event_type", sa.String(50), nullable=False, comment="事件类型"),
sa.Column("level", sa.String(10), nullable=False, server_default="info", comment="日志级别"),
sa.Column("node_name", sa.String(100), nullable=True, comment="相关节点名称"),
sa.Column("message", sa.Text, nullable=False, comment="日志消息正文"),
sa.Column("extra_data", postgresql.JSONB(), nullable=True, comment="附加元数据"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_system_event_log_trace_id", "system_event_log", ["trace_id"])
op.create_index("ix_system_event_log_event_type", "system_event_log", ["event_type"])
op.create_index("ix_system_event_log_level", "system_event_log", ["level"])
op.create_index("ix_system_event_log_created_at", "system_event_log", ["created_at"])
def downgrade() -> None:
op.drop_table("system_event_log")
op.drop_table("workflow_graph_state")
@@ -0,0 +1,69 @@
"""add persona_template table, node_affinity and template_origin_id to base_individual
Revision ID: 0003_persona_template
Revises: 0002_graph_and_logs
Create Date: 2026-06-04 00:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0003_persona_template"
down_revision: Union[str, None] = "0002_graph_and_logs"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"persona_template",
sa.Column("template_id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("system_prompt", sa.Text(), nullable=False, server_default=""),
sa.Column("agent_type", sa.String(32), nullable=False, server_default="ordinary"),
sa.Column("provider_title", sa.String(50), nullable=True),
sa.Column("model_id", sa.String(100), nullable=True),
sa.Column("tools", postgresql.JSONB(), nullable=True, server_default="'[]'::jsonb"),
sa.Column("tags", postgresql.JSONB(), nullable=True, server_default="'[]'::jsonb"),
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("owner_id", sa.String(64), nullable=True),
)
op.create_index("ix_persona_template_name", "persona_template", ["name"])
op.create_index("ix_persona_template_owner_id", "persona_template", ["owner_id"])
op.add_column(
"base_individual",
sa.Column("node_affinity", sa.String(32), nullable=False, server_default="cpu"),
)
op.add_column(
"base_individual",
sa.Column("template_origin_id", sa.String(64), nullable=True),
)
op.create_foreign_key(
"fk_base_individual_template_origin",
"base_individual",
"persona_template",
["template_origin_id"],
["template_id"],
ondelete="SET NULL",
)
op.create_index(
"ix_base_individual_template_origin_id",
"base_individual",
["template_origin_id"],
)
def downgrade() -> None:
op.drop_index("ix_base_individual_template_origin_id", "base_individual")
op.drop_constraint("fk_base_individual_template_origin", "base_individual", type_="foreignkey")
op.drop_column("base_individual", "template_origin_id")
op.drop_column("base_individual", "node_affinity")
op.drop_index("ix_persona_template_owner_id", "persona_template")
op.drop_index("ix_persona_template_name", "persona_template")
op.drop_table("persona_template")
@@ -0,0 +1,27 @@
"""add custom_system_prompt to system_node_config
Revision ID: 0004_system_node_custom_prompt
Revises: 0003_persona_template
Create Date: 2026-06-04 00:01:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004_system_node_custom_prompt"
down_revision: Union[str, None] = "0003_persona_template"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"system_node_config",
sa.Column("custom_system_prompt", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("system_node_config", "custom_system_prompt")
@@ -0,0 +1,28 @@
"""system_node_display_name
Revision ID: 0005_system_node_display_name
Revises: 0004_system_node_custom_prompt
Create Date: 2026-06-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0005_system_node_display_name"
down_revision: Union[str, None] = "0004_system_node_custom_prompt"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"system_node_config",
sa.Column("display_name", sa.String(100), nullable=True),
)
def downgrade() -> None:
op.drop_column("system_node_config", "display_name")
@@ -0,0 +1,34 @@
"""simplify persona_template to name + system_prompt
Revision ID: 0006
Revises: 0005
Create Date: 2026-06-04
"""
from alembic import op
import sqlalchemy as sa
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("persona_template", "description")
op.drop_column("persona_template", "agent_type")
op.drop_column("persona_template", "provider_title")
op.drop_column("persona_template", "model_id")
op.drop_column("persona_template", "tools")
op.drop_column("persona_template", "tags")
op.drop_column("persona_template", "is_builtin")
def downgrade() -> None:
op.add_column("persona_template", sa.Column("description", sa.Text(), nullable=False, server_default=""))
op.add_column("persona_template", sa.Column("agent_type", sa.String(32), nullable=False, server_default="ordinary"))
op.add_column("persona_template", sa.Column("provider_title", sa.String(50), nullable=True))
op.add_column("persona_template", sa.Column("model_id", sa.String(100), nullable=True))
op.add_column("persona_template", sa.Column("tools", sa.JSON(), server_default="[]"))
op.add_column("persona_template", sa.Column("tags", sa.JSON(), server_default="[]"))
op.add_column("persona_template", sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"))
@@ -0,0 +1,82 @@
"""persona_id FK refactor: agents and system nodes reference persona_template
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
from ulid import ULID
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Data migration: create persona records from existing system_prompts
conn = op.get_bind()
# Migrate base_individual.system_prompt → persona_template records
rows = conn.execute(
sa.text(
"SELECT agent_id, system_prompt, owner_id FROM base_individual "
"WHERE system_prompt IS NOT NULL AND system_prompt != ''"
)
).fetchall()
for row in rows:
template_id = str(ULID())
conn.execute(
sa.text(
"INSERT INTO persona_template (template_id, name, system_prompt, owner_id) "
"VALUES (:tid, :name, :prompt, :owner)"
),
{"tid": template_id, "name": f"auto_{row[0][:8]}", "prompt": row[1], "owner": row[2]},
)
conn.execute(
sa.text(
"UPDATE base_individual SET template_origin_id = :tid WHERE agent_id = :aid"
),
{"tid": template_id, "aid": row[0]},
)
# Migrate system_node_config.custom_system_prompt → persona_template
node_rows = conn.execute(
sa.text(
"SELECT node_name, custom_system_prompt FROM system_node_config "
"WHERE custom_system_prompt IS NOT NULL AND custom_system_prompt != ''"
)
).fetchall()
# Add persona_id column to system_node_config before populating
op.add_column(
"system_node_config",
sa.Column("persona_id", sa.String(64), sa.ForeignKey("persona_template.template_id", ondelete="SET NULL"), nullable=True),
)
for row in node_rows:
template_id = str(ULID())
conn.execute(
sa.text(
"INSERT INTO persona_template (template_id, name, system_prompt, owner_id) "
"VALUES (:tid, :name, :prompt, NULL)"
),
{"tid": template_id, "name": f"node_{row[0]}", "prompt": row[1]},
)
conn.execute(
sa.text(
"UPDATE system_node_config SET persona_id = :tid WHERE node_name = :nn"
),
{"tid": template_id, "nn": row[0]},
)
# 2. Rename template_origin_id → persona_id on base_individual
op.alter_column("base_individual", "template_origin_id", new_column_name="persona_id")
# 3. Drop old columns
op.drop_column("base_individual", "system_prompt")
op.drop_column("system_node_config", "custom_system_prompt")
@@ -0,0 +1,149 @@
"""toolset system refactor: add is_system/category to custom_toolset, seed system toolsets, migrate node tools to toolset_ids
Revision ID: 0008
Revises: 0007
Create Date: 2026-06-05
"""
from alembic import op
import sqlalchemy as sa
from ulid import ULID
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
# 系统预置 toolset 的固定 ID(前端可能引用)
SYSTEM_TOOLSETS = [
{
"toolset_id": "system_basic",
"name": "系统基础工具集",
"description": "文件读写、搜索、Python/Shell 执行等基础能力",
"category": "system_basic",
"tools": [
"file_reader",
"write_file",
"edit_file",
"search_file",
"python_executor",
"shell_executor",
],
},
{
"toolset_id": "system_chat",
"name": "系统对话工具集",
"description": "对话场景专用工具,例如向用户发送文件附件",
"category": "system_chat",
"tools": ["send_file"],
},
{
"toolset_id": "system_workflow",
"name": "系统工作流工具集",
"description": "工作流场景专用工具,例如人工审批",
"category": "system_workflow",
"tools": ["approval"],
},
]
def upgrade() -> None:
# 1. 加 is_system / category 列
op.add_column(
"custom_toolset",
sa.Column(
"is_system",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.add_column(
"custom_toolset",
sa.Column(
"category",
sa.String(32),
nullable=False,
server_default=sa.text("'user'"),
),
)
op.create_index(
"ix_custom_toolset_is_system", "custom_toolset", ["is_system"]
)
op.create_index("ix_custom_toolset_category", "custom_toolset", ["category"])
# 2. 种子三个系统工具集
conn = op.get_bind()
for ts in SYSTEM_TOOLSETS:
conn.execute(
sa.text(
"""
INSERT INTO custom_toolset
(toolset_id, name, description, owner_id, tools, is_system, category)
VALUES (:tid, :name, :desc, NULL, CAST(:tools AS JSONB), true, :cat)
ON CONFLICT (toolset_id) DO NOTHING
"""
),
{
"tid": ts["toolset_id"],
"name": ts["name"],
"desc": ts["description"],
"tools": _json_dump(ts["tools"]),
"cat": ts["category"],
},
)
# 3. 迁移 system_node_config.tools:旧值是 tool_name 列表,按工具名归属推断 toolset_id
_migrate_tools_to_toolsets(conn, "system_node_config", "node_name")
# 4. 同样迁移 specialist_individual / ordinary_individual
_migrate_tools_to_toolsets(conn, "specialist_individual", "agent_id")
_migrate_tools_to_toolsets(conn, "ordinary_individual", "agent_id")
def downgrade() -> None:
op.drop_index("ix_custom_toolset_category", table_name="custom_toolset")
op.drop_index("ix_custom_toolset_is_system", table_name="custom_toolset")
op.drop_column("custom_toolset", "category")
op.drop_column("custom_toolset", "is_system")
# 注意:tools 字段语义变更不可逆——保留 toolset_ids,不还原
def _json_dump(value) -> str:
import json
return json.dumps(value, ensure_ascii=False)
_TOOL_TO_TOOLSET = {
"file_reader": "system_basic",
"write_file": "system_basic",
"edit_file": "system_basic",
"search_file": "system_basic",
"python_executor": "system_basic",
"shell_executor": "system_basic",
"send_file": "system_chat",
"approval": "system_workflow",
}
def _migrate_tools_to_toolsets(conn, table: str, pk_col: str) -> None:
"""把表里 ``tools`` 字段(旧:tool_name 列表)转换为 toolset_id 列表。
第三方/未知工具名直接丢弃(这些应该由用户自定义的 toolset 承载,迁移期不识别)。
"""
rows = conn.execute(
sa.text(f"SELECT {pk_col}, tools FROM {table} WHERE tools IS NOT NULL")
).fetchall()
for pk, old_tools in rows:
if not old_tools:
continue
toolset_ids = sorted({
_TOOL_TO_TOOLSET[t] for t in old_tools if t in _TOOL_TO_TOOLSET
})
conn.execute(
sa.text(
f"UPDATE {table} SET tools = CAST(:val AS JSONB) WHERE {pk_col} = :pk"
),
{"val": _json_dump(list(toolset_ids)), "pk": pk},
)
@@ -0,0 +1,53 @@
"""add org_task and org_task_event tables for heavy plugin system
Revision ID: 0009
Revises: 0008
Create Date: 2026-06-16
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"org_task",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("task_id", sa.String(64), unique=True, index=True),
sa.Column("org_name", sa.String(128), index=True),
sa.Column("status", sa.String(20), index=True, server_default="pending"),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("result", sa.Text(), nullable=True),
sa.Column("context", 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(),
),
)
op.create_table(
"org_task_event",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("task_id", sa.String(64), index=True),
sa.Column("event_type", sa.String(30), index=True),
sa.Column("payload", JSONB, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
index=True,
),
)
@@ -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")
+20
View File
@@ -0,0 +1,20 @@
# Alembic versions
迁移脚本会被自动生成到这个目录。
常用命令(在项目根目录运行):
- 生成 baseline(首次接入,已有数据库):
`alembic stamp head`
- 自动检测 ORM 与 DB 差异并生成迁移:
`alembic revision --autogenerate -m "your message"`
- 应用所有未执行的迁移:
`alembic upgrade head`
- 回滚一个版本:
`alembic downgrade -1`
- 查看历史:
`alembic history --verbose`
+1 -1
View File
@@ -17,7 +17,7 @@
- **分布式 Actor 骨架**:基于 Ray 框架构建了多智能体协作底座,支持节点跨进程通讯与资源调度。
- **全局状态机 (GSM)**:实现了 `GlobalStateMachine` 模块,作为系统的“唯一真相来源”,管理所有 Individual、Skill 和 Provider 的注册信息。
- **核心认知节点**
- `SupervisoryNode`:负责任务拆解与分发。
- `regulatoryNode`:负责任务拆解与分发。
- `ConsciousnessNode`:负责意图识别与语义理解。
- `ControlNode`:负责工作流状态监控与逻辑卡点。
- **异步工作流引擎**:实现 `WorkflowRunningEngine`,支持从数据库自动轮询并异步执行待办任务流。
+67 -14
View File
@@ -1,17 +1,70 @@
# Roadmap
# ROADMAP
KiloStar 各阶段的方向规划。已完成项归入 [CHANGELOG](./CHANGELOG.md)。
---
## [v0.1.0Alpha] - 2026/4/28
### 未来展望:
#### 功能增加
- [ ] **完善系统插件**: 如 **RAG(检索增强生成)****沙箱** **联网搜索** ,使agent拥有更多的能力适应多样化任务需求
- [ ] **增加MCP功能**: 增加MCP,使得agent可以调用通用工具
- [ ] **完善special_individual** 使得`supervisory_node`等可以调用实现语言生成图像生成等功能
- [ ] **完善supervisory_node**: 实现`supervisory_node`对于工作流状态的访问,实现更方便的检测
- [ ] **对消息平台的对接**: 完善platform,实现对于更多消息平台的对接(如:钉钉微信等),实现在社交软件对`supervisory_node`下达命令
#### 系统优化
- [ ] **优化workflow逻辑**: 通过**graph**等设计实现更优秀的工作流调度
- [ ] **优化GSM设计**: 对于 **GSMglobal_state_machine全局状态机)** 进行重构,实现更高的并发
- [ ] **工具及skill优化**: 完善前端获取工具或skill的逻辑,实现对于skill或者tool的配置改写以及详细信息获取
- [ ] **前端优化**: 完善前端设置逻辑(如:调节语言等),以及使前端更加灵活智能
## v0.1.x 系列(当前)
主线目标:补齐 v0.1.0 骨架之上的工程化与可用性短板,让平台进入"装上能用"的状态。
### 已完成(截至 v0.1.1-alpha
- **多 Agent 编排能力线**:人设模板、节点调度标签、Worker 动态派生、调控节点对话模式重构
- **工具系统重构**:以 toolset 为单位组织工具,系统预置工具集自动补种,Agent 工具集多选绑定
- **MCP 完整接入**:前端 CRUD、Dockerfile Node.js、后端 API 全链路落地,可作为标准 MCP 客户端调用第三方 MCP 服务器
- **Provider UX 重做**5 种 Provider 类型、品牌图标、默认 URL、Test Connection、API key 脱敏
- **沙箱执行子项目**stardomain 落地(local + Docker 双模式)
- **基础安全**:JWT 鉴权、资源归属校验、fetch-based SSE、生产密钥强制校验
### 计划中
#### 平台体验
- **重型插件机制**:定义包格式(manifest + frontend + backend + tools + agent 配置)、挂载协议、生命周期管理;为后续生态铺路
- **Skill 工程化**:兼容 Anthropic Agent Skills 标准的同时,补充 KiloStar 自己的 SkillManifest 抽象(依赖识别、文件分类、执行模式声明)
- **Provider 模型调用参数体系**:贯通 temperature / top_p / 自定义 headers / 超时 等模型调用参数,统一前后端
- **前端 Tauri 桌面端**:把当前 Web 前端打包为 Tauri 桌面客户端,承载需要本地能力的功能
#### 模型与 Agent 能力
- **本地微调模型集成**:vLLM 适配深化,支持把本地微调小模型部署为 Agent 节点
- **special_individual 完善**embedding / TTS / 图像生成等特殊 Agent 标准化接入
- **regulatory_node 工作流可见性**:监管节点对工作流执行状态的访问与干预能力
#### 系统性优化
- **GSM 写入路径优化**:单 Actor 写串行化的瓶颈处理,方向上倾向"PG 为真相之源 + GSM 退化为热缓存"
- **workflow 引擎深化**:基于 pydantic-graph 的更复杂调度模式(嵌套子流、并行分支汇聚等)
- **可观测性**:跨节点 trace 串联、workflow 执行可视化、日志检索体验
---
## v0.2.x 系列(中期)
围绕"通用 Agent 平台"的关键缺口。
- **重型插件生态**:第一批官方重型插件示例 + 第三方插件开发文档
- **Skill 分发与缓存层**:跨节点的 Skill 同步策略,按需拉取 + 本地缓存 + 哈希去重
- **多镜像部署**:拆分 core / worker / gpu / standalone 等多镜像,按场景组合
- **消息平台对接**:完善 platform 模块,支持钉钉 / 微信 / Slack 等平台的接入
- **persona 外键化**:人设统一为外键引用,消除 system_prompt 的数据冗余
---
## v0.3.x 系列及之后(远期)
- **特殊 Agent 生态**embedding / 多模态 / 语音 / 图像生成等专项 Agent 的标准化接入
- **生长机制**growth_node 真正实现集群与子个体的自适应扩张
- **微调模型工具链**:与 unsloth / axolotl 等微调框架的集成路径,把"训练 → 部署 → 接入"做成顺滑流程
- **多用户多租户**:从单实例多用户演进到真正的多租户隔离
- **联邦化部署**:跨组织的 Agent 协作与资源借用机制
---
## 不在路线图中
KiloStar 不计划自己做:
- 任何具体垂直场景的 Agent 产品(编程助手、英语学习、数据分析等都应通过重型插件实现)
- 闭源模型的深度定制
- 自研推理引擎(继续依托 vLLM / llama.cpp 等成熟方案)
> 路线图按版本节奏组织,但实际推进顺序会根据使用反馈调整。重大方向变化会在此文档留痕。
+2 -2
View File
@@ -1,2 +1,2 @@
version: v0.1
name:
version: v0.1.1-alpha
name: Kilostar
+46
View File
@@ -0,0 +1,46 @@
# KiloStar 沙箱安全策略配置
sandbox:
enabled: true
# 文件系统沙箱
filesystem:
workspace_root: "/tmp/kilostar_workspace"
allowed_read_paths:
- "/tmp"
denied_paths:
- "/etc/shadow"
- "/etc/passwd"
- "/root"
# Shell 命令沙箱
shell:
enabled: true
blocked_commands:
- "rm -rf /"
- "mkfs"
- "dd "
- "shutdown"
- "reboot"
blocked_operators:
- "&&"
- "||"
- ";"
- "`"
- "$("
max_timeout: 60
# Python 执行器沙箱
python_executor:
enabled: true
max_timeout: 30
blocked_imports:
- "os"
- "subprocess"
- "shutil"
- "socket"
- "ctypes"
blocked_builtins:
- "exec"
- "eval"
- "compile"
- "__import__"
+2
View File
@@ -0,0 +1,2 @@
retry:
max_attempts: 5
+1
View File
@@ -0,0 +1 @@
"""data_analytics 重型插件包。"""
+16
View File
@@ -0,0 +1,16 @@
{
"agents": [
{
"name": "analyst",
"role": "数据分析师",
"system_prompt": "你是一位严谨、克制的数据分析师。任务进来后:1) 先用 s3_list_objects/s3_peek 看几行了解结构;2) 决定用 python_executor(小数据,单机 pandas)或 ray_submit(大数据,分布式);3) 执行分析、得出明确结论,必要时给出图表链接或样例数据。注意:你只能读取 S3,**不能写入**。如果用户让你上传/删除/修改对象,请明确告知做不到。",
"tools": ["s3_list_objects", "s3_peek", "s3_get_object", "ray_submit", "python_executor"],
"skills": [],
"peers": []
}
],
"orchestration": {
"type": "react",
"entry": "analyst"
}
}
+150
View File
@@ -0,0 +1,150 @@
"""data_analytics 插件 API:凭证 CRUD + 分析任务提交/查询/事件流。
挂载后路径前缀为 /api/v1/plugin/data_analytics/...,跟核心 API 完全独立。
所有数据库读写都走 organization actor 的代理方法(确保分布式模式下不跨 actor
共享 SQLAlchemy session)。
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from kilostar.utils.access import Accessor, TokenData
from kilostar.utils.ray_hook import ray_actor_hook
router = APIRouter(tags=["data_analytics"])
# ─── Schemas ────────────────────────────────────────────────────────────────
class CredentialCreate(BaseModel):
display_name: str = Field(..., max_length=100)
endpoint_url: Optional[str] = None
region: str = "us-east-1"
access_key: str = Field(..., min_length=1)
secret_key: str = Field(..., min_length=1)
class JobCreate(BaseModel):
cred_id: str
description: str = Field(..., min_length=1, max_length=2000)
# ─── Helpers ────────────────────────────────────────────────────────────────
def _get_org():
try:
return ray_actor_hook("org_data_analytics").org_data_analytics
except Exception as e:
raise HTTPException(503, f"data_analytics 插件未就绪:{e}")
# ─── Credentials ────────────────────────────────────────────────────────────
@router.get("/credentials")
async def list_credentials(
token_data: TokenData = Depends(Accessor.get_current_user),
):
org = _get_org()
rows = await org.cred_list.remote(token_data.username)
return {"credentials": rows}
@router.post("/credentials")
async def create_credential(
payload: CredentialCreate,
token_data: TokenData = Depends(Accessor.get_current_user),
):
org = _get_org()
row = await org.cred_create.remote(
user_id=token_data.username,
display_name=payload.display_name,
access_key=payload.access_key,
secret_key=payload.secret_key,
endpoint_url=payload.endpoint_url,
region=payload.region,
)
return row
@router.delete("/credentials/{cred_id}")
async def delete_credential(
cred_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
org = _get_org()
ok = await org.cred_delete.remote(cred_id, token_data.username)
if not ok:
raise HTTPException(404, "凭证不存在或不属于当前用户")
return {"status": "ok"}
# ─── Jobs ───────────────────────────────────────────────────────────────────
@router.post("/jobs")
async def create_job(
payload: JobCreate,
token_data: TokenData = Depends(Accessor.get_current_user),
):
org = _get_org()
try:
return await org.job_create.remote(
user_id=token_data.username,
cred_id=payload.cred_id,
description=payload.description,
)
except ValueError as e:
raise HTTPException(400, str(e))
@router.get("/jobs")
async def list_jobs(
token_data: TokenData = Depends(Accessor.get_current_user),
):
org = _get_org()
rows = await org.job_list.remote(token_data.username)
return {"jobs": rows}
@router.get("/jobs/{job_id}")
async def get_job(
job_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
org = _get_org()
row = await org.job_get.remote(job_id, token_data.username)
if row is None:
raise HTTPException(404, "任务不存在")
return row
@router.get("/jobs/{job_id}/stream")
async def stream_job(
job_id: str,
token_data: TokenData = Depends(Accessor.get_current_user),
):
"""转发 organization 事件流为 SSE。"""
import json
org = _get_org()
row = await org.job_get.remote(job_id, token_data.username)
if row is None:
raise HTTPException(404, "任务不存在")
org_task_id = row.get("org_task_id")
if not org_task_id:
raise HTTPException(409, "任务尚未投递到 organization")
async def _generate():
async for event in await org.stream.remote(org_task_id):
payload = event if isinstance(event, str) else json.dumps(event, ensure_ascii=False)
yield f"data: {payload}\n\n"
return StreamingResponse(_generate(), media_type="text/event-stream")
@@ -0,0 +1 @@
"""data_analytics organization 实现。"""
+235
View File
@@ -0,0 +1,235 @@
"""data_analytics 插件本地 SQLite 表与 DAO。
注意:本插件用的 ``DeclarativeBase`` 跟核心 PG 完全独立,避免元数据空间串场。
所有数据落到 ``data/plugin/data_analytics/_data/data_analytics.db``。
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from sqlalchemy import DateTime, String, Text, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from kilostar.utils.crypto import decrypt_dict_secrets, encrypt_dict_secrets
class Base(DeclarativeBase):
"""data_analytics 插件私有的元数据空间,跟核心 PG 隔离。"""
pass
class S3Credential(Base):
__tablename__ = "s3_credential"
cred_id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
endpoint_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
region: Mapped[str] = mapped_column(String(50), default="us-east-1")
access_key: Mapped[str] = mapped_column(String(255), nullable=False)
secret_key: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class AnalysisJob(Base):
__tablename__ = "analysis_job"
job_id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
cred_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
description: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
org_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
result: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False, index=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class CredentialDAO:
"""S3 凭证 DAO:写入时自动加密,读取时自动解密。"""
SENSITIVE_KEYS = ("access_key", "secret_key")
def __init__(self, sm: async_sessionmaker[AsyncSession]):
self._sm = sm
@staticmethod
def _row_to_dict(row: S3Credential, *, include_secrets: bool) -> dict:
d = {
"cred_id": row.cred_id,
"user_id": row.user_id,
"display_name": row.display_name,
"endpoint_url": row.endpoint_url,
"region": row.region,
"access_key": row.access_key,
"secret_key": row.secret_key,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
if not include_secrets:
ak = decrypt_dict_secrets({"access_key": d["access_key"]}).get("access_key", "")
d["access_key"] = (ak[:4] + "***" + ak[-2:]) if len(ak) > 6 else "***"
d.pop("secret_key", None)
return d
# include_secrets=True 用于工具内部,返回明文给 boto3
return decrypt_dict_secrets(d)
async def list_by_user(self, user_id: str) -> List[dict]:
async with self._sm() as s:
stmt = select(S3Credential).where(S3Credential.user_id == user_id)
rows = (await s.execute(stmt)).scalars().all()
return [self._row_to_dict(r, include_secrets=False) for r in rows]
async def get(self, cred_id: str, *, include_secrets: bool = False) -> Optional[dict]:
async with self._sm() as s:
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
row = (await s.execute(stmt)).scalar_one_or_none()
if row is None:
return None
return self._row_to_dict(row, include_secrets=include_secrets)
async def upsert(
self,
cred_id: str,
user_id: str,
display_name: str,
access_key: str,
secret_key: str,
endpoint_url: Optional[str] = None,
region: str = "us-east-1",
) -> dict:
encrypted = encrypt_dict_secrets(
{"access_key": access_key, "secret_key": secret_key}
)
async with self._sm() as s:
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
existing = (await s.execute(stmt)).scalar_one_or_none()
if existing is not None:
existing.display_name = display_name
existing.endpoint_url = endpoint_url
existing.region = region
existing.access_key = encrypted["access_key"]
existing.secret_key = encrypted["secret_key"]
s.add(existing)
await s.commit()
await s.refresh(existing)
return self._row_to_dict(existing, include_secrets=False)
row = S3Credential(
cred_id=cred_id,
user_id=user_id,
display_name=display_name,
endpoint_url=endpoint_url,
region=region,
access_key=encrypted["access_key"],
secret_key=encrypted["secret_key"],
)
s.add(row)
await s.commit()
await s.refresh(row)
return self._row_to_dict(row, include_secrets=False)
async def delete(self, cred_id: str, user_id: str) -> bool:
async with self._sm() as s:
stmt = select(S3Credential).where(
S3Credential.cred_id == cred_id, S3Credential.user_id == user_id
)
row = (await s.execute(stmt)).scalar_one_or_none()
if row is None:
return False
await s.delete(row)
await s.commit()
return True
class JobDAO:
"""分析任务记录 DAO。"""
def __init__(self, sm: async_sessionmaker[AsyncSession]):
self._sm = sm
@staticmethod
def _row_to_dict(row: AnalysisJob) -> dict:
return {
"job_id": row.job_id,
"user_id": row.user_id,
"cred_id": row.cred_id,
"description": row.description,
"status": row.status,
"org_task_id": row.org_task_id,
"result": row.result,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
async def create(
self,
job_id: str,
user_id: str,
description: str,
cred_id: Optional[str] = None,
) -> dict:
async with self._sm() as s:
row = AnalysisJob(
job_id=job_id,
user_id=user_id,
description=description,
cred_id=cred_id,
)
s.add(row)
await s.commit()
await s.refresh(row)
return self._row_to_dict(row)
async def update(
self,
job_id: str,
*,
status: Optional[str] = None,
result: Optional[str] = None,
org_task_id: Optional[str] = None,
) -> Optional[dict]:
async with self._sm() as s:
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
row = (await s.execute(stmt)).scalar_one_or_none()
if row is None:
return None
if status is not None:
row.status = status
if result is not None:
row.result = result
if org_task_id is not None:
row.org_task_id = org_task_id
s.add(row)
await s.commit()
await s.refresh(row)
return self._row_to_dict(row)
async def list_by_user(self, user_id: str, limit: int = 50) -> List[dict]:
async with self._sm() as s:
stmt = (
select(AnalysisJob)
.where(AnalysisJob.user_id == user_id)
.order_by(AnalysisJob.created_at.desc())
.limit(limit)
)
rows = (await s.execute(stmt)).scalars().all()
return [self._row_to_dict(r) for r in rows]
async def get(self, job_id: str) -> Optional[dict]:
async with self._sm() as s:
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
row = (await s.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row) if row else None
@@ -0,0 +1,135 @@
"""data_analytics organization:管理本插件的 SQLite 元数据 + 注入凭证 ctx。
凭证经由 ``S3_CREDS_VAR`` ContextVar 传给工具,避免污染 agent tool signature
agent 看到的工具不带 cred 参数,模型不会误传)。
API 层通过本类暴露的 ``cred_*`` / ``job_*`` 代理方法跨 actor 调 DAO
保证分布式模式下 actor 之间不直接共享 SQLAlchemy session。
"""
from __future__ import annotations
import contextvars
import uuid
from typing import Any, Callable, Dict, List, Optional
from kilostar.plugin_runtime.base_organization import BaseOrganization
from kilostar.plugin_runtime.event import OrgEvent
from .db import Base, CredentialDAO, JobDAO
# 当前任务的 S3 凭证(明文):工具内部读 .get() 拿
S3_CREDS_VAR: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
"data_analytics_s3_creds", default=None
)
class DataAnalyticsOrganization(BaseOrganization):
"""对接 S3 的数据分析组织。"""
async def setup(self) -> None:
await super().setup()
await self.init_local_db([Base])
# 跨工具/跨 API 共享的 DAO 实例
self.cred_dao = CredentialDAO(self._session_maker)
self.job_dao = JobDAO(self._session_maker)
async def on_first_install(self) -> None:
self.logger.info(
"data_analytics installed; configure S3 credentials in dashboard."
)
async def react(
self,
task_description: str,
ctx: Dict[str, Any],
emit: Callable[[OrgEvent], Any],
) -> Any:
cred_id = ctx.get("cred_id")
if cred_id and getattr(self, "cred_dao", None) is not None:
cred = await self.cred_dao.get(cred_id, include_secrets=True)
if cred is None:
raise RuntimeError(f"S3 凭证 {cred_id} 不存在")
S3_CREDS_VAR.set(cred)
ctx["s3_cred_display"] = cred.get("display_name")
else:
S3_CREDS_VAR.set(None)
return await super().react(task_description, ctx, emit)
# ─── 凭证代理(API 层调用) ─────────────────────────────────────
async def cred_list(self, user_id: str) -> List[dict]:
return await self.cred_dao.list_by_user(user_id)
async def cred_create(
self,
user_id: str,
display_name: str,
access_key: str,
secret_key: str,
endpoint_url: Optional[str] = None,
region: str = "us-east-1",
) -> dict:
cred_id = uuid.uuid4().hex
return await self.cred_dao.upsert(
cred_id=cred_id,
user_id=user_id,
display_name=display_name,
access_key=access_key,
secret_key=secret_key,
endpoint_url=endpoint_url,
region=region,
)
async def cred_delete(self, cred_id: str, user_id: str) -> bool:
return await self.cred_dao.delete(cred_id, user_id)
# ─── 任务代理 ──────────────────────────────────────────────────
async def job_create(
self, user_id: str, cred_id: str, description: str
) -> dict:
# 校验凭证归属
cred = await self.cred_dao.get(cred_id, include_secrets=False)
if cred is None or cred.get("user_id") != user_id:
raise ValueError("凭证不存在或不属于当前用户")
job_id = uuid.uuid4().hex
await self.job_dao.create(
job_id=job_id,
user_id=user_id,
description=description,
cred_id=cred_id,
)
# 投递 organization 任务(拿 task_id 回填,便于前端拉事件流)
task_id = await self.submit(
description, {"user_id": user_id, "cred_id": cred_id, "job_id": job_id}
)
await self.job_dao.update(job_id, status="running", org_task_id=task_id)
return {"job_id": job_id, "task_id": task_id, "status": "running"}
async def job_list(self, user_id: str) -> List[dict]:
return await self.job_dao.list_by_user(user_id)
async def job_get(self, job_id: str, user_id: str) -> Optional[dict]:
row = await self.job_dao.get(job_id)
if row is None or row.get("user_id") != user_id:
return None
# 附带最新 organization 状态
org_task_id = row.get("org_task_id")
if org_task_id:
ts = await self.status(org_task_id)
if ts is not None:
row["task_status"] = ts.get("status")
row["task_result"] = ts.get("result")
row["task_error"] = ts.get("error")
# 任务终态时把结果回写 SQLite,方便重启后查询
if ts.get("status") in ("completed", "failed") and row.get("status") != ts.get("status"):
result_payload = ts.get("result") if ts.get("status") == "completed" else ts.get("error")
await self.job_dao.update(
job_id,
status=ts.get("status"),
result=str(result_payload) if result_payload is not None else None,
)
row["status"] = ts.get("status")
return row
@@ -0,0 +1,174 @@
import { useState } from 'react';
import { Plus, Trash2, Loader2, Key, Eye, EyeOff } from 'lucide-react';
import { usePluginContext } from './client';
import type { S3Credential } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
credentials: S3Credential[];
loading: boolean;
onChanged: () => void;
}
export function CredentialPanel({ credentials, loading, onChanged }: Props) {
const { client } = usePluginContext();
const [showForm, setShowForm] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
display_name: '',
endpoint_url: '',
region: 'us-east-1',
access_key: '',
secret_key: '',
});
const reset = () => {
setForm({ display_name: '', endpoint_url: '', region: 'us-east-1', access_key: '', secret_key: '' });
setError('');
setShowSecret(false);
};
const submit = async () => {
if (!form.display_name.trim() || !form.access_key.trim() || !form.secret_key.trim()) {
setError('显示名 / Access Key / Secret Key 必填');
return;
}
setBusy(true);
setError('');
try {
await client.post(`${API_BASE}/credentials`, {
display_name: form.display_name.trim(),
endpoint_url: form.endpoint_url.trim() || null,
region: form.region.trim() || 'us-east-1',
access_key: form.access_key,
secret_key: form.secret_key,
});
reset();
setShowForm(false);
onChanged();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '保存失败');
} finally {
setBusy(false);
}
};
const remove = async (cred_id: string) => {
if (!confirm('确定删除该凭证?删除后该凭证下的任务将无法继续运行。')) return;
try {
await client.delete(`${API_BASE}/credentials/${cred_id}`);
onChanged();
} catch (e) {
console.error(e);
}
};
return (
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Key size={16} className="text-accent" /> S3
</h3>
<p className="text-xs text-text-muted mt-0.5">访 SQLite</p>
</div>
<button
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5"
onClick={() => { setShowForm((s) => !s); setError(''); }}
>
<Plus size={14} /> {showForm ? '取消' : '新增'}
</button>
</div>
{showForm && (
<div className="space-y-3 mb-4 p-4 bg-bg-secondary rounded-xl border border-border-secondary">
<input
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="显示名(如 prod-aws"
value={form.display_name}
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
/>
<input
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Endpoint URL(可选,自托管 S3 / MinIO 填写)"
value={form.endpoint_url}
onChange={(e) => setForm({ ...form, endpoint_url: e.target.value })}
/>
<input
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Region(默认 us-east-1"
value={form.region}
onChange={(e) => setForm({ ...form, region: e.target.value })}
/>
<input
className="w-full px-3 py-2 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Access Key"
value={form.access_key}
onChange={(e) => setForm({ ...form, access_key: e.target.value })}
/>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
className="w-full px-3 py-2 pr-10 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Secret Key"
value={form.secret_key}
onChange={(e) => setForm({ ...form, secret_key: e.target.value })}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
onClick={() => setShowSecret((s) => !s)}
>
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{error && <div className="text-xs text-danger">{error}</div>}
<button
className="w-full px-3 py-2 text-sm rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center justify-center gap-2"
onClick={submit}
disabled={busy}
>
{busy && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : credentials.length === 0 ? (
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
</div>
) : (
<div className="space-y-2">
{credentials.map((c) => (
<div
key={c.cred_id}
className="flex items-center justify-between p-3 bg-bg-secondary rounded-xl border border-border-secondary"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text-primary truncate">{c.display_name}</div>
<div className="text-[11px] text-text-muted font-mono mt-0.5">
{c.endpoint_url || 'aws-s3'} · {c.region} · {c.access_key}
</div>
</div>
<button
className="p-1.5 text-text-muted hover:text-danger transition"
onClick={() => remove(c.cred_id)}
title="删除"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react';
import { BarChart3, Plus, Loader2, ListChecks } from 'lucide-react';
import { usePluginContext } from './client';
import { CredentialPanel } from './CredentialPanel';
import { NewJobDialog } from './NewJobDialog';
import { JobDetail } from './JobDetail';
import type { S3Credential, AnalysisJob } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
pluginName: string;
}
export function Dashboard({ pluginName }: Props) {
const { client } = usePluginContext();
const [credentials, setCredentials] = useState<S3Credential[]>([]);
const [credLoading, setCredLoading] = useState(true);
const [jobs, setJobs] = useState<AnalysisJob[]>([]);
const [jobLoading, setJobLoading] = useState(true);
const [showNewJob, setShowNewJob] = useState(false);
const [openJobId, setOpenJobId] = useState<string | null>(null);
const [error, setError] = useState('');
const loadCredentials = useCallback(async () => {
setCredLoading(true);
try {
const resp = await client.get<{ credentials: S3Credential[] }>(`${API_BASE}/credentials`);
setCredentials(resp.data.credentials || []);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '加载凭证失败');
} finally {
setCredLoading(false);
}
}, [client]);
const loadJobs = useCallback(async () => {
setJobLoading(true);
try {
const resp = await client.get<{ jobs: AnalysisJob[] }>(`${API_BASE}/jobs`);
setJobs(resp.data.jobs || []);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '加载任务失败');
} finally {
setJobLoading(false);
}
}, [client]);
useEffect(() => {
loadCredentials();
loadJobs();
}, [loadCredentials, loadJobs]);
// 轮询任务列表,方便看状态变化
useEffect(() => {
const t = setInterval(loadJobs, 5000);
return () => clearInterval(t);
}, [loadJobs]);
return (
<div className="h-full overflow-y-auto p-6 bg-bg-base">
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3 pb-2">
<div className="w-10 h-10 rounded-xl bg-accent-light text-accent flex items-center justify-center">
<BarChart3 size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-primary"></h2>
<p className="text-xs text-text-muted mt-0.5">
S3 agent python_executor / ray_submit{' '}
<span className="font-mono text-[10px] opacity-70">{pluginName}</span>
</p>
</div>
</div>
{error && (
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
)}
<CredentialPanel credentials={credentials} loading={credLoading} onChanged={loadCredentials} />
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<ListChecks size={16} className="text-accent" />
</h3>
<p className="text-xs text-text-muted mt-0.5"> 5 </p>
</div>
<button
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5 disabled:opacity-50"
onClick={() => setShowNewJob(true)}
disabled={credentials.length === 0}
title={credentials.length === 0 ? '请先添加凭证' : ''}
>
<Plus size={14} />
</button>
</div>
{jobLoading && jobs.length === 0 ? (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : jobs.length === 0 ? (
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
</div>
) : (
<div className="space-y-2">
{jobs.map((j) => (
<button
key={j.job_id}
className="w-full text-left p-3 bg-bg-secondary rounded-xl border border-border-secondary hover:border-accent transition flex items-center justify-between gap-3"
onClick={() => setOpenJobId(j.job_id)}
>
<div className="min-w-0 flex-1">
<div className="text-sm text-text-primary truncate">{j.description}</div>
<div className="text-[11px] text-text-muted mt-0.5 font-mono">
{j.job_id.slice(0, 8)} · {j.created_at?.slice(0, 19).replace('T', ' ')}
</div>
</div>
<StatusBadge status={j.status} />
</button>
))}
</div>
)}
</div>
</div>
{showNewJob && (
<NewJobDialog
credentials={credentials}
onClose={() => setShowNewJob(false)}
onCreated={loadJobs}
/>
)}
{openJobId && <JobDetail jobId={openJobId} onClose={() => setOpenJobId(null)} />}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
pending: 'bg-bg-base text-text-muted border-border-primary',
running: 'bg-warning-bg text-warning border-warning/20',
completed: 'bg-success-bg text-success border-success/20',
failed: 'bg-danger-bg text-danger border-danger/20',
};
const cls = map[status] || map.pending;
return (
<span className={`text-[10px] font-medium px-2 py-1 rounded-lg border ${cls} shrink-0`}>
{status}
</span>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, X, Activity } from 'lucide-react';
import { usePluginContext } from './client';
import type { AnalysisJob } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
jobId: string;
onClose: () => void;
}
interface StreamEvent {
type?: string;
ts?: number;
payload?: unknown;
raw?: string;
}
export function JobDetail({ jobId, onClose }: Props) {
const { client, token, apiBase } = usePluginContext();
const [job, setJob] = useState<AnalysisJob | null>(null);
const [events, setEvents] = useState<StreamEvent[]>([]);
const [loading, setLoading] = useState(true);
const eventBoxRef = useRef<HTMLDivElement | null>(null);
// 初次加载 + 后台轮询
useEffect(() => {
let cancelled = false;
const fetchJob = async () => {
try {
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
if (!cancelled) setJob(resp.data);
} catch (e) {
console.error('fetch job failed', e);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchJob();
const t = setInterval(fetchJob, 4000);
return () => {
cancelled = true;
clearInterval(t);
};
}, [client, jobId]);
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header
useEffect(() => {
const controller = new AbortController();
const run = async () => {
try {
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
if (!resp.body) return;
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const part of parts) {
const line = part.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const payload = line.slice(5).trim();
try {
setEvents((prev) => [...prev, JSON.parse(payload)]);
} catch {
setEvents((prev) => [...prev, { raw: payload }]);
}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
}
};
run();
return () => controller.abort();
}, [apiBase, jobId, token]);
// 自动滚动到底部
useEffect(() => {
if (eventBoxRef.current) {
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
}
}, [events]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-border-primary">
<div className="min-w-0">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Activity size={16} className="text-accent" />
</h3>
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
</div>
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : job ? (
<>
<div className="space-y-2">
<Field label="状态" value={job.task_status || job.status} />
<Field label="描述" value={job.description} />
{job.task_error && <Field label="错误" value={job.task_error} danger />}
</div>
{job.task_result !== undefined && job.task_result !== null && (
<div>
<div className="text-xs text-text-secondary mb-1.5"></div>
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
{typeof job.task_result === 'string'
? job.task_result
: JSON.stringify(job.task_result, null, 2)}
</pre>
</div>
)}
<div>
<div className="text-xs text-text-secondary mb-1.5">SSE</div>
<div
ref={eventBoxRef}
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
>
{events.length === 0 ? (
<span className="text-text-muted">()</span>
) : (
events.map((e, i) => (
<div key={i} className="text-text-secondary">
<span className="text-accent">{e.type || 'event'}</span>{' '}
{e.payload !== undefined ? (
<span>{JSON.stringify(e.payload)}</span>
) : e.raw ? (
<span>{e.raw}</span>
) : null}
</div>
))
)}
</div>
</div>
</>
) : (
<div className="text-sm text-text-muted text-center py-8"></div>
)}
</div>
</div>
</div>
);
}
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
return (
<div className="flex gap-3">
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Loader2, Send, X } from 'lucide-react';
import { usePluginContext } from './client';
import type { S3Credential } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
credentials: S3Credential[];
onClose: () => void;
onCreated: () => void;
}
export function NewJobDialog({ credentials, onClose, onCreated }: Props) {
const { client } = usePluginContext();
const [credId, setCredId] = useState(credentials[0]?.cred_id || '');
const [description, setDescription] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const submit = async () => {
if (!credId) {
setError('请选择 S3 凭证');
return;
}
if (!description.trim()) {
setError('请描述要做的分析');
return;
}
setBusy(true);
setError('');
try {
await client.post(`${API_BASE}/jobs`, {
cred_id: credId,
description: description.trim(),
});
onCreated();
onClose();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '提交失败');
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-lg bg-bg-card rounded-2xl border border-border-primary shadow-xl">
<div className="flex items-center justify-between p-4 border-b border-border-primary">
<h3 className="font-semibold text-text-primary"></h3>
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<div>
<label className="text-xs text-text-secondary block mb-1.5">S3 </label>
{credentials.length === 0 ? (
<div className="text-xs text-warning bg-warning-bg/50 border border-warning/20 rounded-lg p-2">
S3
</div>
) : (
<select
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
value={credId}
onChange={(e) => setCredId(e.target.value)}
>
{credentials.map((c) => (
<option key={c.cred_id} value={c.cred_id}>
{c.display_name} · {c.region}
</option>
))}
</select>
)}
</div>
<div>
<label className="text-xs text-text-secondary block mb-1.5"></label>
<textarea
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent min-h-[120px] resize-y"
placeholder="例如:分析 s3://my-bucket/sales/2026-q1/ 的销售趋势,输出按月汇总"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-[11px] text-text-muted mt-1">
Agent s3_peek/s3_list_objects python_executor ray_submit
</p>
</div>
{error && <div className="text-xs text-danger">{error}</div>}
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t border-border-primary">
<button
className="px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-text-primary"
onClick={onClose}
>
</button>
<button
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center gap-1.5"
onClick={submit}
disabled={busy || credentials.length === 0}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// vite lib 模式 build 完后写一份 wc-manifest.json,给后端 /ui-manifest 端点读
const distDir = 'dist';
if (!existsSync(distDir)) {
console.error('dist/ not found; run vite build first');
process.exit(1);
}
const files = readdirSync(distDir);
const js = files.find((f) => f.endsWith('.js')) || 'plugin-element.js';
const css = files.filter((f) => f.endsWith('.css'));
const manifest = {
tag: 'plugin-data-analytics',
js,
css,
};
writeFileSync(join(distDir, 'wc-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
console.log(`wrote dist/wc-manifest.json: ${JSON.stringify(manifest)}`);
@@ -0,0 +1,30 @@
import axios, { type AxiosInstance } from 'axios';
import { createContext, useContext } from 'react';
export interface PluginContextValue {
client: AxiosInstance;
token: string;
apiBase: string;
}
export const PluginContext = createContext<PluginContextValue | null>(null);
export function usePluginContext(): PluginContextValue {
const ctx = useContext(PluginContext);
if (!ctx) throw new Error('PluginContext missing — Web Component not initialized');
return ctx;
}
export function makeClient(token: string, apiBase: string): AxiosInstance {
const c = axios.create({
baseURL: apiBase || undefined,
});
c.interceptors.request.use((cfg) => {
if (token) {
cfg.headers = cfg.headers || {};
(cfg.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return cfg;
});
return c;
}
@@ -0,0 +1,61 @@
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Dashboard } from './Dashboard';
import { PluginContext, makeClient } from './client';
// 把 build 出来的 CSS 当字符串收入,作为 ConstructableStyleSheet 注入到 shadow root
// 既能享受 shadow DOM 的样式隔离,也不需要额外的 fetch 步骤。
import css from './styles.css?inline';
const TAG = 'plugin-data-analytics';
class DataAnalyticsElement extends HTMLElement {
private root?: Root;
private mount?: HTMLDivElement;
static get observedAttributes() {
return ['token', 'api-base'];
}
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
// 用 <style> 注入 CSSConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
const style = document.createElement('style');
style.textContent = css;
shadow.appendChild(style);
this.mount = document.createElement('div');
this.mount.style.cssText = 'height:100%;width:100%';
shadow.appendChild(this.mount);
this.root = createRoot(this.mount);
this.render();
}
attributeChangedCallback() {
if (this.root) this.render();
}
disconnectedCallback() {
this.root?.unmount();
this.root = undefined;
}
private render() {
const token = this.getAttribute('token') ?? '';
const apiBase = this.getAttribute('api-base') ?? '';
const client = makeClient(token, apiBase);
this.root!.render(
<React.StrictMode>
<PluginContext.Provider value={{ client, token, apiBase }}>
<Dashboard pluginName="data_analytics" />
</PluginContext.Provider>
</React.StrictMode>,
);
}
}
if (!customElements.get(TAG)) {
customElements.define(TAG, DataAnalyticsElement);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
{
"name": "plugin-data-analytics",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build && node build-manifest.mjs",
"dev": "vite build --watch"
},
"dependencies": {
"axios": "^1.15.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.8.0",
"vite": "^8.0.4"
}
}
@@ -0,0 +1,91 @@
@import "tailwindcss";
/* 在 shadow DOM 内 :root 不匹配,用 :host 给 Web Component 自身定义主题 token。
颜色名称跟主前端 frontend/src/index.css 保持一致——这样组件里的 bg-bg-card / text-accent
等类名在插件 build 时也能解析到对应的 var()。 */
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-card: var(--bg-card);
--color-bg-sidebar: var(--bg-sidebar);
--color-bg-input: var(--bg-input);
--color-bg-hover: var(--bg-hover);
--color-bg-active: var(--bg-active);
--color-bg-base: var(--bg-base);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-text-muted: var(--text-muted);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-light: var(--accent-light);
--color-danger: var(--danger);
--color-danger-bg: var(--danger-bg);
--color-success: var(--success);
--color-success-bg: var(--success-bg);
--color-warning: var(--warning);
--color-warning-bg: var(--warning-bg);
}
:host {
/* light theme defaults — 跟主前端保持一致 */
--bg-primary: #f2f0ed;
--bg-secondary: #eae8e4;
--bg-tertiary: #e0ddd8;
--bg-card: #faf9f7;
--bg-sidebar: #eae8e4;
--bg-input: #f2f0ed;
--bg-hover: rgba(255, 255, 255, 0.4);
--bg-active: rgba(156, 175, 136, 0.08);
--bg-base: #f2f0ed;
--border-primary: #e0ddd8;
--border-secondary: #eae8e4;
--text-primary: #3d3d3d;
--text-secondary: #5a5a5a;
--text-tertiary: #8c8680;
--text-muted: #b5afa8;
--accent: #9caf88;
--accent-hover: #8a9e78;
--accent-light: rgba(156, 175, 136, 0.12);
--danger: #c4917a;
--danger-bg: rgba(196, 145, 122, 0.08);
--success: #7a8e6a;
--success-bg: rgba(122, 142, 106, 0.08);
--warning: #c4a882;
--warning-bg: rgba(196, 168, 130, 0.08);
display: block;
height: 100%;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* 跟随系统/主前端的暗色主题:宿主元素加 [data-theme="dark"] 时切换 */
:host([data-theme="dark"]) {
--bg-primary: #1c1b19;
--bg-secondary: #232220;
--bg-tertiary: #2d2b28;
--bg-card: #252421;
--bg-sidebar: #1e1d1b;
--bg-input: #2d2b28;
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-active: rgba(156, 175, 136, 0.1);
--bg-base: #1c1b19;
--border-primary: rgba(255, 255, 255, 0.06);
--border-secondary: rgba(255, 255, 255, 0.03);
--text-primary: #e8e6e3;
--text-secondary: #c8c5c0;
--text-tertiary: #a09c96;
--text-muted: #7a7772;
--accent: #a8bc94;
--accent-hover: #b8caa6;
--accent-light: rgba(156, 175, 136, 0.15);
--danger: #d4a894;
--danger-bg: rgba(196, 145, 122, 0.1);
--success: #9caf88;
--success-bg: rgba(156, 175, 136, 0.1);
--warning: #c4a882;
--warning-bg: rgba(196, 168, 130, 0.1);
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["*.ts", "*.tsx"]
}
@@ -0,0 +1,25 @@
export interface S3Credential {
cred_id: string;
user_id: string;
display_name: string;
endpoint_url: string | null;
region: string;
access_key: string;
created_at: string | null;
updated_at: string | null;
}
export interface AnalysisJob {
job_id: string;
user_id: string;
cred_id: string | null;
description: string;
status: string;
org_task_id: string | null;
result: string | null;
created_at: string | null;
updated_at: string | null;
task_status?: string;
task_result?: unknown;
task_error?: string | null;
}
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
lib: {
entry: 'index.tsx',
formats: ['es'],
fileName: () => 'plugin-element.js',
},
outDir: 'dist',
emptyOutDir: true,
cssCodeSplit: false,
rollupOptions: {
output: {
assetFileNames: (info) => {
if (info.name && info.name.endsWith('.css')) return 'plugin-element.css';
return 'assets/[name]-[hash][extname]';
},
},
},
},
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "data_analytics",
"version": "0.1.0",
"display_name": "数据分析",
"description": "对接 S3 对象存储,由 agent 自主决定使用 python_executor 或 ray_submit 跑分析。仅读不写。",
"entry": "core.organization:DataAnalyticsOrganization",
"concurrency": "queue",
"node_affinity": "cpu",
"api_prefix": "/api/v1/plugin/data_analytics",
"capabilities": ["data_analysis", "s3_readonly"],
"dependencies": {
"python": [],
"plugins": []
},
"ui": {
"entry": "frontend/index.tsx",
"icon": "BarChart3"
}
}
@@ -0,0 +1,16 @@
"""data_analytics 插件本地工具集。
agent 看到这些工具时不带凭证参数,凭证由 organization 通过 ContextVar 注入。
"""
from .s3_list_objects import s3_list_objects
from .s3_peek import s3_peek
from .s3_get_object import s3_get_object
from .ray_submit import ray_submit
__all__ = [
"s3_list_objects",
"s3_peek",
"s3_get_object",
"ray_submit",
]
@@ -0,0 +1,43 @@
"""S3 工具共用辅助:从 ContextVar 拿凭证 + 解析 URI。
所有 s3_* 工具都依赖这个模块,把"明文凭证"的取用集中在一处。
"""
from __future__ import annotations
import re
from typing import Any, Dict, Tuple
def get_s3_creds_or_raise() -> Dict[str, Any]:
"""从 organization 注入的 ContextVar 中取出明文凭证;未注入则抛错。"""
# 延迟 import 避免循环;这里走 organization 子类被加载时注入的虚拟包路径
from ..core.organization import S3_CREDS_VAR
creds = S3_CREDS_VAR.get()
if not creds:
raise RuntimeError(
"未提供 S3 凭证:本任务上下文中没有 cred_id,请在创建 job 时选择凭证。"
)
return creds
def parse_s3_uri(uri: str) -> Tuple[str, str]:
"""解析 ``s3://bucket/key`` → ``(bucket, key)``;非法格式抛 ValueError。"""
m = re.match(r"^s3://([^/]+)/(.+)$", uri.strip())
if not m:
raise ValueError(f"非法 S3 URI{uri!r}(期待 s3://bucket/key 形式)")
return m.group(1), m.group(2)
def make_session_kwargs(creds: Dict[str, Any]) -> Dict[str, Any]:
"""转 boto3/aiobotocore client 调用所需的 kwargs。"""
kw: Dict[str, Any] = {
"aws_access_key_id": creds["access_key"],
"aws_secret_access_key": creds["secret_key"],
"region_name": creds.get("region") or "us-east-1",
}
endpoint = creds.get("endpoint_url")
if endpoint:
kw["endpoint_url"] = endpoint
return kw
@@ -0,0 +1,39 @@
{
"name": "data_analytics_internal",
"version": "0.1.0",
"description": "data_analytics 插件内部工具:S3 只读 + Ray 提交。仅限本插件内部 agent 调用。",
"tools": [
{
"name": "s3_list_objects",
"file": "s3_list_objects.py",
"is_system": true,
"action_scope": ["data_analytics_internal"],
"config_args": {},
"category": "system"
},
{
"name": "s3_peek",
"file": "s3_peek.py",
"is_system": true,
"action_scope": ["data_analytics_internal"],
"config_args": {},
"category": "system"
},
{
"name": "s3_get_object",
"file": "s3_get_object.py",
"is_system": true,
"action_scope": ["data_analytics_internal"],
"config_args": {},
"category": "system"
},
{
"name": "ray_submit",
"file": "ray_submit.py",
"is_system": true,
"action_scope": ["data_analytics_internal"],
"config_args": {},
"category": "system"
}
]
}
@@ -0,0 +1,95 @@
"""ray_submit:把分析脚本提交到 Raydistributed)或 subprocessstandalone)执行。
凭证以 ``AWS_*`` 环境变量注入子进程,让 boto3/pandas-s3 自然读到。
脚本走 ``kilostar.utils.sandbox.validate_python_code`` 的静态屏蔽兜底。
"""
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
from kilostar.utils.ray_compat import _STANDALONE
from kilostar.utils.sandbox import (
CodeViolation,
get_python_timeout,
validate_python_code,
)
from ._s3_common import get_s3_creds_or_raise
def _build_env(creds) -> dict:
env = os.environ.copy()
env["AWS_ACCESS_KEY_ID"] = creds["access_key"]
env["AWS_SECRET_ACCESS_KEY"] = creds["secret_key"]
env["AWS_DEFAULT_REGION"] = creds.get("region") or "us-east-1"
if creds.get("endpoint_url"):
env["AWS_ENDPOINT_URL_S3"] = creds["endpoint_url"]
env["AWS_ENDPOINT_URL"] = creds["endpoint_url"]
return env
async def ray_submit(script: str, timeout: int = 300) -> str:
"""提交 Python 脚本到 Ray(分布式)或子进程(单机)执行。
脚本中可直接 ``import boto3`` 读 S3(凭证已通过环境变量注入);可用
pandas / polars / numpy 等已安装的依赖。**只读**——不要尝试 put/delete。
Args:
script: Python 源码
timeout: 超时秒数(默认 300
Returns:
stdout(必要时尾部追加 stderr 与 exit code
"""
try:
script = validate_python_code(script)
except CodeViolation as e:
return f"[Sandbox] {e}"
creds = get_s3_creds_or_raise()
env = _build_env(creds)
timeout = get_python_timeout(timeout)
# standalone 与 distributed 第一版都走 subprocess,保证环境变量传递可控
# ray.remote 跑函数时 env vars 需另装 runtime_env,复杂度跟 subprocess 持平
# 但前者透明可控,先这样落地)
tmp_file = None
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as f:
f.write(script)
tmp_file = f.name
proc = await asyncio.create_subprocess_exec(
sys.executable,
tmp_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
out = stdout.decode("utf-8", errors="replace")
err = stderr.decode("utf-8", errors="replace")
result = ""
if out:
result += out
if err:
result += f"\n[stderr]\n{err}"
if proc.returncode != 0:
result += f"\n[exit code: {proc.returncode}]"
result = result.strip() or "(no output)"
if not _STANDALONE:
result = f"[mode: ray-cluster (subprocess)]\n{result}"
return result
except asyncio.TimeoutError:
return f"[Error] ray_submit 执行超时({timeout}s"
except Exception as e:
return f"[Error] ray_submit 失败:{e}"
finally:
if tmp_file and os.path.exists(tmp_file):
os.unlink(tmp_file)
@@ -0,0 +1,46 @@
"""s3_get_object:下载到 artifact 目录(路径强校验防穿越)。"""
from __future__ import annotations
import os
from pathlib import Path
from kilostar.utils.settings import get_artifact_dir
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
async def s3_get_object(uri: str, save_as: str) -> str:
"""把 S3 对象下载到本进程的 artifact 工作区,返回本地绝对路径。
``save_as`` 必须是相对路径,落到 ``data/artifact/data_analytics_downloads/``
下面(防越权写入任意目录)。下载后供 python_executor / ray_submit 中以
pandas/polars 读取。
Args:
uri: 形如 ``s3://bucket/key`` 的对象路径
save_as: 保存的相对文件名(不能含 ``..`` 或绝对路径)
Returns:
本地保存的绝对路径
"""
from aiobotocore.session import get_session
creds = get_s3_creds_or_raise()
bucket, key = parse_s3_uri(uri)
save_path = Path(save_as).as_posix()
if save_path.startswith("/") or ".." in save_path.split("/"):
raise ValueError(f"save_as 必须是相对、不含 .. 的路径,收到 {save_as!r}")
base = get_artifact_dir() / "data_analytics_downloads"
base.mkdir(parents=True, exist_ok=True)
target = base / save_path
target.parent.mkdir(parents=True, exist_ok=True)
session = get_session()
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
resp = await client.get_object(Bucket=bucket, Key=key)
body = await resp["Body"].read()
target.write_bytes(body)
return str(target.resolve())
@@ -0,0 +1,47 @@
"""s3_list_objects:列出 bucket+prefix 下的对象列表(key/size/last_modified)。"""
from __future__ import annotations
from typing import Any, Dict, List
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs
async def s3_list_objects(
bucket: str,
prefix: str = "",
limit: int = 50,
) -> List[Dict[str, Any]]:
"""列出 S3 bucket 下指定 prefix 的对象(最多 limit 条)。
Args:
bucket: S3 bucket 名
prefix: 对象 key 前缀,留空表示根路径
limit: 最多返回条数(1-1000),默认 50
Returns:
对象信息列表,每项含 key / size / last_modifiedISO 字符串)
"""
from aiobotocore.session import get_session
creds = get_s3_creds_or_raise()
limit = max(1, min(int(limit), 1000))
session = get_session()
out: List[Dict[str, Any]] = []
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
paginator = client.get_paginator("list_objects_v2")
async for page in paginator.paginate(
Bucket=bucket, Prefix=prefix, PaginationConfig={"MaxItems": limit}
):
for item in page.get("Contents", []) or []:
out.append({
"key": item.get("Key"),
"size": item.get("Size"),
"last_modified": (
item["LastModified"].isoformat() if item.get("LastModified") else None
),
})
if len(out) >= limit:
return out
return out
@@ -0,0 +1,35 @@
"""s3_peek:读取对象的头若干字节并尝试 UTF-8 解码(看几行用)。"""
from __future__ import annotations
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
async def s3_peek(uri: str, n_bytes: int = 4096) -> str:
"""读取 S3 对象的头 ``n_bytes`` 字节,UTF-8 解码后返回。
适合快速预览 csv/json/log 等文本类对象的开头几行。二进制内容会以
``[binary, ...]`` 占位说明返回。
Args:
uri: 形如 ``s3://bucket/key`` 的对象路径
n_bytes: 读取字节数,默认 4096,上限 1MB
Returns:
对象内容片段(解码后的字符串或占位说明)
"""
from aiobotocore.session import get_session
creds = get_s3_creds_or_raise()
bucket, key = parse_s3_uri(uri)
n = max(1, min(int(n_bytes), 1024 * 1024))
session = get_session()
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
resp = await client.get_object(Bucket=bucket, Key=key, Range=f"bytes=0-{n-1}")
body = await resp["Body"].read()
try:
text = body.decode("utf-8")
return text
except UnicodeDecodeError:
return f"[binary, {len(body)} bytes; first 64 hex] {body[:64].hex()}"
+16
View File
@@ -0,0 +1,16 @@
# 示例部门 (Example Dept)
演示用的重型插件骨架。包含两个平级 agentanalyst + executor),
可作为开发新组织插件的模板。
## 目录结构
```
example_dept/
├── manifest.json # 插件元数据
├── agents.json # agent 定义
├── core/ # 业务逻辑
├── toolset/ # 本地工具
├── skills/ # 本地技能
└── dashboard/ # 前端面板(占位)
```
+32
View File
@@ -0,0 +1,32 @@
{
"agents": [
{
"name": "analyst",
"role": "数据分析专家",
"system_prompt": "你是一位数据分析专家,负责理解用户需求并给出分析方案。",
"model": {
"provider_title": "",
"model_id": ""
},
"tools": [],
"skills": [],
"peers": ["executor"]
},
{
"name": "executor",
"role": "执行专家",
"system_prompt": "你是一位执行专家,负责将分析方案转化为具体操作。",
"model": {
"provider_title": "",
"model_id": ""
},
"tools": ["shell_executor", "python_executor"],
"skills": [],
"peers": ["analyst"]
}
],
"orchestration": {
"type": "react",
"entry": "analyst"
}
}
@@ -0,0 +1,6 @@
from kilostar.plugin_runtime.base_organization import BaseOrganization
class ExampleOrganization(BaseOrganization):
"""示例组织 — 直接使用基类的 react 逻辑。"""
pass
+19
View File
@@ -0,0 +1,19 @@
{
"name": "example_dept",
"version": "0.1.0",
"display_name": "示例部门",
"description": "演示用的重型插件骨架,可作为开发模板。",
"entry": "core.organization:ExampleOrganization",
"concurrency": "queue",
"node_affinity": "cpu",
"api_prefix": "/plugin/example_dept",
"capabilities": ["text_processing"],
"dependencies": {
"python": [],
"plugins": []
},
"ui": {
"entry": "dashboard/index.html",
"icon": null
}
}
+30
View File
@@ -0,0 +1,30 @@
# base_toolset
KiloStar 内置基础工具集。提供文件操作、命令执行、搜索等通用能力,所有 Agent 默认可用。
## 工具列表
| 工具 | 说明 |
|------|------|
| `shell_executor` | 执行 shell 命令,返回 stdout/stderr |
| `file_reader` | 读取文件内容(支持按行偏移和行数限制) |
| `edit_file` | 按 old_string → new_string 的方式精确替换文件内容 |
| `write_file` | 整体写入或覆盖文件 |
| `search_file` | 在目录树内按 glob/正则搜索文件名或内容 |
| `python_executor` | 在沙箱中运行 Python 代码片段 |
| `tavily_search` | 调用 Tavily API 进行联网搜索(需配置 `api_key` |
## 配置说明
`tavily_search` 需要在工具配置中填入 `api_key`,可选参数:
- `max_results`:返回结果条数,默认 `5`
- `search_depth``basic``advanced`
- `include_answer`:是否带 LLM 摘要,默认 `true`
其他工具开箱即用,无需配置。
## 安全提示
- `shell_executor` / `python_executor` 会在受限沙箱内执行,但仍建议在受信环境下使用
- `edit_file` / `write_file` 会修改本地文件系统,注意权限范围
+17
View File
@@ -0,0 +1,17 @@
from .shell_executor import shell_executor
from .file_reader import file_reader
from .edit_file import edit_file
from .write_file import write_file
from .search_file import search_file
from .python_executor import python_executor
from .tavily_search import tavily_search
__all__ = [
"shell_executor",
"file_reader",
"edit_file",
"write_file",
"search_file",
"python_executor",
"tavily_search",
]
+43
View File
@@ -0,0 +1,43 @@
import os
async def edit_file(
file_path: str,
old_content: str,
new_content: str,
) -> str:
"""通过查找替换的方式编辑文件内容。
Args:
file_path: 文件的路径
old_content: 要被替换的原始内容片段
new_content: 替换后的新内容
Returns:
操作结果描述
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
file_path = validate_path(file_path, write=True)
except PathViolation as e:
return f"[Sandbox] {e}"
try:
if not os.path.exists(file_path):
return f"[Error] 文件不存在: {file_path}"
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if old_content not in content:
return f"[Error] 未在文件中找到要替换的内容片段"
new_file_content = content.replace(old_content, new_content, 1)
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_file_content)
return f"已成功编辑文件: {file_path}"
except Exception as e:
return f"[Error] 编辑文件失败: {e}"
+23
View File
@@ -0,0 +1,23 @@
async def file_reader(file_path: str) -> str:
"""读取本地文件的内容。
Args:
file_path: 文件的绝对路径或相对路径
Returns:
文件内容文本,若文件不存在则返回错误信息
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
file_path = validate_path(file_path, write=False)
except PathViolation as e:
return f"[Sandbox] {e}"
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return f"[Error] File not found: {file_path}"
except Exception as e:
return f"[Error] Failed to read file: {str(e)}"
+68
View File
@@ -0,0 +1,68 @@
{
"name": "基础工具集",
"version": "0.1.0",
"description": "文件读写、命令执行、Python/搜索等通用能力",
"tools": [
{
"name": "shell_executor",
"file": "shell_executor.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "file_reader",
"file": "file_reader.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "edit_file",
"file": "edit_file.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "write_file",
"file": "write_file.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "search_file",
"file": "search_file.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "python_executor",
"file": "python_executor.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
},
{
"name": "tavily_search",
"file": "tavily_search.py",
"is_system": false,
"action_scope": ["consciousness_node", "regulatory_node"],
"config_args": {
"api_key": "",
"max_results": "5",
"search_depth": "basic",
"include_answer": "true"
},
"category": "search"
}
]
}
@@ -0,0 +1,59 @@
import asyncio
import sys
import tempfile
import os
async def python_executor(code: str, timeout: int = 30) -> str:
"""执行 Python 代码片段并返回输出。
Args:
code: 要执行的 Python 代码
timeout: 超时秒数,默认 30 秒
Returns:
代码的标准输出 + 标准错误
"""
from kilostar.utils.sandbox import (
validate_python_code, CodeViolation, get_python_timeout,
)
try:
code = validate_python_code(code)
except CodeViolation as e:
return f"[Sandbox] {e}"
timeout = get_python_timeout(timeout)
tmp_file = None
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, encoding="utf-8"
) as f:
f.write(code)
tmp_file = f.name
proc = await asyncio.create_subprocess_exec(
sys.executable, tmp_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
output = stdout.decode("utf-8", errors="replace")
err_output = stderr.decode("utf-8", errors="replace")
result = ""
if output:
result += output
if err_output:
result += f"\n[stderr]\n{err_output}"
if proc.returncode != 0:
result += f"\n[exit code: {proc.returncode}]"
return result.strip() or "(no output)"
except asyncio.TimeoutError:
return f"[Error] Python 代码执行超时({timeout}s"
except Exception as e:
return f"[Error] 执行失败: {e}"
finally:
if tmp_file and os.path.exists(tmp_file):
os.unlink(tmp_file)
+55
View File
@@ -0,0 +1,55 @@
import asyncio
async def search_file(
keyword: str,
directory: str = ".",
file_pattern: str = "*",
max_results: int = 20,
) -> str:
"""在指定目录下递归搜索包含关键字的文件内容。
Args:
keyword: 要搜索的关键字或正则表达式
directory: 搜索的根目录,默认当前目录
file_pattern: 文件名匹配模式,如 "*.py"
max_results: 最大返回结果数
Returns:
匹配的文件名和行内容
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
directory = validate_path(directory, write=False)
except PathViolation as e:
return f"[Sandbox] {e}"
max_results = min(max_results, 100)
try:
grep_args = [
"grep", "-rn",
f"--include={file_pattern}",
"-m", str(max_results),
"--", keyword, directory,
]
proc = await asyncio.create_subprocess_exec(
*grep_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=30
)
output = stdout.decode("utf-8", errors="replace").strip()
if not output:
return f"未找到包含 '{keyword}' 的匹配项"
lines = output.split("\n")
if len(lines) > max_results:
output = "\n".join(lines[:max_results])
return output
except asyncio.TimeoutError:
return "[Error] 搜索超时"
except Exception as e:
return f"[Error] 搜索失败: {e}"
@@ -0,0 +1,46 @@
import asyncio
async def shell_executor(command: str, timeout: int = 30) -> str:
"""在服务器上执行 shell 命令并返回输出。
Args:
command: 要执行的 shell 命令
timeout: 超时秒数,默认 30 秒
Returns:
命令的 stdout + stderr 输出
"""
from kilostar.utils.sandbox import (
validate_shell_command, CommandViolation, get_shell_timeout,
)
try:
command = validate_shell_command(command)
except CommandViolation as e:
return f"[Sandbox] {e}"
timeout = get_shell_timeout(timeout)
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
output = stdout.decode("utf-8", errors="replace")
err_output = stderr.decode("utf-8", errors="replace")
result = ""
if output:
result += output
if err_output:
result += f"\n[stderr]\n{err_output}"
if proc.returncode != 0:
result += f"\n[exit code: {proc.returncode}]"
return result.strip() or "(no output)"
except asyncio.TimeoutError:
return f"[Error] 命令执行超时({timeout}s"
except Exception as e:
return f"[Error] 执行失败: {e}"
@@ -0,0 +1,78 @@
import os
from typing import Optional
from tavily import AsyncTavilyClient
async def _resolve_api_key(explicit: Optional[str]) -> Optional[str]:
"""按优先级解析 Tavily API key:显式参数 > GSM 配置 > 环境变量。"""
if explicit:
return explicit
try:
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
snapshot = await fetch_snapshot()
cfg = snapshot.tool_configs.get("tavily_search") or {}
if isinstance(cfg, dict) and cfg.get("api_key"):
return cfg["api_key"]
except Exception:
pass
return os.environ.get("TAVILY_API_KEY")
async def tavily_search(
query: str,
max_results: int = 5,
search_depth: str = "basic",
include_answer: bool = True,
api_key: Optional[str] = None,
) -> str:
"""使用 Tavily 进行网络搜索,获取高质量的网络搜索结果。
Args:
query: 搜索查询内容
max_results: 返回的最大结果数量(1-10)
search_depth: 搜索深度,"basic""advanced"
include_answer: 是否包含 AI 生成的答案摘要
api_key: 可选;不传则按 GSM 配置 → 环境变量顺序解析
Returns:
格式化的搜索结果文本,包含标题、URL、摘要和可选的 AI 答案
"""
resolved_key = await _resolve_api_key(api_key)
if not resolved_key:
return (
"[Error] Tavily API key 未配置。"
"请在 ``/api/v1/resource/tool/config`` 写入或设置环境变量 ``TAVILY_API_KEY``。"
)
try:
client = AsyncTavilyClient(api_key=resolved_key)
result = await client.search(
query=query,
max_results=min(max_results, 10),
search_depth=search_depth,
include_answer=include_answer,
)
lines = []
if include_answer and result.get("answer"):
lines.append(f"【AI 摘要】{result['answer']}\n")
results = result.get("results", [])
if not results:
return "No results found for the query."
lines.append("【搜索结果】")
for i, item in enumerate(results, 1):
title = item.get("title", "Untitled")
url = item.get("url", "")
content = item.get("content", "").strip()
lines.append(f"\n{i}. {title}")
lines.append(f" URL: {url}")
if content:
lines.append(f" {content[:300]}{'...' if len(content) > 300 else ''}")
return "\n".join(lines)
except Exception as e:
return f"[Error] Tavily search failed: {str(e)}"
+31
View File
@@ -0,0 +1,31 @@
import os
async def write_file(file_path: str, content: str) -> str:
"""将内容写入指定文件(会覆盖已有内容,自动创建目录)。
Args:
file_path: 文件的路径
content: 要写入的内容
Returns:
操作结果描述
"""
from kilostar.utils.sandbox import validate_path, PathViolation
try:
file_path = validate_path(file_path, write=True)
except PathViolation as e:
return f"[Sandbox] {e}"
try:
dir_path = os.path.dirname(file_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return f"已成功写入文件: {file_path}{len(content)} 字符)"
except Exception as e:
return f"[Error] 写入文件失败: {e}"
@@ -0,0 +1,26 @@
# interactive__toolset
KiloStar 工作流交互工具集。这些工具用于 Agent 与用户/前端之间的实时交互,依赖 `global_workflow_manager` 的消息通道。
## 工具列表
| 工具 | 说明 |
|------|------|
| `approval` | 在执行高风险操作前向用户发送审批请求,阻塞等待用户回复 |
| `send_file` | 把 Agent 生成的文件作为附件推送到当前对话窗口,前端渲染为可下载卡片 |
## 使用前提
这两个工具需要工作流上下文:调用方必须在 deps 中传入 `trace_id`,工具会通过 `global_workflow_manager` 的 pending 队列与前端通信。
- 在普通聊天场景下,`send_file``trace_id` 为空时会退化为直接返回文件内容字符串
- `approval` 在没有合法 `trace_id` 时会一直阻塞,建议仅在工作流节点中绑定
## 配置说明
无需任何配置,开箱即用。
## 适用场景
- Agent 计划执行删除、转账等高风险操作前的人工确认
- 让 Agent 把生成的报告、代码片段、图表数据以文件形式投递给用户
@@ -0,0 +1,5 @@
from .approval import approval
__all__ = [
"approval",
]
@@ -0,0 +1,17 @@
from kilostar.utils.ray_hook import ray_actor_hook
async def approval(message: str, trace_id: str) -> str:
"""当任务存在某些高风险操作或者计划需要让用户审批,发送请求给用户等待用户审批。
Args:
message: 发送给用户的请求
trace_id: 当前工作流的 trace_id
Returns:
用户的审批结果
"""
actor_list = ray_actor_hook("global_workflow_manager")
await actor_list.global_workflow_manager.put_pending.remote(trace_id, message)
reply = await actor_list.global_workflow_manager.get_received.remote(trace_id)
return reply
@@ -0,0 +1,15 @@
{
"name": "交互工具集",
"version": "0.1.0",
"description": "工作流场景下与用户/前端交互的工具(HITL 审批)",
"tools": [
{
"name": "approval",
"file": "approval.py",
"is_system": true,
"action_scope": [],
"config_args": {},
"category": "system"
}
]
}
@@ -0,0 +1,9 @@
from .query_workflow_status import query_workflow_status
from .query_task_list import query_task_list
from .send_file import send_file
__all__ = [
"query_workflow_status",
"query_task_list",
"send_file",
]
@@ -0,0 +1,31 @@
{
"name": "监管节点工具集",
"version": "0.1.0",
"description": "监管节点(regulatory_node)专属能力:查询工作流、查询任务列表、发送文件",
"tools": [
{
"name": "query_workflow_status",
"file": "query_workflow_status.py",
"is_system": true,
"action_scope": ["regulatory_node"],
"config_args": {},
"category": "system"
},
{
"name": "query_task_list",
"file": "query_task_list.py",
"is_system": true,
"action_scope": ["regulatory_node"],
"config_args": {},
"category": "system"
},
{
"name": "send_file",
"file": "send_file.py",
"is_system": true,
"action_scope": ["regulatory_node"],
"config_args": {},
"category": "system"
}
]
}
@@ -0,0 +1,59 @@
"""query_task_list:列出当前用户的短任务记录。
regulatory_node 用以回答"我之前那份报告呢""昨天那个查询结果是什么"
返回 task 表中的精简元数据列表(不含工作流的 graph state、context 等)。
注:此处的 "task" 是 regulatory_node 完成的轻量短任务(出报告/写文件/查询整理等),
与 workflow(多步骤复杂任务)是两套独立体系。如需查工作流进度,使用 query_workflow_status。
"""
from typing import Any, Dict, List, Optional
from kilostar.utils.ray_hook import ray_actor_hook
async def query_task_list(
user_id: str,
status_filter: Optional[str] = None,
limit: int = 20,
) -> Dict[str, Any]:
"""列出当前用户的短任务记录,按时间倒序。
Args:
user_id: 用户 ID(通常由调用方从对话上下文中带入)
status_filter: 可选,按状态过滤(running/completed/failed
limit: 最多返回条数,默认 20
Returns:
{
"user_id": str,
"tasks": [
{"task_id": ..., "title": ..., "status": ...,
"result_summary": ..., "created_at": ...}
],
"total": int
}
"""
pg = ray_actor_hook("postgres_database").postgres_database
rows: List[Dict[str, Any]] = await pg.list_tasks_by_user.remote(
user_id=user_id,
status=status_filter,
limit=limit,
) or []
tasks = [
{
"task_id": r.get("task_id"),
"title": r.get("title"),
"status": r.get("status"),
"result_summary": r.get("result_summary"),
"created_at": r.get("created_at"),
}
for r in rows
]
return {
"user_id": user_id,
"tasks": tasks,
"total": len(tasks),
}
@@ -0,0 +1,51 @@
"""query_workflow_status:查询某个 trace_id 对应工作流的最近事件。
regulatory_node 在与用户对话时,可以借此工具回答"我那个任务跑到哪一步了"
之类的问题。返回最近 N 条事件 + 当前工作流 status。
"""
from typing import Any, Dict, List
from kilostar.utils.ray_hook import ray_actor_hook
async def query_workflow_status(trace_id: str, limit: int = 10) -> Dict[str, Any]:
"""查询指定工作流 trace_id 的状态与最近事件。
Args:
trace_id: 工作流追踪 ID
limit: 返回最近多少条事件,默认 10
Returns:
{
"trace_id": str,
"status": str | None, # 工作流当前状态(pending/running/completed/failed
"title": str | None,
"recent_events": [ # 最近事件,按时间倒序
{"event_type": ..., "level": ..., "message": ..., "node_name": ..., "created_at": ...}
]
}
"""
pg = ray_actor_hook("postgres_database").postgres_database
workflow = await pg.get_workflow.remote(trace_id)
events = await pg.query_event_logs.remote(trace_id=trace_id, limit=limit)
recent: List[Dict[str, Any]] = []
for e in events or []:
recent.append(
{
"event_type": getattr(e, "event_type", None),
"level": getattr(e, "level", None),
"message": getattr(e, "message", None),
"node_name": getattr(e, "node_name", None),
"created_at": str(getattr(e, "created_at", "")),
}
)
return {
"trace_id": trace_id,
"status": getattr(workflow, "status", None) if workflow else None,
"title": getattr(workflow, "title", None) if workflow else None,
"recent_events": recent,
}
@@ -0,0 +1,63 @@
"""send_file:在对话/工作流场景下投递一份文件给用户。
regulatory_node 直接对话场景下用此工具把生成的文件发给用户:
- 工作流场景(带 trace_id):写入 data/artifact/<trace_id>/,前端通过 SSE
收到带下载链接的卡片。
- 直接对话场景(无 trace_id):退化为把文件内容拼回字符串返回给 agent,
让 agent 再以代码块形式吐给用户。
"""
import json
import re
import uuid
from pathlib import Path
from kilostar.utils.ray_hook import ray_actor_hook
from kilostar.utils.settings import get_artifact_dir
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _sanitize_filename(name: str) -> str:
name = name.strip().replace("\\", "/").split("/")[-1]
name = _SAFE_NAME_RE.sub("_", name)
return name or "file"
async def send_file(filename: str, content: str, trace_id: str = "") -> str:
"""把 agent 生成的文件作为附件投递给用户。
Args:
filename: 文件名(含扩展名),如 "report.md" / "main.py"
content: 文件内容(UTF-8 文本)
trace_id: 当前会话/工作流的 trace_id;为空时退化为直接返回内容
Returns:
发送结果说明或文件内容
"""
if not trace_id:
return f"文件 {filename} 内容如下:\n\n```\n{content}\n```"
safe_name = _sanitize_filename(filename)
artifact_id = uuid.uuid4().hex[:12]
trace_dir: Path = get_artifact_dir() / trace_id
trace_dir.mkdir(parents=True, exist_ok=True)
file_path = trace_dir / f"{artifact_id}_{safe_name}"
file_path.write_text(content, encoding="utf-8")
payload = json.dumps(
{
"type": "file",
"filename": safe_name,
"artifact_id": artifact_id,
"url": f"/api/v1/resource/artifact/{trace_id}/{artifact_id}",
"size": len(content.encode("utf-8")),
},
ensure_ascii=False,
)
actor_list = ray_actor_hook("global_workflow_manager")
await actor_list.global_workflow_manager.put_pending.remote(
trace_id, f"__FILE__{payload}"
)
return f"已发送文件: {safe_name}"
+15 -15
View File
@@ -1,24 +1,22 @@
version: '3.8'
services:
db:
image: postgres:16-alpine
container_name: pretor_db
container_name: kilostar_db_test
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: pretor
POSTGRES_PASSWORD: testpass123
POSTGRES_DB: kilostar
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
test: ["CMD-SHELL", "pg_isready -U postgres -d kilostar"]
interval: 5s
timeout: 5s
retries: 5
pretor:
kilostar:
build: .
container_name: pretor
container_name: kilostar_test
ports:
- "8000:8000"
- "8265:8265"
@@ -26,10 +24,12 @@ services:
db:
condition: service_healthy
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgrespassword
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=pretor
- SECRET_KEY=changethiskey12345
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpass123
POSTGRES_HOST: db
POSTGRES_PORT: 5432
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
+205
View File
@@ -0,0 +1,205 @@
# 项目结构
> 最后更新:2026-06-17
```
KiloStar/
├── main.py # 应用入口(standalone / distributed 双模式)
├── pyproject.toml # Python 依赖与项目元数据(uv 管理)
├── Dockerfile / docker-compose.yml # 容器化部署
├── alembic/ # 数据库迁移脚本(顺序编号 0001~0009)
├── config/ # 环境配置模板
│ ├── .env.example # 环境变量模板
│ ├── config.yml # 应用配置(provider/node 默认值)
│ ├── workflow.yaml # 工作流重试策略
│ └── sandbox.yaml # 沙箱策略(路径白名单/命令过滤)
├── kilostar/ # ===== 后端核心包 =====
│ │
│ ├── api/ # FastAPI 路由层(每个文件一个 APIRouter)
│ │ ├── __init__.py # app 实例、中间件、异常处理、路由挂载
│ │ ├── system.py # GET /health + /api/v1/system 系统信息
│ │ ├── workflow.py # /api/v1/workflow CRUD / SSE / resume
│ │ ├── chat.py # /api/v1/chat 对话(含历史上下文注入)
│ │ ├── agent.py # /api/v1/agent Worker CRUD / 模板
│ │ ├── resource.py # /api/v1/resource Toolset / Skill / Artifact
│ │ ├── plugin.py # /api/v1/plugin 重型插件 submit/status/stream
│ │ ├── provider.py # /api/v1/provider 模型供应商 CRUD
│ │ ├── auth.py # /api/v1/auth 登录/注册/改密
│ │ └── platform/ # 平台接入
│ │ ├── frontend.py # SPA 静态资源 fallback
│ │ └── onebot.py # OneBot v11 协议适配
│ │
│ ├── core/ # 核心业务逻辑
│ │ ├── individual/ # 系统 Agent 节点
│ │ │ ├── consciousness_node/ # 意识节点:接收用户命令 → 设计工作流 DAG
│ │ │ │ ├── consciousness_node.py
│ │ │ │ └── template.py # structured output 模板
│ │ │ ├── regulatory_node/ # 监管节点:直面用户对话、质量把关
│ │ │ │ ├── regulatory_node.py
│ │ │ │ └── template.py
│ │ │ ├── control_node/ # 控制节点(已废弃,名字保留给未来远程探针节点)
│ │ │ │ ├── control_node.py
│ │ │ │ └── template.py
│ │ │ └── growth_node/ # 生长节点:能力自扩展(占位)
│ │ │
│ │ ├── work/ # 工作执行层
│ │ │ ├── workflow/ # 工作流引擎
│ │ │ │ ├── workflow_engine.py # 轮询 + 调度主循环
│ │ │ │ ├── workflow.py # pydantic-graph 节点定义
│ │ │ │ ├── model.py # WorkflowState / StepResult 等
│ │ │ │ └── graph_persistence.py # 执行状态持久化到 PG
│ │ │ ├── chat/ # 对话处理(占位)
│ │ │ └── task/ # 短任务执行(占位)
│ │ │
│ │ ├── global_state_machine/ # 全局状态机 Actor(系统唯一真相源)
│ │ │ ├── global_state_machine.py # GSM 主体:初始化、注册表读写、toolset 补种
│ │ │ ├── gsm_snapshot.py # 不可变快照(放入 Ray Object Store 供快读)
│ │ │ ├── individual_manager.py # Individual 注册/查询/删除
│ │ │ ├── provider_manager.py # Provider 注册/CRUD/test_connection
│ │ │ ├── tool_manager.py # Toolset 加载(读 manifest.json+ 工具分发
│ │ │ ├── skill_manager.py # Skill 元数据注册/查询
│ │ │ └── model_provider/ # Provider 适配(per-vendor 子类)
│ │ │ ├── base_provider.py # 抽象基类
│ │ │ ├── openai_provider.py # OpenAI / 兼容接口
│ │ │ ├── claude_provider.py # Anthropic Claude
│ │ │ ├── gemini_provider.py # Google Gemini
│ │ │ └── deepseek_provider.py # DeepSeek
│ │ │
│ │ ├── global_workflow_manager/ # 工作流调度 Actor
│ │ │ └── global_workflow_manager.py # 消息队列、pending workflow 轮询
│ │ │
│ │ └── postgres_database/ # PostgreSQL DAO 层(Actor 门面模式)
│ │ ├── postgres.py # 统一门面:组合所有子 DAOready_event 守卫
│ │ ├── database_exception.py# @database_exception 装饰器(统一异常包装)
│ │ ├── model/ # SQLAlchemy ORM 模型
│ │ │ ├── base.py # DeclarativeBase
│ │ │ ├── user.py # User + UserAuthority
│ │ │ ├── provider.py # ProviderModel
│ │ │ ├── individual.py # Base/Specialist/Ordinary/Special Individual
│ │ │ ├── workflow.py # Workflow + Context + GraphState
│ │ │ ├── chat_history.py # ChatHistoryRegister + Message
│ │ │ ├── system_node.py # SystemNodeConfigModel
│ │ │ ├── mcp_server.py # MCPServerModel
│ │ │ ├── tool_config.py # ToolConfigModel
│ │ │ ├── custom_toolset.py# CustomToolsetModel
│ │ │ ├── persona_template.py# PersonaTemplate
│ │ │ ├── system_event_log.py# SystemEventLog
│ │ │ ├── org_task.py # OrgTask(重型插件任务)
│ │ │ └── org_task_event.py# OrgTaskEvent(任务事件流)
│ │ └── module/ # 各表 DAO 实现(async session + CRUD
│ │ ├── user.py / provider.py / individual.py / ...
│ │ └── org_task.py # 重型插件任务 + 事件 DAO
│ │
│ ├── plugin_runtime/ # 重型插件(Organization)运行时
│ │ ├── base_organization.py # 基类:asyncio.Queue 消费、dispatch/submit 双通道
│ │ │ # react 循环、consult 工具、PG 持久化
│ │ ├── plugin_manager.py # GlobalPluginManager Actorbootstrap/install/reload
│ │ ├── loader.py # discover_plugins + load_plugin + uv 依赖安装
│ │ ├── tool_bridge.py # make_dispatch_tool() → 生成 dispatch_to_<org> 函数
│ │ ├── manifest.py # OrgManifest pydantic 模型
│ │ ├── agents_config.py # AgentsConfig / AgentDef / orchestration
│ │ └── event.py # OrgEvent / OrgEventType / TaskState
│ │
│ ├── adapter/ # 模型适配器
│ │ └── model_adapter/
│ │ ├── agent_factory.py # AgentFactory:根据 provider+model 构建 pydantic-ai Agent
│ │ └── deepseek_reasoner.py # DeepSeek R1 reasoning 特殊适配
│ │
│ ├── utils/ # ===== 工具函数层 =====
│ │ ├── settings.py # AppSettingspydantic-settings+ 路径工具
│ │ │ # get_settings / get_toolset_dir / get_plugin_dir / get_artifact_dir
│ │ ├── config_loader.py # 多 YAML 统一加载 → AppConfigworkflow/sandbox/应用配置)
│ │ ├── ray_compat.py # standalone/distributed 兼容层
│ │ │ # @actor_class 装饰器、StandaloneProxy、_STANDALONE 标志
│ │ ├── ray_hook.py # ray_actor_hook():按名字获取 Actor 句柄(两种模式统一)
│ │ ├── access.py # JWT 认证 + RBAC 鉴权(Accessor / TokenData / RoleChecker
│ │ ├── crypto.py # Fernet 对称加密(API key 等敏感字段落盘加密)
│ │ ├── error.py # 统一异常体系:KiloStarError / BusinessError / InfraError
│ │ ├── logger.py # loguru + rich 日志(get_logger 按模块名取 logger
│ │ ├── request_context.py # contextvars 双层 IDrequest_id + trace_id 传播
│ │ ├── get_tool.py # 按工具名动态加载函数(扫描 manifest → importlib
│ │ ├── mcp_helper.py # MCP Server 实例创建(stdio/sse/http 三种传输)
│ │ ├── sandbox.py # 工具沙箱:路径校验、命令黑名单、Python AST 检查
│ │ ├── agent_model.py # Agent 通用 pydantic 响应模型(ResponseModel 等)
│ │ ├── prompts.py # 系统节点 system prompt 模板(按角色×locale
│ │ ├── rate_limit.py # 滑动窗口内存限流器(按 IP,单实例用)
│ │ ├── retry.py # @retry_on_retryable_error 装饰器(指数退避)
│ │ ├── banner.py # 启动 banner ASCII art
│ │ └── i18n.py # 国际化翻译(t() 函数,accept-language 解析)
│ │
│ ├── worker_cluster/ # Worker 集群管理
│ │ └── worker_cluster.py # WorkerCluster Actor:按资源标签管理 worker 池
│ │ # CPU / Core / GPU 三类,分布式下各一个 Actor
│ │
│ └── worker_individual/ # Worker 个体
│ ├── base_individual.py # 抽象基类(生命周期 + 工具绑定)
│ ├── ordinary_individual.py # 通用 Worker(接受任意 system prompt
│ ├── skill_individual.py # Skill Worker(加载指定 skill 执行)
│ └── special_individual.py # 特殊 Workerembedding/TTS/图像,占位)
├── data/ # ===== 数据/插件目录(运行时读取)=====
│ ├── toolset/ # 工具集(每个子目录 = 一个 toolset)
│ │ ├── base_toolset/ # 系统基础工具
│ │ │ ├── manifest.json # 声明 7 个工具的元数据
│ │ │ ├── shell_executor.py # Shell 命令执行
│ │ │ ├── file_reader.py # 文件读取
│ │ │ ├── edit_file.py # 文件编辑(diff patch
│ │ │ ├── write_file.py # 文件写入
│ │ │ ├── search_file.py # 文件搜索(glob + grep
│ │ │ ├── python_executor.py # Python 代码执行(沙箱内)
│ │ │ └── tavily_search.py # Tavily 网络搜索
│ │ └── interactive_toolset/ # 交互工具(需要人/系统介入)
│ │ ├── manifest.json
│ │ ├── approval.py # 人工审批节点
│ │ └── send_file.py # 文件下发(存 artifact + 推 SSE
│ │
│ └── plugin/ # 重型插件(每个子目录 = 一个 Organization
│ └── example_dept/ # 示例插件(开发模板)
│ ├── manifest.json # 插件元数据(name/entry/concurrency/...
│ ├── agents.json # 内部 agent 定义(analyst + executor
│ ├── README.md
│ ├── core/ # 业务逻辑入口
│ │ └── organization.py # ExampleOrganization(BaseOrganization)
│ ├── toolset/ # 插件本地工具(可选)
│ ├── skills/ # 插件本地技能(可选)
│ └── dashboard/ # 前端面板(占位,Tauri 化后接通)
├── frontend/ # ===== React 前端(Vite + TypeScript + Tailwind=====
│ └── src/
│ ├── api/ # Axios client + SSE 封装 + 类型化请求
│ ├── assets/ # 静态资源(图标/字体)
│ ├── hooks/ # 自定义 React hooks
│ ├── components/ # UI 组件
│ │ ├── Chat/ # 工作流面板 + 实时日志 + 文件卡片
│ │ ├── Agent/ # Worker / Provider 管理
│ │ ├── Plugin/ # Skill / Toolset / MCP 配置
│ │ ├── Auth/ # 登录 / 注册
│ │ ├── Layout/ # 布局骨架 + 导航
│ │ └── Settings/ # 系统设置
│ ├── i18n/ # 国际化
│ │ └── locales/ # zh.json / en.json
│ ├── store/ # Zustand 状态管理
│ └── types/ # TypeScript 类型定义
├── tests/ # ===== 测试套件(331 用例)=====
│ ├── unit/ # 单元测试(纯逻辑,mock 外部依赖)
│ └── integration/ # 集成测试(启动真实服务)
└── subprojects/ # ===== 子项目 =====
└── stardomain/ # Skill 脚本沙箱执行(local + Docker 双模式)
```
---
## 关键设计模式
| 模式 | 说明 |
|:--|:--|
| `@actor_class` | 装饰器统一 standalone(普通对象)和 distributedRay Actor)两种运行形态 |
| `ray_actor_hook(name)` | 按名字获取 actor 句柄,两种模式下接口一致 |
| `StandaloneProxy` | 将普通方法调用包装为 `.remote()` 语法兼容 |
| GSM Snapshot | 不可变快照放入 Object Store,各节点快速读取无需 RPC |
| DAO 门面 | `PostgresDatabase` 组合所有子 DAO`ready_event` 确保初始化后才放行 |
| manifest.json 声明式 | 工具集/插件元数据与代码分离,支持热发现和前端展示 |
| dispatch / submit 双通道 | 重型插件对内阁阻塞(dispatch),对用户射后不管(submit) |
+94 -26
View File
@@ -1,34 +1,102 @@
## Pretor项目
# KiloStar 项目介绍
#### 简介
**Pretor**是一款python开发,实现将小模型进行微调后整理为一个大型集群,从而实现低算力情况下高复杂度任务的实现。
系统模型分为以下部分:
- **监管节点**:负责基本交流和任务分流;
- **管控节点**:负责调度系统资源;
- **意识节点**:负责复杂任务的处理;
- **生长节点**:负责获取资源并且将基础模型训练为特化模型;
- **特殊子个体**:与外界交互的模型,如embedding模型,tts模型等;
- **专家子个体**:携带有专业skill的agent对象;
- **基础子个体**:普通的agent对象;
---
#### 项目介绍
**Pretor** 是一款基于分布式计算平台 **Ray** 和 agent开发框架**pydantic-AI** 开发的多智能体协作平台,通过多智能体的协作和任务拆解,实现复杂任务的高质量完成。
## 简介
**Pretor**使用 **python**著名的高性能后端框架 **Fastapi** 来作为整个系统对用户暴露接口的网关。在**Pretor**运行过程中,用户通过发送请求至fastapi从而包装为 `PretorEvent`对象,并且发往`supervisory_node`,由**supervisory_node**进行简单的意图判断,如果判断用户只是简单交流比如聊天等,**supervisory_node**会直接对用户进行回复结束事件
,如果判断用户想要完成复杂的任务,**supervisory_node**会选择将从`workflow_template(工作流模板)`中选择一个或者不选择,然后将event挂到`全局状态机`实现追溯方便并发往`Workflow_Running_Engine`的异步队列,被协程对象取走后,由**consciousness_node**创建为`PretorWorkflow`对象,挂载到实例化的`WorkflowEngine`进行执行。完成任务后返回给用户。
**KiloStar(千星)** 是一个开源的通用多 Agent 协作平台,提供从模型接入、Agent 编排、工作流执行到插件扩展的完整能力栈。系统基于 [Ray](https://www.ray.io/) 实现分布式执行,基于 [Pydantic-AI](https://ai.pydantic.dev/) 提供类型安全的 Agent 开发框架,并通过 [FastAPI](https://fastapi.tiangolo.com/) 网关对外暴露统一接口
平台同时支持云端 API 模型与本地微调模型,内置多 Agent 协作的核心系统节点,并通过**重型插件**机制允许使用者把平台改造成面向具体场景的专用 Agent 应用。
---
#### 技术架构背景
- 分布式大脑:利用 Ray 框架实现 Actor 模型,将不同的智能体节点(Node)部署为独立运行的分布式 Actor,具备跨节点通信和动态调度的能力。
- 强类型通信协议:引入 PydanticAI 作为智能体开发框架,核心目的在于将大语言模型(LLM)产生的非结构化文本,通过 Pydantic 模型转化为强类型的结构化数据(JSON),确保多智能体协作时数据传输的工业级稳定性。
- 推理驱动路由:系统针对最新的**deepseek-v4**系列进行了适配,实现灵活调用
## 项目特色
1. **本地微调小模型一等公民**:内置 vLLM 适配,本地微调模型在调用层与云端 API 模型对等,使用者可以为不同 Agent 节点绑定不同的本地模型。
2. **重型插件机制**:插件可附带独立前端页面、工具组与 API 接口,将 KiloStar 改造为编程辅助、学习助手、数据分析等专用 Agent 应用。
3. **多 Agent 协作内核**:监管 / 意识 / 控制 / 生长四类系统节点 + 动态派生的 Worker 个体,原生支持任务拆解、调度、监督的分工模式。
4. **standalone / distributed 双模式**:单机零依赖起步,集群按需横向扩展,业务代码在两种模式下完全一致。
5. **私有化部署友好**:所有组件可在用户自有环境内运行,不强制依赖任何第三方服务。
---
#### 项目背景
###### 1.多智能体架构的需求
随着任务复杂度的提升,单一**Agent**一定程度上以及满足不了人们对于人工智能完成复杂任务的需求。模仿人类社会中的团队合作,Pretor以**Ray**作为底座,从而实现一种多智能体协作的设计。
###### 2.对于大语言模型输出内容约束的需求
LLM 输出的非结构化文本在多智能体交互中极易崩溃。所以,**Pretor**没有选择如**LangChain**这种老牌智能体开发框架,而是选择了新兴的**pydanticAI**这种强约束框架,使得多智能体协作避免黑盒化。
**PydanticAI**是一款基于**Pydantic**的智能体开发框架,**Pydantic**是**python**中著名的数据类型约束库,**Pydantic**官方通过**Pydantic**的强约束,实现了对于LLM的生成约束。
## 系统组成
### 节点体系
| 节点类型 | 职责 |
|:--|:--|
| **Regulatory Node** | 监管节点。承担用户对话入口、意图判断、对话模式的工具调用,以及对工作流执行结果的监督 |
| **Consciousness Node** | 意识节点。负责复杂任务的拆解、规划与工作流构建 |
| **Control Node** | 控制节点。负责工作流执行过程中的路由调度与状态监控 |
| **Growth Node** | 生长节点。负责集群与子个体的扩张 |
| **Worker Individual** | Worker 个体。包括 Ordinary(基础 Agent)、Skill(携带专业技能的 Agent)、Special(与外部世界交互的特殊 Agent,例如 embedding / TTS |
### 模块布局
```
KiloStar/
├── kilostar/
│ ├── api/ # FastAPI 路由层
│ ├── core/
│ │ ├── individual/ # 各类系统节点实现
│ │ ├── work/ # 工作流 / 对话 / 任务执行层
│ │ ├── global_state_machine/ # 全局状态机(Provider / Config 等)
│ │ ├── global_workflow_manager/ # 工作流消息队列 Actor
│ │ └── postgres_database/ # PostgreSQL DAO 层
│ ├── adapter/ # 模型适配器
│ ├── plugin/ # 工具插件
│ ├── worker_cluster/ # Worker 集群管理
│ ├── worker_individual/ # Worker 个体生命周期
│ └── utils/ # 鉴权 / Ray 句柄 / 配置等
├── frontend/ # React + Vite 前端
├── subprojects/ # Rust 子项目(viceroy / stardomain
├── alembic/ # 数据库迁移
├── tests/ # 单元 + 集成测试
└── docs/ # 设计文档
```
---
## 技术栈
| 层级 | 选型 | 说明 |
|:--|:--|:--|
| 分布式运行时 | **Ray** | Actor 模型、跨进程跨机器通信、自定义资源调度 |
| Agent 框架 | **Pydantic-AI** | LLM 输出强类型约束,避免多 Agent 协作中的非结构化文本黑盒 |
| Web 网关 | **FastAPI + Ray Serve** | 异步 HTTP 网关与 SSE 事件流 |
| 工作流引擎 | **pydantic-graph** | 有向图工作流编排,支持条件分支、循环、人工介入 |
| 状态持久化 | **PostgreSQL + Alembic** | workflow 中断恢复、Agent 配置、Provider 注册等 |
| 前端 | **React 19 + Vite + Tailwind + Zustand** | TypeScript 单页应用 |
| 子项目 | **Rust** | viceroySkill 安装)、stardomain(沙箱执行) |
---
## 运行模式
### standalone 单机模式
通过 `KILOSTAR_MODE=standalone` 启动,所有 Actor 退化为普通 Python 异步实例,无需安装 Ray,适合个人使用与开发调试。
### distributed 分布式模式
默认模式。Ray 启动后将系统节点部署为命名 ActorWorkerCluster 按 `kilostar_node_cpu` / `core` / `gpu` 自定义资源调度到对应物理节点,支持跨机器横向扩展。
两种模式共享同一套业务代码,通过 `kilostar.utils.standalone_proxy` 在 Actor Handle 层做透明适配。
---
## 安全设计
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
- **资源归属校验**workflow / chat 等用户资源严格绑定 user_id,跨用户访问返回 403
- **fetch-based SSE**Token 走 `Authorization` header,不暴露在 URL 中
- **生产模式密钥校验**:未提供有效 SECRET_KEY 时拒绝以 production 模式启动
---
## 生态子项目
| 项目 | 代号 | 功能 | 状态 |
|:--|:--|:--|:--|
| [kilostar-viceroy](https://github.com/zhaoxi826/viceroy) | 总督 | Skill 动态安装与全集群分发 | ✅ 已发布 |
| [kilostar-stardomain](../subprojects/stardomain) | 星域 | Skill / 插件脚本沙箱执行 | 开发中 |
| [kilostar-thought](https://github.com/zhaoxi826/thought) | 思绪 | Agent 增强记忆系统 | 开发中 |
+96
View File
@@ -0,0 +1,96 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
KiloStar is a distributed multi-agent system. Python/FastAPI backend with Ray actor orchestration, React 19 frontend, PostgreSQL storage. Supports two deployment modes: standalone (pure asyncio) and distributed (full Ray cluster).
## Commands
### Backend
```bash
uv sync # Install Python deps
uv run python main.py # Start distributed mode
KILOSTAR_MODE=standalone uv run python main.py # Start standalone mode
```
### Frontend (this directory)
```bash
npm install # Install deps
npm run dev # Dev server (Vite)
npm run build # Production build
npm run lint # ESLint
npx tsc --noEmit # Type-check without emitting
```
### Database Migrations (from project root)
```bash
make db-revision m="description" # Generate migration
make db-upgrade # Apply to HEAD
make db-downgrade # Rollback one
```
### Testing (from project root)
```bash
uv run pytest tests -q # Full suite
uv run pytest tests/unit -q # Unit only
uv run pytest tests/integration -q # Integration (needs DB)
```
### Docker
```bash
docker-compose up -d # Full stack (frontend build + backend)
```
## Architecture
### Dual-Mode Actor System
All core components are Ray actors in distributed mode, plain Python instances in standalone mode. Code uses a unified call pattern:
```python
from kilostar.utils.ray_hook import ray_actor_hook
# Returns a namespace object with actor handles
actors = ray_actor_hook("postgres_database")
await actors.postgres_database.some_method.remote(arg)
```
`StandaloneProxy` (in `kilostar/utils/standalone_proxy.py`) wraps instances to expose the same `.method.remote()` interface via asyncio. The `@actor_class` decorator marks classes that can be Ray actors or standalone instances depending on mode.
Mode is set via `KILOSTAR_MODE` env var. Entry point `main.py` branches into `start_standalone()` or `start_distributed()`.
### Backend Layout (`kilostar/`)
- `api/` — FastAPI routers (auth, chat, agent, workflow, system, resource, platform)
- `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)
- `worker_cluster/` — Task queue & worker dispatch
- `adapter/` — LLM model adapters (pydantic-ai AgentFactory)
- `plugin/tool_plugin/` — MCP-style tool plugins
- `utils/` — ray_hook, standalone_proxy, config_loader, access (JWT), i18n
### Frontend Layout (`frontend/src/`)
- `store/` — Zustand stores (useAppStore, useChatStore, etc.)
- `components/` — Chat, Agent, Plugin, Settings, Layout
- `api/client.ts` — Axios instance with auth interceptor
- `i18n/` — Chinese + English translations
### Key Patterns
- **Database facade**: `postgres.py` delegates to per-entity modules (`module/chat_history.py`, `module/persona_template.py`, etc.). Always add new DB methods to both the module AND the facade.
- **pydantic-ai agents**: Regulatory/Consciousness nodes use pydantic-ai with structured output (tool calls). Streaming chat uses direct httpx calls to OpenAI-compatible API instead.
- **SSE streaming**: Chat stream endpoint uses `StreamingResponse(media_type="text/event-stream")` with `data: {json}\n\n` format.
- **Config**: Multi-YAML in `config/` directory, loaded via `config_loader.py` at startup.
- **Alembic migrations**: `alembic/versions/` — naming convention: `YYYY_MM_DD_HHMM-NNNN_description.py`
### Environment Variables
- `KILOSTAR_MODE``standalone` or omit for distributed
- `KILOSTAR_SECRET_KEY` — JWT signing key
- `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` — DB connection
- `KILOSTAR_ENV``dev` or `prod`
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pretor-dashboard</title>
<title>kilostar-dashboard</title>
</head>
<body>
<div id="root"></div>
+1913 -19
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1,5 +1,5 @@
{
"name": "pretor-dashboard",
"name": "kilostar-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -10,11 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.10.2",
"axios": "^1.15.1",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-i18next": "^17.0.8",
"react-markdown": "^9.0.3",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "^4.0.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+118 -98
View File
@@ -1,143 +1,131 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import i18n from './i18n';
import { TopBar } from './components/Layout/TopBar';
import { CollapsibleSidebar } from './components/Layout/CollapsibleSidebar';
import { SetupGuideModal } from './components/Layout/SetupGuideModal';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentLayout } from './components/Agent/AgentLayout';
import { PluginLayout } from './components/Plugin/PluginLayout'; // Will rename to PluginLayout soon
import { PluginLayout } from './components/Plugin/PluginLayout';
import { ToolSettings } from './components/Plugin/ToolSettings';
import { WorkflowConfigSettings } from './components/Agent/WorkflowConfigSettings';
import { SystemLogsView } from './components/Agent/SystemLogsView';
import { LeftPanel } from './components/Chat/LeftPanel';
import { ChatPanel } from './components/Chat/ChatPanel';
import { RightPanel } from './components/Chat/RightPanel';
import { WorkflowListView } from './components/Chat/WorkflowListView';
import { NewWorkflowDialog } from './components/Chat/NewWorkflowDialog';
import { AuthPage } from './components/Auth/AuthPage';
// For Chat Module State Persistence
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface ChatSession {
id: string;
title: string;
messages: Message[];
updatedAt: number;
}
import { HeavyPluginShell } from './plugins/HeavyPluginShell';
import { useAppStore } from './store/useAppStore';
import { useChatStore } from './store/useChatStore';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const {
isAuthenticated,
setIsAuthenticated,
mode,
setMode,
showSettings,
workTab,
agentTab,
applyTheme,
locale,
loadInstalledPlugins,
} = useAppStore();
// Layout State
const [mode, setMode] = useState<'work' | 'agent'>('work');
const [showSettings, setShowSettings] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const { loadSessions } = useChatStore();
const [showSetupGuide, setShowSetupGuide] = useState(false);
const activeHeavyPlugin = useAppStore((s) => s.activeHeavyPlugin);
// Module Sub-navigation States
// Work Mode
const [workTab, setWorkTab] = useState<'chat' | 'workflow'>('chat');
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
useEffect(() => {
applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme();
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [applyTheme]);
// Agent Mode
const [agentTab, setAgentTab] = useState<'plugin' | 'agents'>('plugin');
// Settings Sub-tab
const [settingsTab, setSettingsTab] = useState('users');
// Inner Agent Tab (temporary until full Agent layout rewrite)
const [innerAgentTab, setInnerAgentTab] = useState('worker');
const [resourceTab, setResourceTab] = useState('skill');
// Chat State Hoisted for Persistence
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
useEffect(() => {
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
}, [locale]);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setIsAuthenticated(true);
}
}, []);
}, [setIsAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
loadSessions();
loadInstalledPlugins();
setShowSetupGuide(true);
}
}, [isAuthenticated, loadSessions, loadInstalledPlugins]);
if (!isAuthenticated) {
return <AuthPage onLoginSuccess={() => setIsAuthenticated(true)} />;
}
return (
<div className="flex flex-col h-screen w-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
{/* 1. Top Bar */}
<TopBar
mode={mode}
setMode={setMode}
showSettings={showSettings}
setShowSettings={setShowSettings}
/>
<div className="flex flex-col h-screen w-screen bg-bg-primary text-text-primary font-sans overflow-hidden">
<TopBar />
{showSetupGuide && (
<SetupGuideModal
onClose={() => setShowSetupGuide(false)}
onNavigateToAgent={() => {
setMode('agent');
}}
/>
)}
{/* 2. Main Content Area */}
<div className="flex flex-1 overflow-hidden relative">
{showSettings ? (
<SettingsLayout settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
<SettingsLayout />
) : (
<>
{/* Collapsible Main Sidebar */}
<CollapsibleSidebar
mode={mode}
isOpen={isSidebarOpen}
setIsOpen={setIsSidebarOpen}
workTab={workTab}
setWorkTab={setWorkTab}
agentTab={agentTab}
setAgentTab={setAgentTab}
/>
<CollapsibleSidebar />
{/* Dynamic View based on Mode and Tab */}
<div className="flex-1 flex overflow-hidden">
{mode === 'work' && workTab === 'chat' && (
<div className="flex-1 p-6 flex overflow-hidden">
<div className="flex-1 flex bg-white rounded-3xl shadow-md border border-slate-200 overflow-hidden relative">
<LeftPanel
activeTab="chats"
selectedWorkflow={null}
setSelectedWorkflow={() => {}}
// Pass hoisted state down
chatSessions={chatSessions}
setChatSessions={setChatSessions}
activeSessionId={activeSessionId}
setActiveSessionId={setActiveSessionId}
/>
<ChatPanel
chatSessions={chatSessions}
setChatSessions={setChatSessions}
activeSessionId={activeSessionId}
setActiveSessionId={setActiveSessionId}
/>
</div>
<div className="flex-1 flex overflow-hidden">
<LeftPanel activeTab="chats" />
{activeHeavyPlugin ? (
<HeavyPluginShell name={activeHeavyPlugin} />
) : (
<ChatPanel />
)}
</div>
)}
{mode === 'work' && workTab === 'workflow' && (
<>
{selectedWorkflow ? (
<>
<LeftPanel
activeTab="workflows"
selectedWorkflow={selectedWorkflow}
setSelectedWorkflow={setSelectedWorkflow}
/>
<RightPanel selectedWorkflow={selectedWorkflow} />
</>
) : (
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
)}
</>
<WorkflowShell />
)}
{mode === 'agent' && agentTab === 'agents' && (
<AgentLayout agentTab={innerAgentTab} setAgentTab={setInnerAgentTab} />
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
{mode === 'agent' && agentTab === 'toolsets' && (
<div className="flex-1 overflow-y-auto p-8">
<ToolSettings />
</div>
)}
{mode === 'agent' && agentTab === 'plugin' && (
<PluginLayout resourceTab={resourceTab} setResourceTab={setResourceTab} />
{mode === 'agent' && agentTab === 'plugin' && <PluginLayout />}
{mode === 'agent' && agentTab === 'config' && (
<div className="flex-1 overflow-y-auto p-8">
<WorkflowConfigSettings />
</div>
)}
{mode === 'agent' && agentTab === 'logs' && (
<div className="flex-1 overflow-y-auto p-8">
<SystemLogsView />
</div>
)}
</div>
</>
@@ -147,4 +135,36 @@ function App() {
);
}
function WorkflowShell() {
const { selectedWorkflow, setSelectedWorkflow } = useChatStore();
if (selectedWorkflow === 'new') {
return (
<div className="flex-1 flex overflow-hidden">
<LeftPanel activeTab="workflows" />
<NewWorkflowDialog
onClose={() => setSelectedWorkflow(null)}
onSuccess={(traceId: string) => setSelectedWorkflow(traceId)}
/>
</div>
);
}
if (selectedWorkflow) {
return (
<div className="flex-1 flex overflow-hidden">
<LeftPanel activeTab="workflows" />
<RightPanel selectedWorkflow={selectedWorkflow} />
</div>
);
}
return (
<div className="flex-1 flex overflow-hidden">
<LeftPanel activeTab="workflows" />
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
</div>
);
}
export default App;
+5 -2
View File
@@ -4,18 +4,21 @@ import axios from 'axios';
// If missing, defaulting to '' means requests will be relative to the current browser origin.
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 10000,
timeout: 120000,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor to attach token to requests if we have one
// Interceptor to attach token and locale to requests
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 把用户语言偏好透传给后端,让 Agent prompt 和错误消息都能本地化
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh';
config.headers['Accept-Language'] = lang;
return config;
});
+130
View File
@@ -0,0 +1,130 @@
// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。
//
// 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query
// 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。
// 这里用 fetch 手动读取 text/event-streamtoken 走标准 Authorization header。
export interface SSEHandlers {
onOpen?: () => void;
onMessage?: (data: string) => void;
onError?: (err: unknown) => void;
// 连接断开、准备重连时回调,附带本次退避延迟(毫秒)
onReconnect?: (delayMs: number) => void;
}
export interface SSEOptions {
// 初始重连延迟(毫秒),默认 1000
baseDelayMs?: number;
// 最大重连延迟(毫秒),默认 30000
maxDelayMs?: number;
// 鉴权失败(401/403)时是否停止重连,默认 true
stopOnAuthError?: boolean;
}
export interface SSEConnection {
close: () => void;
}
const AUTH_ERROR_STATUSES = new Set([401, 403]);
export function connectSSE(
url: string,
token: string,
handlers: SSEHandlers,
options: SSEOptions = {},
): SSEConnection {
const baseDelay = options.baseDelayMs ?? 1000;
const maxDelay = options.maxDelayMs ?? 30000;
const stopOnAuthError = options.stopOnAuthError ?? true;
let controller = new AbortController();
let closed = false;
let attempt = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleReconnect = () => {
if (closed) return;
// 指数退避 + 抖动,封顶 maxDelay
const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay);
const delay = backoff / 2 + Math.random() * (backoff / 2);
attempt += 1;
handlers.onReconnect?.(delay);
retryTimer = setTimeout(() => {
if (closed) return;
controller = new AbortController();
void run();
}, delay);
};
const run = async () => {
try {
const resp = await fetch(url, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
});
if (!resp.ok || !resp.body) {
handlers.onError?.(new Error(`SSE connect failed: ${resp.status}`));
if (stopOnAuthError && AUTH_ERROR_STATUSES.has(resp.status)) {
closed = true;
return;
}
scheduleReconnect();
return;
}
// 连接成功,重置退避计数
attempt = 0;
handlers.onOpen?.();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const rawEvent = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const data = parseEventData(rawEvent);
if (data !== null) handlers.onMessage?.(data);
}
}
// 流正常结束(服务端关闭),非主动 close 则尝试重连
if (!closed) scheduleReconnect();
} catch (err) {
if (controller.signal.aborted || closed) return;
handlers.onError?.(err);
scheduleReconnect();
}
};
void run();
return {
close: () => {
closed = true;
if (retryTimer) clearTimeout(retryTimer);
controller.abort();
},
};
}
function parseEventData(rawEvent: string): string | null {
// 只关心 data: 行,多行 data 用 \n 拼接,忽略注释(:)与其他字段
const dataLines = rawEvent
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''));
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}
+26 -24
View File
@@ -1,38 +1,40 @@
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore';
import { ProvidersSettings } from './ProvidersSettings';
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
import { PersonaTemplateSettings } from './PersonaTemplateSettings';
interface AgentLayoutProps {
agentTab: string;
setAgentTab: (tab: string) => void;
}
export function AgentLayout() {
const { t } = useTranslation();
const { innerAgentTab, setInnerAgentTab } = useAppStore();
const tabs = [
{ key: 'worker', label: t('agent.individual') },
{ key: 'persona', label: t('agent.personaManagement') },
{ key: 'providers', label: t('agent.providerManagement') },
];
export function AgentLayout({ agentTab, setAgentTab }: AgentLayoutProps) {
return (
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
{/* Top Tabs for Agent Module */}
<div className="h-14 border-b border-slate-200 bg-white flex items-center px-6 shadow-sm z-10 shrink-0 space-x-6">
<div className="flex-1 flex flex-col bg-bg-secondary overflow-hidden">
<div className="h-12 border-b border-border-primary bg-bg-card/80 backdrop-blur flex items-center px-6 shrink-0 gap-1">
{tabs.map((tab) => (
<button
onClick={() => setAgentTab('worker')}
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
agentTab === 'worker' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
key={tab.key}
onClick={() => setInnerAgentTab(tab.key)}
className={`px-4 py-2 text-xs font-semibold rounded-lg transition-all ${
innerAgentTab === tab.key
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
}`}
>
Individual
</button>
<button
onClick={() => setAgentTab('providers')}
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
agentTab === 'providers' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
}`}
>
Provider Management
{tab.label}
</button>
))}
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto p-8">
{agentTab === 'worker' && <WorkerIndividualSettings />}
{agentTab === 'providers' && <ProvidersSettings />}
{innerAgentTab === 'worker' && <WorkerIndividualSettings />}
{innerAgentTab === 'persona' && <PersonaTemplateSettings />}
{innerAgentTab === 'providers' && <ProvidersSettings />}
</div>
</div>
);
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText } from 'lucide-react';
interface PersonaTemplate {
template_id: string;
name: string;
system_prompt: string;
owner_id: string | null;
}
export function PersonaTemplateSettings() {
const { t } = useTranslation();
const [templates, setTemplates] = useState<PersonaTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<PersonaTemplate>>({});
const [isNew, setIsNew] = useState(false);
const [modalMessage, setModalMessage] = useState('');
const [submitLoading, setSubmitLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const res = await apiClient.get('/api/v1/agent/template');
setTemplates(res.data.templates || []);
} catch { setError(t('agent.loadFailed')); }
finally { setLoading(false); }
};
useEffect(() => { fetchData(); }, []);
const handleAddNew = () => {
setEditData({ name: '', system_prompt: '' });
setIsNew(true);
setIsEditing(true);
setModalMessage('');
};
const handleEdit = (tpl: PersonaTemplate) => {
setEditData({ ...tpl });
setIsNew(false);
setIsEditing(true);
setModalMessage('');
};
const handleDelete = async (id: string) => {
if (!confirm(t('agent.deleteTemplateConfirm'))) return;
try { await apiClient.delete(`/api/v1/agent/template/${id}`); fetchData(); }
catch { alert(t('common.deleteFailed')); }
};
const handleModalSave = async (e: React.FormEvent) => {
e.preventDefault();
setModalMessage('');
setSubmitLoading(true);
try {
const payload = { name: editData.name, system_prompt: editData.system_prompt };
if (isNew) await apiClient.post('/api/v1/agent/template', payload);
else await apiClient.put(`/api/v1/agent/template/${editData.template_id}`, payload);
setIsEditing(false);
fetchData();
} catch (err: any) {
setModalMessage(err.response?.data?.detail || t('common.saveFailed'));
} finally { setSubmitLoading(false); }
};
return (
<div className="max-w-5xl space-y-6">
<div className="flex justify-between items-end">
<div>
<h1 className="text-lg font-bold text-text-primary">{t('agent.personaManagement')}</h1>
<p className="text-sm text-text-muted mt-0.5">{t('agent.personaManagementDesc')}</p>
</div>
<button onClick={handleAddNew} className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
<Plus size={14} /> {t('agent.addTemplate')}
</button>
</div>
{error && <div className="text-sm text-danger bg-danger-bg border border-danger/20 rounded-xl p-3">{error}</div>}
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
{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>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<FileText size={32} className="mb-3 opacity-40" />
<span className="text-sm">{t('agent.noTemplates')}</span>
</div>
) : (
<div className="divide-y divide-border-secondary">
{templates.map((tpl) => (
<div key={tpl.template_id} className="flex items-center justify-between px-5 py-3.5 hover:bg-bg-hover transition-colors">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center shrink-0">
<FileText size={14} className="text-text-muted" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-text-primary text-sm">{tpl.name}</div>
<div className="text-xs text-text-muted truncate mt-0.5">{tpl.system_prompt || t('agent.noPrompt')}</div>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0 ml-3">
<button onClick={() => handleEdit(tpl)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
<button onClick={() => handleDelete(tpl.template_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
</div>
{isEditing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto border border-border-primary">
<div className="flex justify-between items-center p-5 border-b border-border-primary">
<h2 className="text-base font-bold text-text-primary">{isNew ? t('agent.addTemplate') : t('agent.editTemplate')}</h2>
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleModalSave} className="p-5 space-y-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.templateName')}</label>
<input type="text" required value={editData.name || ''} onChange={(e) => setEditData({...editData, name: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label>
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={6}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
<button type="submit" disabled={submitLoading} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
<Save size={14} /> {submitLoading ? t('common.saving') : t('common.save')}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,82 @@
interface ProviderIconProps {
type: string;
size?: number;
className?: string;
}
export function ProviderIcon({ type, size = 18, className = '' }: ProviderIconProps) {
const props = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'currentColor',
className,
};
switch (type?.toLowerCase()) {
case 'openai':
return (
<svg {...props} viewBox="0 0 24 24">
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
</svg>
);
case 'claude':
case 'anthropic':
return (
<svg {...props} viewBox="0 0 24 24">
<path d="M4.709 15.955l4.72-2.647.079-.23-.079-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.328h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312z"/>
</svg>
);
case 'deepseek':
return (
<svg {...props} viewBox="0 0 24 24">
<path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.846-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"/>
</svg>
);
case 'gemini':
case 'google':
return (
<svg {...props} viewBox="0 0 24 24">
<path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12"/>
</svg>
);
case 'local':
case 'ollama':
return (
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M7 20h10M9 16v4M15 16v4" />
<circle cx="8" cy="10" r="1" fill="currentColor" />
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="16" cy="10" r="1" fill="currentColor" />
</svg>
);
default:
return (
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M9 9h6v6H9z" />
</svg>
);
}
}
const PROVIDER_BRAND_COLORS: Record<string, string> = {
openai: '#10a37f',
claude: '#d97757',
anthropic: '#d97757',
deepseek: '#4d6bfe',
gemini: '#4285f4',
google: '#4285f4',
local: '#8a8a8a',
ollama: '#8a8a8a',
};
export function getProviderBrandColor(type: string): string {
return PROVIDER_BRAND_COLORS[type?.toLowerCase()] || '#8a8a8a';
}
@@ -1,255 +1,455 @@
import { useState, useEffect } from 'react';
import { Box, Plus, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Plus, X, Loader2, Boxes, Zap, ChevronDown, ChevronRight, Settings as SettingsIcon } from 'lucide-react';
import type { Provider } from '../../types';
import apiClient from '../../api/client';
import { ProviderIcon, getProviderBrandColor } from './ProviderIcon';
interface ProviderTypeOption {
id: string;
iconKey: string;
backendType: 'openai' | 'claude' | 'deepseek' | 'gemini';
defaultUrl: string;
descKey: string;
nameKey: string;
}
const PROVIDER_TYPES: ProviderTypeOption[] = [
{ id: 'openai', iconKey: 'openai', backendType: 'openai', defaultUrl: 'https://api.openai.com/v1', nameKey: 'agent.providerTypeOpenai', descKey: 'agent.providerTypeOpenaiDesc' },
{ id: 'openai_compat', iconKey: 'openai', backendType: 'openai', defaultUrl: '', nameKey: 'agent.providerTypeOpenaiCompat', descKey: 'agent.providerTypeOpenaiCompatDesc' },
{ id: 'anthropic', iconKey: 'claude', backendType: 'claude', defaultUrl: 'https://api.anthropic.com', nameKey: 'agent.providerTypeAnthropic', descKey: 'agent.providerTypeAnthropicDesc' },
{ id: 'gemini', iconKey: 'gemini', backendType: 'gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta', nameKey: 'agent.providerTypeGemini', descKey: 'agent.providerTypeGeminiDesc' },
{ id: 'deepseek', iconKey: 'deepseek', backendType: 'deepseek', defaultUrl: 'https://api.deepseek.com/v1', nameKey: 'agent.providerTypeDeepseek', descKey: 'agent.providerTypeDeepseekDesc' },
];
function detectTypeFromProvider(p: Provider): string {
if (p.provider_type === 'openai') {
return p.provider_url?.includes('api.openai.com') ? 'openai' : 'openai_compat';
}
if (p.provider_type === 'claude') return 'anthropic';
return p.provider_type || 'openai';
}
export function ProvidersSettings() {
const { t } = useTranslation();
const [providers, setProviders] = useState<Provider[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({
provider_type: 'openai',
provider_title: '',
provider_url: '',
provider_apikey: ''
});
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: '', 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];
const fetchProviders = async () => {
setLoading(true);
try {
const response = await apiClient.get('/api/v1/provider/list');
const data = response.data.provider_list || {};
const providerArray: Provider[] = Object.values(data);
setProviders(providerArray);
setProviders(Object.values(response.data.provider_list || {}));
} catch (error) {
console.error("Failed to fetch providers", error);
console.error('Failed to fetch providers', error);
setProviders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProviders();
}, []);
useEffect(() => { fetchProviders(); }, []);
const handleOpenModal = () => {
setFormData({
provider_type: 'openai',
provider_title: '',
provider_url: '',
provider_apikey: ''
});
const openAddModal = () => {
setEditingProvider(null);
setSelectedTypeId('openai');
setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '', model_settings: '' });
setError('');
setModelSettingsError('');
setTestResult(null);
setShowAdvanced(false);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
const openEditModal = (provider: Provider) => {
const typeId = detectTypeFromProvider(provider);
setEditingProvider(provider.provider_title);
setSelectedTypeId(typeId);
setFormData({
provider_title: provider.provider_title,
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);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
const handleSelectType = (typeId: string) => {
setSelectedTypeId(typeId);
if (!editingProvider) {
const tp = PROVIDER_TYPES.find((p) => p.id === typeId);
if (tp) setFormData((prev) => ({ ...prev, provider_url: tp.defaultUrl }));
}
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);
const payload: any = {
provider_type: selectedType.backendType,
provider_title: formData.provider_title,
provider_url: formData.provider_url,
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;
};
const handleTestConnection = async () => {
if (!formData.provider_url || !formData.provider_apikey) {
setError(t('agent.providerFillUrlAndKey'));
return;
}
setTestLoading(true);
setTestResult(null);
setError('');
try {
const resp = await apiClient.post('/api/v1/provider/test', buildPayload());
setTestResult(resp.data);
} catch {
setTestResult({ success: false, error: 'Request failed' });
} finally {
setTestLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.provider_title || !formData.provider_url || !formData.provider_apikey) {
setError('Please fill in all fields.');
setError(t('agent.providerFillAll'));
return;
}
const ms = parseModelSettings();
if (!ms.ok) return;
setSubmitLoading(true);
setError('');
try {
await apiClient.post('/api/v1/provider', formData);
if (editingProvider) {
await apiClient.delete(`/api/v1/provider/${editingProvider}`);
}
await apiClient.post('/api/v1/provider', buildPayload());
await fetchProviders();
handleCloseModal();
setIsModalOpen(false);
setEditingProvider(null);
} catch (err) {
console.error("Error adding provider", err);
setError('Failed to add provider. Please check your inputs and try again.');
setError(t('agent.providerAddFailed'));
} finally {
setSubmitLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-end">
<div>
<h3 className="text-xl font-semibold text-slate-800">Provider Management</h3>
<p className="text-sm text-slate-500 mt-1">Configure external AI model providers and API keys.</p>
<h3 className="text-lg font-bold text-text-primary">{t('agent.providerManagement')}</h3>
<p className="text-sm text-text-muted mt-0.5">{t('agent.providerDesc')}</p>
</div>
<button
onClick={handleOpenModal}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
>
<Plus size={16} className="mr-2" />
Add Provider
<button onClick={openAddModal}
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
<Plus size={14} /> {t('agent.addProvider')}
</button>
</div>
{loading ? (
<div className="text-center text-slate-500 py-8">Loading providers...</div>
<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>
) : providers.length === 0 ? (
<div className="text-center text-slate-500 py-8 bg-white rounded-xl border border-slate-200">
No providers configured yet. Click "Add Provider" to get started.
<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('agent.noProviders')}</span>
</div>
) : (
<div className="grid grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{providers.map((provider, i) => (
<div key={i} className="bg-white border border-slate-200 p-5 rounded-xl shadow-sm hover:border-blue-200 transition-colors flex flex-col justify-between">
<div>
<div key={i} className="bg-bg-card border border-border-primary rounded-2xl p-5 card-hover">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center">
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-100 flex items-center justify-center mr-3">
<Box size={20} className="text-slate-600" />
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-bg-secondary border border-border-secondary flex items-center justify-center" style={{ color: getProviderBrandColor(provider.provider_type) }}>
<ProviderIcon type={provider.provider_type} size={20} />
</div>
<div>
<h4 className="font-semibold text-slate-800">{provider.provider_title}</h4>
<span className="text-xs text-slate-500 font-mono uppercase">{provider.provider_type}</span>
<h4 className="font-semibold text-sm text-text-primary">{provider.provider_title}</h4>
<span className="text-[10px] text-text-muted font-mono uppercase">{provider.provider_type}</span>
</div>
</div>
<span className={`flex items-center text-xs font-medium px-2 py-1 rounded-md border ${provider.status === 'Connected' ? 'bg-green-50 text-green-700 border-green-200' : 'bg-slate-50 text-slate-500 border-slate-200'}`}>
{provider.status === 'Connected' && <span className="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5"></span>}
{provider.status || 'Unknown'}
<span className={`flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-lg border ${provider.status === 'Connected' || provider.provider_status === 'up' ? 'bg-success-bg text-success border-success/20' : 'bg-bg-secondary text-text-muted border-border-primary'}`}>
{(provider.status === 'Connected' || provider.provider_status === 'up') && <span className="w-1 h-1 rounded-full bg-success" />}
{provider.provider_status === 'up' ? 'Connected' : provider.status || t('common.unknown')}
</span>
</div>
<div className="mb-4">
<p className="text-sm text-slate-600 mb-1">URL / Endpoint:</p>
<div className="bg-slate-50 border border-slate-100 rounded text-sm px-3 py-1.5 font-mono text-slate-700 truncate" title={provider.provider_url}>
{provider.provider_url || 'Default'}
<div className="bg-bg-secondary rounded-lg px-3 py-2 mb-3">
<p className="text-[10px] text-text-muted mb-0.5">{t('agent.endpoint')}</p>
<p className="text-xs font-mono text-text-secondary truncate">{provider.provider_url || t('common.default')}</p>
</div>
</div>
</div>
<div className="flex justify-end space-x-2 mt-2">
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded hover:bg-slate-50 transition-colors cursor-pointer">Edit</button>
{provider.provider_models && provider.provider_models.length > 0 && (
<div className="mb-3">
<button
onClick={async () => {
if (!confirm('Are you sure you want to delete this provider?')) return;
try {
await apiClient.delete(`/api/v1/provider/${provider.provider_title}`);
fetchProviders();
} catch (err) {
console.error('Failed to delete provider', err);
alert('Failed to delete provider');
}
}}
className="px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-slate-200 rounded hover:bg-red-50 transition-colors cursor-pointer"
onClick={() => setExpandedProvider(expandedProvider === provider.provider_title ? null : provider.provider_title)}
className="flex items-center gap-1.5 text-[11px] text-text-muted hover:text-accent transition-colors"
>
Delete
{expandedProvider === provider.provider_title ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{provider.provider_models.length} {t('agent.providerModels')}
</button>
{expandedProvider === provider.provider_title && (
<div className="mt-2 bg-bg-secondary rounded-lg p-2 max-h-32 overflow-y-auto">
{provider.provider_models.map((model: string) => (
<div key={model} className="text-[11px] font-mono text-text-secondary py-0.5 px-1.5">{model}</div>
))}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button onClick={() => openEditModal(provider)} className="px-3 py-1.5 text-xs font-medium text-text-secondary bg-bg-secondary hover:bg-bg-hover rounded-lg transition-colors border border-border-primary">{t('common.edit')}</button>
<button onClick={async () => {
if (!confirm(t('agent.deleteProviderConfirm'))) return;
try { await apiClient.delete(`/api/v1/provider/${provider.provider_title}`); fetchProviders(); } catch { alert(t('common.deleteFailed')); }
}} className="px-3 py-1.5 text-xs font-medium text-danger bg-danger-bg hover:bg-danger-bg/80 rounded-lg transition-colors border border-danger/20">{t('common.delete')}</button>
</div>
</div>
))}
</div>
)}
{/* Add Provider Modal */}
{/* Modal — 2 column layout */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Add New Provider</h3>
<button
onClick={handleCloseModal}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden border border-border-primary animate-fade-in-scale flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center px-5 py-4 border-b border-border-primary shrink-0">
<h3 className="text-base font-bold text-text-primary">
{editingProvider ? t('agent.editProvider') : t('agent.addNewProvider')}
</h3>
<button onClick={() => { setIsModalOpen(false); setEditingProvider(null); }} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100">
{error}
<form onSubmit={handleSubmit} className="flex flex-1 overflow-hidden">
{/* Left: provider type list */}
<div className="w-56 shrink-0 border-r border-border-primary bg-bg-secondary/40 overflow-y-auto p-3 space-y-1">
<div className="text-[10px] font-bold text-text-muted uppercase tracking-wider px-2 py-1.5">
{t('agent.providerType')}
</div>
{PROVIDER_TYPES.map((type) => {
const active = selectedTypeId === type.id;
return (
<button
key={type.id}
type="button"
onClick={() => handleSelectType(type.id)}
disabled={!!editingProvider}
className={`w-full flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-left transition-all ${
active
? 'bg-bg-card border border-accent/40 shadow-sm'
: 'border border-transparent hover:bg-bg-card/60'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div
className="w-8 h-8 rounded-lg bg-bg-card border border-border-primary flex items-center justify-center shrink-0"
style={{ color: getProviderBrandColor(type.iconKey) }}
>
<ProviderIcon type={type.iconKey} size={18} />
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-semibold truncate ${active ? 'text-accent' : 'text-text-primary'}`}>
{t(type.nameKey)}
</div>
<div className="text-[10px] text-text-muted truncate">{t(type.descKey)}</div>
</div>
</button>
);
})}
</div>
{/* Right: form */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{error && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>}
{testResult && (
<div className={`p-3 text-sm rounded-xl border ${testResult.success ? 'bg-success-bg text-success border-success/20' : 'bg-danger-bg text-danger border-danger/20'}`}>
{testResult.success
? `${t('agent.providerTestSuccess')} · ${testResult.model_count} ${t('agent.providerModels')}`
: `${t('agent.providerTestFailed')}: ${testResult.error}`}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Type</label>
<select
name="provider_type"
value={formData.provider_type}
onChange={handleChange}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
<option value="openai">OpenAI</option>
<option value="deepseek">DeepSeek</option>
<option value="claude">Claude</option>
<option value="local">Local</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Title</label>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('agent.providerTitle')}
</label>
<input
type="text"
name="provider_title"
placeholder="e.g. My OpenAI Instance"
value={formData.provider_title}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
onChange={(e) => setFormData({ ...formData, provider_title: e.target.value })}
placeholder={t('agent.providerTitlePlaceholder')}
disabled={!!editingProvider}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Base URL</label>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('agent.baseUrl')}
</label>
<input
type="url"
name="provider_url"
placeholder="e.g. https://api.openai.com/v1"
type="text"
value={formData.provider_url}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
onChange={(e) => setFormData({ ...formData, provider_url: e.target.value })}
placeholder={selectedType.defaultUrl || t('agent.baseUrlPlaceholder')}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono"
/>
{selectedType.defaultUrl && !editingProvider && (
<p className="text-[10px] text-text-muted mt-1">
{t('agent.baseUrlHint')}: <span className="font-mono">{selectedType.defaultUrl}</span>
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">API Key</label>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('agent.apiKey')}
</label>
<input
type="password"
name="provider_apikey"
placeholder="sk-..."
value={formData.provider_apikey}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400 font-mono"
onChange={(e) => setFormData({ ...formData, provider_apikey: e.target.value })}
placeholder={editingProvider ? t('agent.apiKeyEditPlaceholder') : t('agent.apiKeyPlaceholder')}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono"
/>
</div>
<div className="pt-4 flex justify-end space-x-3">
{/* 参数设置 — collapsible */}
<div className="border border-border-primary rounded-xl overflow-hidden">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-3.5 py-2.5 bg-bg-secondary/50 hover:bg-bg-secondary transition-colors"
>
Cancel
<div className="flex items-center gap-2 text-xs font-semibold text-text-secondary">
<SettingsIcon size={13} />
{t('agent.providerAdvanced')}
</div>
{showAdvanced ? <ChevronDown size={14} className="text-text-muted" /> : <ChevronRight size={14} className="text-text-muted" />}
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</span>
{showAdvanced && (
<div className="p-4 space-y-3 bg-bg-card">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('agent.providerCustomModels')}
</label>
<textarea
value={formData.custom_models}
onChange={(e) => setFormData({ ...formData, custom_models: e.target.value })}
placeholder={t('agent.providerCustomModelsPlaceholder')}
rows={3}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono resize-none"
/>
<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>
) : (
'Add Provider'
<p className="text-[10px] text-text-muted mt-1">{t('agent.providerModelSettingsHint')}</p>
)}
</button>
</div>
</div>
)}
</div>
</div>
</form>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-primary bg-bg-secondary/30 flex items-center justify-between shrink-0">
<button
type="button"
onClick={handleTestConnection}
disabled={testLoading}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-accent bg-accent-light hover:bg-accent/20 rounded-xl transition-colors disabled:opacity-50"
>
{testLoading ? <Loader2 size={12} className="animate-spin" /> : <Zap size={12} />}
{t('agent.testConnection')}
</button>
<div className="flex gap-2">
<button type="button" onClick={() => { setIsModalOpen(false); setEditingProvider(null); }} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{submitLoading ? t('common.saving') : editingProvider ? t('common.save') : t('agent.addProvider')}
</button>
</div>
</div>
</div>
</div>
)}
@@ -0,0 +1,319 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Server, GitBranch, ChevronDown, ChevronRight, Pause, Play } from 'lucide-react';
import apiClient from '../../api/client';
interface EventLog {
id: number;
trace_id: string;
event_type: string;
level: string;
node_name: string | null;
message: string;
metadata: Record<string, any> | null;
created_at: string | null;
}
interface WorkflowSummary {
trace_id: string;
title: string;
status: string;
created_at: string;
}
interface WorkflowStep {
name: string;
step: number;
node: string;
action: string;
status: string;
agent_id: string | null;
output?: string;
}
const LEVEL_COLORS: Record<string, string> = {
error: 'text-red-400',
warn: 'text-yellow-400',
info: 'text-green-400',
};
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
completed: { bg: 'bg-success-bg', text: 'text-success' },
failed: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]' },
working: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]' },
pending: { bg: 'bg-bg-secondary', text: 'text-text-muted' },
};
export function SystemLogsView() {
const { t } = useTranslation();
const [tab, setTab] = useState<'system' | 'workflow'>('system');
const [autoRefresh, setAutoRefresh] = useState(true);
// System logs state
const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(false);
const [traceFilter, setTraceFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const terminalRef = useRef<HTMLDivElement>(null);
// Workflow logs state
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [wfLoading, setWfLoading] = useState(false);
const [expandedStep, setExpandedStep] = useState<number | null>(null);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (traceFilter) params.set('trace_id', traceFilter);
if (typeFilter) params.set('event_type', typeFilter);
if (levelFilter) params.set('level', levelFilter);
params.set('limit', '200');
const resp = await apiClient.get(`/api/v1/system/logs?${params.toString()}`);
setLogs(resp.data.logs || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [traceFilter, typeFilter, levelFilter]);
const fetchWorkflows = async () => {
try {
const resp = await apiClient.get('/api/v1/workflow/list');
setWorkflows(resp.data.workflows || []);
} catch (err) {
console.error(err);
}
};
const fetchWorkflowDetail = async (traceId: string) => {
setWfLoading(true);
setExpandedStep(null);
try {
const resp = await apiClient.get(`/api/v1/workflow/${traceId}`);
setWorkflowSteps(resp.data.steps || []);
} catch (err) {
console.error(err);
setWorkflowSteps([]);
} finally {
setWfLoading(false);
}
};
useEffect(() => {
if (tab === 'system') fetchLogs();
else fetchWorkflows();
}, [tab, fetchLogs]);
useEffect(() => {
if (tab !== 'system' || !autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [tab, autoRefresh, fetchLogs]);
useEffect(() => {
if (selectedTrace) fetchWorkflowDetail(selectedTrace);
}, [selectedTrace]);
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [logs]);
const formatTime = (ts: string | null) => {
if (!ts) return '--:--:--';
const d = new Date(ts);
return d.toLocaleTimeString('en-GB', { hour12: false });
};
return (
<div className="max-w-6xl">
{/* Tab Switcher */}
<div className="flex items-center gap-6 mb-6">
<button
onClick={() => setTab('system')}
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'system' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
>
<Server size={14} />
{t('agent.systemLogs')}
</button>
<button
onClick={() => setTab('workflow')}
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'workflow' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
>
<GitBranch size={14} />
{t('agent.workflowLogs')}
</button>
<div className="flex-1" />
{tab === 'system' && (
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className={`p-2 rounded-lg transition-all ${autoRefresh ? 'text-accent bg-accent-light' : 'text-text-muted hover:text-accent hover:bg-accent-light'}`}
title={autoRefresh ? t('agent.logPauseRefresh') : t('agent.logResumeRefresh')}
>
{autoRefresh ? <Pause size={14} /> : <Play size={14} />}
</button>
)}
<button
onClick={() => tab === 'system' ? fetchLogs() : fetchWorkflows()}
disabled={loading || wfLoading}
className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"
>
<RefreshCw size={16} className={(loading || wfLoading) ? 'animate-spin' : ''} />
</button>
</div>
{tab === 'system' ? (
<>
{/* Filter bar */}
<div className="flex gap-3 mb-4">
<input
type="text"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder="Trace ID"
className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15 w-40"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option>
<option value="step_enter">step_enter</option>
<option value="step_complete">step_complete</option>
<option value="step_error">step_error</option>
<option value="workflow_complete">workflow_complete</option>
<option value="workflow_fail">workflow_fail</option>
<option value="system">system</option>
</select>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<button
onClick={fetchLogs}
className="px-3 py-1.5 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
{t('agent.logSearch')}
</button>
</div>
{/* Terminal-style log display */}
<div
ref={terminalRef}
className="bg-[#1a1b1e] border border-border-primary rounded-xl p-4 h-[60vh] overflow-y-auto font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<div className="text-gray-500 text-center py-8">{t('agent.noLogs')}</div>
) : (
logs.map((log) => {
const levelColor = LEVEL_COLORS[log.level] || 'text-gray-400';
return (
<div key={log.id} className="py-0.5 hover:bg-white/5 px-1 rounded">
<span className="text-gray-500">[{formatTime(log.created_at)}]</span>{' '}
<span className={`font-bold ${levelColor}`}>{log.level.toUpperCase().padEnd(5)}</span>{' '}
<span className="text-blue-300">{log.node_name || 'system'}</span>{' '}
<span className="text-gray-200">{log.message}</span>
{log.trace_id && <span className="text-gray-600 ml-2">#{log.trace_id.slice(-6)}</span>}
</div>
);
})
)}
</div>
</>
) : (
<div className="flex gap-4 h-[calc(100vh-220px)]">
{/* Workflow List */}
<div className="w-72 shrink-0 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-border-primary bg-bg-secondary">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowLogList')}</span>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border-secondary">
{workflows.length === 0 ? (
<div className="px-4 py-8 text-center text-text-muted text-xs">{t('workflow.noWorkflows')}</div>
) : (
workflows.map((wf) => (
<button
key={wf.trace_id}
onClick={() => setSelectedTrace(wf.trace_id)}
className={`w-full text-left px-4 py-3 hover:bg-bg-hover transition-colors ${selectedTrace === wf.trace_id ? 'bg-accent-light border-l-2 border-accent' : ''}`}
>
<div className="text-xs font-medium text-text-primary truncate">{wf.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).bg} ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).text}`}>
{wf.status}
</span>
<span className="text-[10px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
</div>
</button>
))
)}
</div>
</div>
{/* Workflow Steps Detail */}
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
{!selectedTrace ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
{t('agent.selectWorkflowToView')}
</div>
) : wfLoading ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
{t('common.loading')}
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{workflowSteps.length === 0 ? (
<div className="text-center text-text-muted text-xs py-8">{t('workflow.noStepsYet')}</div>
) : (
workflowSteps.map((step, idx) => {
const ss = STATUS_STYLES[step.status] || STATUS_STYLES.pending;
const isExpanded = expandedStep === idx;
return (
<div key={idx} className="border border-border-primary rounded-lg overflow-hidden">
<button
onClick={() => setExpandedStep(isExpanded ? null : idx)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-secondary/50 transition-colors text-left"
>
{step.output ? (
isExpanded ? <ChevronDown size={14} className="text-text-muted shrink-0" /> : <ChevronRight size={14} className="text-text-muted shrink-0" />
) : (
<div className="w-3.5 shrink-0" />
)}
<span className="text-xs text-text-muted w-6">{step.step || idx + 1}</span>
<span className="text-xs font-medium text-text-primary flex-1 truncate">{step.name}</span>
<span className="text-xs text-text-secondary mr-2">{step.node}</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${ss.bg} ${ss.text}`}>{step.status}</span>
</button>
{isExpanded && step.output && (
<div className="px-4 pb-3 pt-1 border-t border-border-secondary">
<div className="bg-[#1a1b1e] rounded-lg p-3 text-xs text-gray-200 font-mono whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
{step.output}
</div>
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react';
import { Save, Plus, Edit2, Trash2, X, Bot, Loader2, Users } from 'lucide-react';
import type { Provider } from '../../types';
interface WorkerIndividual {
@@ -10,83 +11,94 @@ interface WorkerIndividual {
description?: string;
provider_title: string;
model_id: string;
system_prompt?: string;
output_template?: string; // Change to string for the form state
bound_skill?: string; // Change to string for the form state
workspace?: string; // Change to string for the form state
tools?: string; // Form state for tools JSON array
persona_id?: string;
output_template?: string;
bound_skill?: string;
workspace?: string;
toolsets?: string;
}
interface PersonaTemplate {
template_id: string;
name: string;
system_prompt: string;
}
interface ToolsetItem {
toolset_id: string;
name: string;
description?: string;
tools: string[];
is_system: boolean;
category: string;
}
export function WorkerIndividualSettings() {
const { t } = useTranslation();
const [providers, setProviders] = useState<Provider[]>([]);
const [workers, setWorkers] = useState<WorkerIndividual[]>([]);
const [systemNodes, setSystemNodes] = useState<any[]>([]);
const [personaTemplates, setPersonaTemplates] = useState<PersonaTemplate[]>([]);
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [availableToolsets, setAvailableToolsets] = useState<ToolsetItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<WorkerIndividual>>({});
const [isNew, setIsNew] = useState(false);
const [modalMessage, setModalMessage] = useState('');
const [submitLoading, setSubmitLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const [provRes, workRes, sysRes, toolsRes, skillsRes] = await Promise.all([
const [provRes, workRes, sysRes, toolsetRes, skillsRes, tplRes] = await Promise.all([
apiClient.get('/api/v1/provider/list'),
apiClient.get('/api/v1/agent/worker'),
apiClient.get('/api/v1/agent'),
apiClient.get('/api/v1/resource/tool'),
apiClient.get('/api/v1/resource/skill')
apiClient.get('/api/v1/resource/custom-toolset'),
apiClient.get('/api/v1/resource/skill'),
apiClient.get('/api/v1/agent/template')
]);
setProviders(Object.values(provRes.data.provider_list || {}));
setWorkers(workRes.data.workers || []);
const allTools = toolsRes.data.tools || [];
setAvailableTools(allTools);
setAvailableToolsets(toolsetRes.data.toolsets || []);
setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
const sysNodesData = sysRes.data.system_nodes || [];
const defaultSysNodes = ['supervisory_node', 'consciousness_node', 'control_node'];
setPersonaTemplates(tplRes.data.templates || []);
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
const sysNodesData = sysRes.data.system_nodes || [];
const defaultSysNodes = ['regulatory_node', 'consciousness_node'];
const formattedSysNodes = defaultSysNodes.map(nodeName => {
setSystemNodes(defaultSysNodes.map(nodeName => {
const found = sysNodesData.find((n: any) => n.node_name === nodeName);
return {
agent_id: nodeName,
agent_name: nodeName,
agent_type: 'System Node',
provider_title: found && found.provider_title ? found.provider_title : defaultProvider,
model_id: found && found.model_id ? found.model_id : '',
tools: found && found.tools ? JSON.stringify(found.tools) : '[]',
agent_id: nodeName, agent_name: nodeName, agent_type: 'System Node',
display_name: found?.display_name || '',
provider_title: found?.provider_title || defaultProvider,
model_id: found?.model_id || '',
toolsets: found?.tools ? JSON.stringify(found.tools) : '[]',
persona_id: found?.persona_id || '',
is_system: true
};
});
setSystemNodes(formattedSysNodes);
}));
} catch (err: any) {
console.error(err);
setError('Failed to load data');
setError(t('agent.loadFailed'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
useEffect(() => { fetchData(); }, []);
const handleEdit = (worker: any) => { // Accept the backend object which might have objects instead of strings
const handleEdit = (worker: any) => {
setEditData({
...worker,
output_template: typeof worker.output_template === 'string' ? worker.output_template : JSON.stringify(worker.output_template || {}),
bound_skill: typeof worker.bound_skill === 'string' ? worker.bound_skill : JSON.stringify(worker.bound_skill || {}),
workspace: typeof worker.workspace === 'string' ? worker.workspace : JSON.stringify(worker.workspace || []),
tools: typeof worker.tools === 'string' ? worker.tools : JSON.stringify(worker.tools || [])
toolsets: typeof worker.toolsets === 'string' ? worker.toolsets : JSON.stringify(worker.toolsets || worker.tools || [])
});
setIsNew(false);
setIsEditing(true);
@@ -94,137 +106,134 @@ export function WorkerIndividualSettings() {
};
const handleAddNew = () => {
setEditData({
agent_name: '',
agent_type: 'ordinary_individual',
description: '',
provider_title: providers.length > 0 ? providers[0].provider_title : '',
model_id: '',
system_prompt: '',
output_template: '{}',
bound_skill: '{}',
workspace: '[]',
tools: '[]'
});
setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '',
provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '',
persona_id: '', output_template: '{}', bound_skill: '{}', workspace: '[]', toolsets: '[]' });
setIsNew(true);
setIsEditing(true);
setModalMessage('');
};
const handleDelete = async (agent_id: string) => {
if (!confirm('Are you sure you want to delete this agent?')) return;
try {
await apiClient.delete(`/api/v1/agent/worker/${agent_id}`);
fetchData();
} catch (err: any) {
console.error(err);
alert('Failed to delete agent');
}
if (!confirm(t('agent.deleteWorkerConfirm'))) return;
try { await apiClient.delete(`/api/v1/agent/worker/${agent_id}`); fetchData(); } catch { alert(t('common.deleteFailed')); }
};
const handleModalSave = async (e: React.FormEvent) => {
e.preventDefault();
setModalMessage('');
setSubmitLoading(true);
try {
if ((editData as any).is_system) {
const payload = {
await apiClient.post('/api/v1/agent', {
individual_name: editData.agent_name,
provider_title: editData.provider_title,
model_id: editData.model_id,
tools: JSON.parse(editData.tools || '[]')
};
await apiClient.post('/api/v1/agent', payload);
toolsets: JSON.parse(editData.toolsets || '[]'),
persona_id: (editData as any).persona_id || null,
display_name: (editData as any).display_name || null
});
} else {
const payload = {
...editData,
output_template: JSON.parse(editData.output_template || '{}'),
bound_skill: JSON.parse(editData.bound_skill || '{}'),
workspace: JSON.parse(editData.workspace || '[]'),
tools: JSON.parse(editData.tools || '[]')
toolsets: JSON.parse(editData.toolsets || '[]')
};
if (isNew) {
await apiClient.post('/api/v1/agent/worker', payload);
} else {
await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload);
if (isNew) await apiClient.post('/api/v1/agent/worker', payload);
else await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload);
}
}
setIsEditing(false);
fetchData();
} catch (err: any) {
console.error(err);
setModalMessage(err.response?.data?.detail || err.message || 'Failed to save');
setModalMessage(err.response?.data?.detail || err.message || t('common.saveFailed'));
} finally {
setSubmitLoading(false);
}
};
const getTypeBadge = (type: string, isSystem?: boolean) => {
if (isSystem) return <span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-accent-light text-accent uppercase tracking-wider">{t('agent.system')}</span>;
const colors: Record<string, string> = {
ordinary_individual: 'bg-bg-secondary text-text-muted',
skill_individual: 'bg-success-bg text-success',
special_individual: 'bg-warning-bg text-warning',
};
return <span className={`px-2 py-0.5 rounded-md text-[10px] font-medium ${colors[type] || colors.ordinary_individual}`}>{t(`agent.type.${type}`, type.replace('_', ' '))}</span>;
};
return (
<div className="max-w-5xl space-y-6 relative">
<div className="mb-8 flex justify-between items-end">
<div className="max-w-5xl space-y-6">
<div className="flex justify-between items-end">
<div>
<h1 className="text-2xl font-bold text-slate-800">Individual</h1>
<p className="text-slate-500 mt-1">Manage all system nodes and custom workers.</p>
<h1 className="text-lg font-bold text-text-primary">{t('agent.individual')}</h1>
<p className="text-sm text-text-muted mt-0.5">{t('agent.individualDesc')}</p>
</div>
<button
onClick={handleAddNew}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={16} className="mr-2" />
Add Worker
<button onClick={handleAddNew} className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
<Plus size={14} /> {t('agent.addWorker')}
</button>
</div>
{error && <div className="text-red-600">{error}</div>}
{error && <div className="text-sm text-danger bg-danger-bg border border-danger/20 rounded-xl p-3">{error}</div>}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-0">
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
{loading ? (
<div className="p-6 text-slate-500">Loading...</div>
<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>
) : (workers.length === 0 && systemNodes.length === 0) ? (
<div className="p-6 text-slate-500">No individuals found.</div>
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Users size={32} className="mb-3 opacity-40" />
<span className="text-sm">{t('agent.noIndividuals')}</span>
</div>
) : (
<table className="w-full text-left border-collapse">
<table className="w-full text-left text-sm">
<thead>
<tr className="bg-slate-50 border-b border-slate-200 text-slate-600 text-sm">
<th className="p-4 font-semibold">Name</th>
<th className="p-4 font-semibold">Type</th>
<th className="p-4 font-semibold">Provider / Model ID</th>
<th className="p-4 font-semibold text-right">Actions</th>
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider">
<th className="px-5 py-3 font-semibold">{t('agent.name')}</th>
<th className="px-5 py-3 font-semibold">{t('agent.type')}</th>
<th className="px-5 py-3 font-semibold">{t('agent.providerModel')}</th>
<th className="px-5 py-3 font-semibold text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-border-secondary">
{systemNodes.map((w) => (
<tr key={w.agent_id} className="border-b border-slate-100 bg-slate-50 hover:bg-slate-100 transition-colors">
<td className="p-4 font-medium text-slate-800">{w.agent_name}</td>
<td className="p-4 text-slate-600">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{w.agent_type}</span>
<tr key={w.agent_id} className="bg-bg-secondary/50 hover:bg-bg-hover transition-colors">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-accent-light flex items-center justify-center">
<Bot size={14} className="text-accent" />
</div>
<div className="flex flex-col">
<span className="font-medium text-text-primary text-xs">{w.display_name || w.agent_name}</span>
{w.display_name && <span className="text-[10px] text-text-muted">{w.agent_name}</span>}
</div>
</div>
</td>
<td className="p-4 text-slate-600 text-sm">
{w.provider_title} <span className="text-slate-400">/</span> {w.model_id}
</td>
<td className="p-4 text-right space-x-2">
<button onClick={() => handleEdit(w)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Edit">
<Edit2 size={16} />
</button>
<td className="px-5 py-3">{getTypeBadge(w.agent_type, true)}</td>
<td className="px-5 py-3 text-xs text-text-muted">{w.provider_title} / {w.model_id}</td>
<td className="px-5 py-3 text-right">
<button onClick={() => handleEdit(w)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
</td>
</tr>
))}
{workers.map((w) => (
<tr key={w.agent_id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="p-4 font-medium text-slate-800">{w.agent_name}</td>
<td className="p-4 text-slate-600">
<span className="px-2 py-1 bg-slate-100 rounded text-xs">{w.agent_type}</span>
<tr key={w.agent_id} className="hover:bg-bg-hover transition-colors">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center">
<Bot size={14} className="text-text-muted" />
</div>
<span className="font-medium text-text-primary text-xs">{w.agent_name}</span>
</div>
</td>
<td className="p-4 text-slate-600 text-sm">
{w.provider_title} <span className="text-slate-400">/</span> {w.model_id}
</td>
<td className="p-4 text-right space-x-2">
<button onClick={() => handleEdit(w)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Edit">
<Edit2 size={16} />
</button>
<button onClick={() => handleDelete(w.agent_id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<Trash2 size={16} />
</button>
<td className="px-5 py-3">{getTypeBadge(w.agent_type)}</td>
<td className="px-5 py-3 text-xs text-text-muted">{w.provider_title} / {w.model_id}</td>
<td className="px-5 py-3 text-right">
<button onClick={() => handleEdit(w)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all mr-0.5"><Edit2 size={14} /></button>
<button onClick={() => handleDelete(w.agent_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
</td>
</tr>
))}
@@ -232,215 +241,135 @@ export function WorkerIndividualSettings() {
</table>
)}
</div>
</div>
{/* Edit/Create Modal */}
{/* Modal */}
{isEditing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-slate-100 sticky top-0 bg-white z-10">
<h2 className="text-xl font-bold text-slate-800">
{(editData as any).is_system ? 'Edit System Node' : (isNew ? 'Create Worker' : 'Edit Worker')}
</h2>
<button onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-slate-600">
<X size={24} />
</button>
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-border-primary animate-fade-in-scale">
<div className="flex justify-between items-center p-5 border-b border-border-primary sticky top-0 bg-bg-card z-10">
<h2 className="text-base font-bold text-text-primary">{(editData as any).is_system ? t('agent.editSystemNode') : (isNew ? t('agent.createWorker') : t('agent.editWorker'))}</h2>
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleModalSave} className="p-6 space-y-4">
<form onSubmit={handleModalSave} className="p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Agent Name</label>
<input
type="text"
required
value={editData.agent_name || ''}
onChange={(e) => setEditData({...editData, agent_name: e.target.value})}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={(editData as any).is_system}
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{(editData as any).is_system ? t('agent.displayName') : t('agent.name')}</label>
<input type="text" required={!(editData as any).is_system} value={(editData as any).is_system ? ((editData as any).display_name || '') : (editData.agent_name || '')} onChange={(e) => {
if ((editData as any).is_system) setEditData({...editData, display_name: e.target.value} as any);
else setEditData({...editData, agent_name: e.target.value});
}}
placeholder={(editData as any).is_system ? editData.agent_name : ''}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Agent Type</label>
<select
value={editData.agent_type || 'ordinary_individual'}
onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={(editData as any).is_system}
>
<option value="ordinary_individual">Ordinary Individual</option>
<option value="skill_individual">Skill Individual</option>
<option value="special_individual">Special Individual</option>
{(editData as any).is_system && (
<option value="System Node">System Node</option>
)}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.type')}</label>
<select value={editData.agent_type || 'ordinary_individual'} onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={(editData as any).is_system}>
<option value="ordinary_individual">{t('agent.type.ordinary_individual')}</option>
<option value="skill_individual">{t('agent.type.skill_individual')}</option>
<option value="special_individual">{t('agent.type.special_individual')}</option>
{(editData as any).is_system && <option value="System Node">{t('agent.system')}</option>}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Provider Title</label>
<select
value={editData.provider_title || ''}
onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})}
required
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="" disabled>Select Provider</option>
{providers.map((p) => (
<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>
))}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.provider')}</label>
<select value={editData.provider_title || ''} onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>{t('common.select')}</option>
{providers.map((p) => (<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Model ID</label>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.model')}</label>
{(() => {
const selectedProvider = providers.find(p => p.provider_title === editData.provider_title);
const models = selectedProvider?.provider_models || [];
const sp = providers.find(p => p.provider_title === editData.provider_title);
const models = sp?.provider_models || [];
return (
<select
value={editData.model_id || ''}
onChange={(e) => setEditData({...editData, model_id: e.target.value})}
required
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="" disabled>Select a model</option>
<select value={editData.model_id || ''} onChange={(e) => setEditData({...editData, model_id: e.target.value})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>{t('common.select')}</option>
{models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
);
})()}
</div>
</div>
{(editData as any).is_system && (
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<select value={(editData as any).persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value || null} as any)}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="">{t('common.none')}</option>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div>
)}
{!(editData as any).is_system && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Description</label>
<textarea
value={editData.description || ''}
onChange={(e) => setEditData({...editData, description: e.target.value})}
rows={2}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.description')}</label>
<textarea value={editData.description || ''} onChange={(e) => setEditData({...editData, description: e.target.value})} rows={2}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">System Prompt</label>
<textarea
value={editData.system_prompt || ''}
onChange={(e) => setEditData({...editData, system_prompt: e.target.value})}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<select value={editData.persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>{t('agent.selectPersona')}</option>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Output Template (JSON)</label>
<textarea
value={editData.output_template || '{}'}
onChange={(e) => setEditData({...editData, output_template: e.target.value})}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.outputTemplate')}</label>
<textarea value={editData.output_template || '{}'} onChange={(e) => setEditData({...editData, output_template: e.target.value})} rows={3}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Bound Skill (Select)</label>
<select
value={(() => {
try {
const parsed = JSON.parse(editData.bound_skill || '{}');
return Object.keys(parsed)[0] || '';
} catch { return ''; }
})()}
onChange={(e) => {
const val = e.target.value;
const newSkill = val ? { [val]: [] } : {};
setEditData({...editData, bound_skill: JSON.stringify(newSkill)});
}}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={editData.agent_type !== 'skill_individual'}
>
<option value="">No Skill Bound</option>
{availableSkills.map(skill => (
<option key={skill} value={skill}>{skill}</option>
))}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.boundSkill')}</label>
<select value={(() => { try { return Object.keys(JSON.parse(editData.bound_skill || '{}'))[0] || ''; } catch { return ''; } })()}
onChange={(e) => setEditData({...editData, bound_skill: JSON.stringify(e.target.value ? { [e.target.value]: [] } : {})})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={editData.agent_type !== 'skill_individual'}>
<option value="">{t('common.none')}</option>
{availableSkills.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Workspace (JSON Array)</label>
<textarea
value={editData.workspace || '[]'}
onChange={(e) => setEditData({...editData, workspace: e.target.value})}
rows={2}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.workspace')}</label>
<textarea value={editData.workspace || '[]'} onChange={(e) => setEditData({...editData, workspace: e.target.value})} rows={2}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tools (Select Multiple)</label>
<div className="flex flex-wrap gap-2 p-4 border border-slate-200 rounded-lg max-h-48 overflow-y-auto">
{availableTools.map(tool => {
let currentTools: string[] = [];
try {
currentTools = JSON.parse(editData.tools || '[]');
} catch { currentTools = []; }
const isSelected = currentTools.includes(tool);
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.toolsets')}</label>
<div className="grid grid-cols-2 gap-2 p-3 bg-bg-input border border-border-primary rounded-xl max-h-48 overflow-y-auto">
{availableToolsets.map(ts => {
let currentToolsets: string[] = [];
try { currentToolsets = JSON.parse(editData.toolsets || '[]'); } catch { }
const isSelected = currentToolsets.includes(ts.toolset_id);
return (
<button
key={tool}
type="button"
onClick={() => {
let updatedTools = [...currentTools];
if (isSelected) {
updatedTools = updatedTools.filter(t => t !== tool);
} else {
updatedTools.push(tool);
}
setEditData({...editData, tools: JSON.stringify(updatedTools)});
<button key={ts.toolset_id} type="button" onClick={() => {
const updated = isSelected ? currentToolsets.filter(id => id !== ts.toolset_id) : [...currentToolsets, ts.toolset_id];
setEditData({...editData, toolsets: JSON.stringify(updated)});
}}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
isSelected
? 'bg-blue-100 text-blue-700 border border-blue-200'
: 'bg-slate-50 text-slate-600 border border-slate-200 hover:bg-slate-100'
}`}
>
{tool}
className={`flex flex-col items-start p-2.5 rounded-lg text-left transition-all ${isSelected ? 'bg-accent-light border border-accent/30' : 'bg-bg-secondary border border-border-primary hover:border-text-muted'}`}>
<span className="text-xs font-medium text-text-primary">{ts.name}</span>
<span className="text-[10px] text-text-muted mt-0.5">{ts.tools.length} {t('agent.toolsCount')}</span>
</button>
);
})}
{availableTools.length === 0 && (
<span className="text-sm text-slate-500">No tools available</span>
)}
{availableToolsets.length === 0 && <span className="text-xs text-text-muted col-span-2">{t('plugin.toolsetEmpty')}</span>}
</div>
</div>
{modalMessage && (
<div className="p-3 bg-red-50 text-red-700 text-sm rounded-lg">
{modalMessage}
</div>
)}
<div className="pt-4 flex justify-end space-x-3 border-t border-slate-100">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Save size={16} className="mr-2" />
Save Worker
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
<button type="submit" disabled={submitLoading} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
<Save size={14} /> {submitLoading ? t('common.saving') : t('common.save')}
</button>
</div>
</form>
@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface WorkflowConfig {
retry: {
max_attempts: number;
};
}
export function WorkflowConfigSettings() {
const { t } = useTranslation();
const [config, setConfig] = useState<WorkflowConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/system/config/workflow', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
const data = await response.json();
setConfig(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load configuration');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!config) return;
try {
setSaving(true);
setError(null);
setSuccessMessage(null);
const response = await fetch('/api/v1/system/config/workflow', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to save: ${response.statusText}`);
}
setSuccessMessage(t('agent.configSaved'));
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSaving(false);
}
};
const handleMaxAttemptsChange = (value: string) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue >= 1 && numValue <= 100) {
setConfig((prev) => prev ? {
...prev,
retry: { ...prev.retry, max_attempts: numValue }
} : null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-muted">{t('common.loading')}</div>
</div>
);
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-error">{error || 'No configuration available'}</div>
</div>
);
}
return (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text-primary mb-6">
{t('agent.workflowConfig')}
</h2>
{error && (
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 p-4 bg-success/10 border border-success/20 rounded-lg text-success text-sm">
{successMessage}
</div>
)}
<div className="bg-bg-card border border-border-primary rounded-lg p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('agent.maxRetryAttempts')}
</label>
<input
type="number"
min="1"
max="100"
value={config.retry.max_attempts}
onChange={(e) => handleMaxAttemptsChange(e.target.value)}
className="w-full px-4 py-2 bg-bg-secondary border border-border-primary rounded-lg text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="mt-2 text-xs text-text-muted">
{t('agent.maxRetryAttemptsDesc')}
</p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
onClick={loadConfig}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-text-secondary bg-bg-secondary border border-border-primary rounded-lg hover:bg-bg-hover transition-colors disabled:opacity-50"
>
{t('common.reset')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-lg hover:bg-accent-dark transition-colors disabled:opacity-50"
>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
);
}
+73 -37
View File
@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import { Activity } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
interface AuthPageProps {
onLoginSuccess: () => void;
}
export function AuthPage({ onLoginSuccess }: AuthPageProps) {
const { t } = useTranslation();
const [isLogin, setIsLogin] = useState(true);
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
@@ -20,78 +22,100 @@ export function AuthPage({ onLoginSuccess }: AuthPageProps) {
try {
if (isLogin) {
// Login
const response = await apiClient.post('/api/v1/auth/login', {
user_name: userName,
password: password,
});
if (response.data.token) {
localStorage.setItem('token', response.data.token);
onLoginSuccess();
}
} else {
// Register
const response = await apiClient.post('/api/v1/auth/register', {
user_name: userName,
password: password,
});
// After successful register, we can automatically log them in
// or just show a message and switch to login. Let's switch to login.
if (response.data.message === 'success') {
setIsLogin(true);
setError('Registration successful. Please log in.');
setError(t('auth.registerSuccess'));
}
}
} catch (err: any) {
console.error(err);
setError(err.response?.data?.detail || err.response?.data?.message || 'Authentication failed');
setError(err.response?.data?.detail || err.response?.data?.message || t('auth.authFailed'));
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen w-full items-center justify-center bg-slate-50">
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8 border border-slate-100">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center text-white mb-4 shadow-md shadow-blue-200">
<Activity size={24} />
</div>
<h2 className="text-2xl font-bold text-slate-800">
{isLogin ? 'Welcome Back' : 'Create Account'}
</h2>
<p className="text-slate-500 mt-2 text-sm">
{isLogin ? 'Enter your credentials to access your account' : 'Sign up to start using the platform'}
<div className="min-h-screen w-full bg-bg-primary flex items-center justify-center">
<div className="flex items-center gap-[70px] w-[900px]">
{/* Left: Brand */}
<div className="flex-1">
<div className="w-10 h-[3px] rounded-sm bg-gradient-to-r from-accent to-clay mb-6" />
<h1 className="text-[40px] font-bold tracking-tight leading-tight mb-3 text-text-primary">
Kilo<span className="not-italic text-accent">Star</span>
</h1>
<p className="text-[15px] text-text-muted leading-relaxed mb-8">
{t('app.tagline')}
</p>
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-2.5 text-[13px] text-text-muted">
<div className="w-[5px] h-[5px] rounded-full bg-clay" />
<span>Multi-Agent Orchestration</span>
</div>
<div className="flex items-center gap-2.5 text-[13px] text-text-muted">
<div className="w-[5px] h-[5px] rounded-full bg-clay" />
<span>Visual Pipeline Builder</span>
</div>
<div className="flex items-center gap-2.5 text-[13px] text-text-muted">
<div className="w-[5px] h-[5px] rounded-full bg-clay" />
<span>Intelligent Monitoring</span>
</div>
</div>
</div>
{/* Right: Card */}
<div className="w-[380px] bg-bg-card rounded-[20px] p-10 shadow-[0_1px_3px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.03)] border border-border-primary">
<h2 className="text-xl font-semibold text-text-primary mb-1">
{isLogin ? t('auth.welcomeBack') : t('auth.createAccount')}
</h2>
<p className="text-[13px] text-text-muted mb-7">
{isLogin ? t('auth.enterCredentials') : t('auth.signUpToStart')}
</p>
{error && (
<div className={`mb-4 p-3 rounded-lg text-sm ${error.includes('successful') ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
<div className={`mb-5 p-3 rounded-[10px] text-sm border ${error.includes('success') || error.includes('成功') ? 'bg-success-bg/50 text-success border-success/20' : 'bg-danger-bg/50 text-danger border-danger/20'}`}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-[18px]">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Username</label>
<label className="block text-xs font-medium text-text-muted mb-1.5">
{t('auth.username')}
</label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
className="w-full px-3.5 py-3 rounded-[10px] border border-border-primary bg-bg-primary text-sm text-text-primary placeholder:text-[#bbb5ae] outline-none transition-all focus:border-accent focus:shadow-[0_0_0_3px_rgba(156,175,136,0.1)]"
placeholder={t('auth.usernamePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
<label className="block text-xs font-medium text-text-muted mb-1.5">
{t('auth.password')}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
className="w-full px-3.5 py-3 rounded-[10px] border border-border-primary bg-bg-primary text-sm text-text-primary placeholder:text-[#bbb5ae] outline-none transition-all focus:border-accent focus:shadow-[0_0_0_3px_rgba(156,175,136,0.1)]"
placeholder={t('auth.passwordPlaceholder')}
required
/>
</div>
@@ -99,23 +123,35 @@ export function AuthPage({ onLoginSuccess }: AuthPageProps) {
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors disabled:opacity-50"
className="w-full py-3 rounded-[10px] bg-accent text-white text-sm font-semibold hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_6px_20px_rgba(156,175,136,0.3)] transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
>
{loading ? 'Processing...' : (isLogin ? 'Sign In' : 'Sign Up')}
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('auth.processing')}
</span>
) : (
<>
{isLogin ? t('auth.signIn') : t('auth.signUp')}
<ArrowRight size={16} />
</>
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-slate-500">
{isLogin ? "Don't have an account? " : "Already have an account? "}
<div className="flex items-center gap-3.5 my-5 text-[#bbb5ae] text-xs before:flex-1 before:h-px before:bg-border-primary after:flex-1 after:h-px after:bg-border-primary">
or
</div>
<p className="text-center text-[13px] text-text-muted">
{isLogin ? t('auth.noAccount') : t('auth.hasAccount')}{' '}
<button
onClick={() => {
setIsLogin(!isLogin);
setError('');
}}
className="text-blue-600 font-medium hover:text-blue-700 focus:outline-none"
onClick={() => { setIsLogin(!isLogin); setError(''); }}
className="text-accent font-medium hover:text-accent-hover transition-colors"
>
{isLogin ? 'Sign up' : 'Sign in'}
{isLogin ? t('auth.signUp') : t('auth.signIn')}
</button>
</p>
</div>
</div>
</div>
+190 -209
View File
@@ -1,265 +1,246 @@
import React, { useState, useEffect, useRef } from 'react';
import { MessageSquare, Activity, Terminal, ChevronRight, Plus } from 'lucide-react';
import apiClient from '../../api/client';
import type { ChatSession, Message } from '../../App';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search, Paperclip } from 'lucide-react';
import { useChatStore } from '../../store/useChatStore';
interface ChatPanelProps {
chatSessions: ChatSession[];
setChatSessions: React.Dispatch<React.SetStateAction<ChatSession[]>>;
activeSessionId: string | null;
setActiveSessionId: React.Dispatch<React.SetStateAction<string | null>>;
}
export function ChatPanel() {
const { t } = useTranslation();
export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setActiveSessionId }: ChatPanelProps) {
const quickActions = [
{ icon: Sparkles, label: t('chat.quickActions.brainstorm'), prompt: '帮我头脑风暴一些创意' },
{ icon: Code, label: t('chat.quickActions.writeCode'), prompt: '帮我写一段 Python 代码' },
{ icon: FileText, label: t('chat.quickActions.summarize'), prompt: '帮我总结这篇文档' },
{ icon: Search, label: t('chat.quickActions.search'), prompt: '帮我搜索相关资料' },
];
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'chat' | 'deploy'>('chat');
const [showPlusMenu, setShowPlusMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const plusMenuRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeSession = chatSessions.find((s) => s.id === activeSessionId) || null;
const {
sessions,
activeSessionId,
loadingMessages,
loadMessages,
createChat,
sendMessage,
} = useChatStore();
const activeSession = sessions.find((s) => s.id === activeSessionId) || null;
const messages = activeSession ? activeSession.messages : [];
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const updateSessionMessages = (newMessages: Message[]) => {
if (!activeSessionId) return;
setChatSessions((prev) =>
prev.map((s) =>
s.id === activeSessionId
? { ...s, messages: newMessages, updatedAt: Date.now() }
: s
)
);
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !activeSessionId) return;
const formData = new FormData();
formData.append('file', file);
setLoading(true);
try {
const response = await apiClient.post('/api/v1/adapter/client/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const aiMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: `已上传文件: ${response.data.filename}`,
timestamp: Date.now(),
};
updateSessionMessages([...messages, aiMessage]);
} catch (error) {
console.error('Error uploading file', error);
const errorMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: '文件上传失败。',
timestamp: Date.now(),
};
updateSessionMessages([...messages, errorMessage]);
} finally {
setLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
useEffect(() => {
if (activeSessionId) {
const session = sessions.find((s) => s.id === activeSessionId);
if (session && session.messages.length === 0) {
loadMessages(activeSessionId);
}
}
}, [activeSessionId]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (plusMenuRef.current && !plusMenuRef.current.contains(e.target as Node)) {
setShowPlusMenu(false);
}
};
if (showPlusMenu) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPlusMenu]);
const handleSendMessage = async () => {
if (!input.trim() || !activeSessionId) return;
const userText = input;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: userText,
timestamp: Date.now(),
};
updateSessionMessages([...messages, userMessage]);
if (!input.trim()) return;
const text = input.trim();
setInput('');
setLoading(true);
try {
const promptModifier = mode === 'deploy' ? '[DEPLOY TASK] ' : '';
const response = await apiClient.post('/api/v1/adapter/client', {
message: promptModifier + userMessage.content,
});
const responseData = response.data.message;
let aiContent = responseData || 'I received your message.';
// Auto-update title if it's the first user message
if (messages.length <= 1 && userText.length > 0) {
setChatSessions((prev) =>
prev.map((s) =>
s.id === activeSessionId
? { ...s, title: userText.slice(0, 20) + (userText.length > 20 ? '...' : '') }
: s
)
);
if (!activeSessionId) {
const title = text.slice(0, 20) + (text.length > 20 ? '...' : '');
await createChat(title, text);
} else {
await sendMessage(activeSessionId, text);
}
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiContent,
timestamp: Date.now(),
};
updateSessionMessages([...messages, userMessage, aiMessage]);
} catch (error) {
console.error('Error sending message', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'Sorry, I encountered an error communicating with the server.',
timestamp: Date.now(),
};
updateSessionMessages([...messages, userMessage, errorMessage]);
} finally {
setLoading(false);
const handleQuickAction = (prompt: string) => {
if (!activeSessionId) {
createChat(prompt.slice(0, 20), prompt);
} else {
sendMessage(activeSessionId, prompt);
}
};
if (!activeSessionId) {
return (
<div className="flex-1 flex flex-col bg-white overflow-hidden items-center justify-center">
<Activity size={48} className="text-slate-300 mb-4" />
<h2 className="text-xl font-semibold text-slate-600">Pretor Assistant</h2>
<p className="text-slate-400 mt-2">Select a chat history or create a new one to start.</p>
<div className="flex-1 flex flex-col items-center justify-center p-10 relative overflow-hidden">
<div className="relative z-10 flex flex-col items-center animate-fade-in-scale">
<div className="w-14 h-14 rounded-2xl bg-accent-light flex items-center justify-center text-accent mb-5">
<Activity size={24} />
</div>
<h2 className="text-lg font-semibold text-text-primary mb-1.5">{t('chat.assistantName')}</h2>
<p className="text-text-secondary text-sm mb-7 text-center max-w-sm">
{t('chat.selectChat')}
</p>
<div className="grid grid-cols-2 gap-2.5 mb-7 w-full max-w-[380px]">
{quickActions.map((action) => (
<button
onClick={() => {
const newSession: ChatSession = {
id: Date.now().toString(),
title: 'New Chat',
messages: [
{
id: Date.now().toString(),
role: 'assistant',
content: 'Hello! I am Pretor Assistant. How can I help you today?',
timestamp: Date.now(),
},
],
updatedAt: Date.now(),
};
setChatSessions([newSession, ...chatSessions]);
setActiveSessionId(newSession.id);
}}
className="mt-6 px-6 py-2 bg-blue-200 text-slate-800 rounded-xl shadow-sm hover:bg-blue-300 transition-colors"
key={action.label}
onClick={() => handleQuickAction(action.prompt)}
className="flex items-center gap-2.5 px-3.5 py-3 bg-bg-card border border-border-primary rounded-[10px] text-left hover:border-border-secondary hover:bg-bg-hover transition-all group shadow-[0_1px_2px_rgba(0,0,0,0.02)]"
>
Start New Chat
<action.icon size={14} className="text-accent flex-shrink-0" />
<span className="text-xs text-text-secondary group-hover:text-text-primary transition-colors">{action.label}</span>
</button>
))}
</div>
<div className="w-full max-w-[480px]">
<div className="flex items-end gap-2">
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder={t('chat.placeholder')}
rows={1}
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-4 py-3 focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-light),0_1px_3px_rgba(0,0,0,0.02)] transition-all text-text-primary placeholder:text-[#bbb5ae] text-[13px] resize-none min-h-[44px] max-h-[100px]"
/>
</div>
<button
onClick={handleSendMessage}
disabled={!input.trim()}
className="w-10 h-10 rounded-[10px] bg-accent text-white hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(156,175,136,0.25)] transition-all disabled:opacity-30 flex items-center justify-center flex-shrink-0"
>
<ArrowUp size={16} />
</button>
</div>
</div>
</div>
</div>
);
}
// Notice we removed the outer padded div, since App.tsx is handling that layout now
return (
<div className="flex-1 flex flex-col bg-white overflow-hidden relative">
<div className="h-14 border-b border-slate-100 bg-white flex items-center justify-between px-6 z-10 shrink-0">
<div className="flex items-center">
<MessageSquare size={18} className="text-blue-600 mr-3" />
<h1 className="font-semibold text-slate-800">{activeSession?.title || 'Chat'}</h1>
<div className="flex-1 flex flex-col bg-bg-primary overflow-hidden relative">
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="max-w-[720px] mx-auto flex flex-col gap-6">
{loadingMessages ? (
<div className="flex justify-center items-center py-12">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
<div className="flex space-x-2 bg-slate-50 p-1 rounded-lg">
<button
onClick={() => setMode('chat')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
mode === 'chat' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={msg.role === 'user' ? 'flex justify-end' : ''}>
{msg.role === 'user' ? (
<div className="max-w-[80%] px-4 py-2.5 bg-bg-card border border-border-primary rounded-2xl rounded-br-sm text-[13px] text-text-primary leading-relaxed whitespace-pre-wrap">
{msg.content}
</div>
) : (
<div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
{msg.content ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const inline = !match && !String(children).includes('\n');
if (inline) {
return <code className="px-1.5 py-0.5 bg-bg-secondary rounded text-[12px] font-mono" {...props}>{children}</code>;
}
return (
<SyntaxHighlighter
style={oneDark}
language={match?.[1] || 'text'}
PreTag="div"
customStyle={{ borderRadius: '10px', fontSize: '12px', margin: '12px 0' }}
>
Chat
</button>
<button
onClick={() => setMode('deploy')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
mode === 'deploy' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
},
}}
>
Deploy Task
</button>
</div>
</div>
{/* Chat History */}
<div className="flex-1 p-6 overflow-y-auto space-y-6 bg-white">
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role === 'assistant' && (
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
<Activity size={16} className="text-blue-600" />
</div>
)}
<div
className={`${
msg.role === 'user'
? 'bg-blue-100 text-slate-800 rounded-2xl rounded-tr-sm'
: 'bg-slate-50 border border-slate-100 text-slate-700 rounded-2xl rounded-tl-sm'
} p-4 max-w-[80%] shadow-sm`}
>
<p className="text-sm leading-relaxed mb-1 whitespace-pre-wrap">{msg.content}</p>
{typeof msg.content === 'string' && msg.content.includes('-') && msg.role === 'assistant' && (msg.content.length === 36 || msg.content.includes('任务已创建')) && (
<div className="mt-2 bg-white border border-slate-100 rounded-lg p-3 flex items-center text-sm shadow-sm">
<Terminal size={16} className="text-slate-400 mr-2" />
<span className="font-mono text-slate-600 text-xs">Task ID: {msg.content.substring(0, 36)}</span>
{msg.content}
</ReactMarkdown>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
)}
</div>
)}
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
<Activity size={16} className="text-blue-600 animate-spin" />
</div>
<div className="bg-slate-50 border border-slate-100 text-slate-700 p-4 rounded-2xl rounded-tl-sm max-w-[80%] shadow-sm">
<span className="flex space-x-1">
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-75"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-150"></span>
</span>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Chat Input */}
<div className="p-4 bg-white border-t border-slate-100 shrink-0">
<div className="relative flex items-center">
<input type="file" ref={fileInputRef} onChange={handleFileUpload} className="hidden" />
{/* Input */}
<div className="px-6 pt-3.5 pb-4.5 border-t border-border-primary bg-bg-primary/60 backdrop-blur-[8px] shrink-0">
<div className="flex items-end gap-2 max-w-[720px] mx-auto">
<input type="file" ref={fileInputRef} className="hidden" />
<div className="relative" ref={plusMenuRef}>
<button
onClick={() => fileInputRef.current?.click()}
className="absolute left-2 p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors z-10 cursor-pointer"
title="Add attachment"
onClick={() => setShowPlusMenu(!showPlusMenu)}
className="w-10 h-10 rounded-xl text-text-muted hover:text-accent hover:bg-bg-hover transition-all flex items-center justify-center flex-shrink-0"
>
<Plus size={20} />
<Plus size={18} />
</button>
<input
type="text"
{showPlusMenu && (
<div className="absolute bottom-12 left-0 bg-bg-card border border-border-primary rounded-xl shadow-lg py-1.5 min-w-[160px] z-50">
<button
onClick={() => { fileInputRef.current?.click(); setShowPlusMenu(false); }}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-xs text-text-secondary hover:bg-bg-hover hover:text-text-primary transition-colors"
>
<Paperclip size={14} />
{t('chat.addAttachment')}
</button>
</div>
)}
</div>
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Ask Pretor to do something..."
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-2xl pl-12 pr-12 py-3.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder={t('chat.placeholder')}
rows={1}
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-4 py-3 focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-light),0_1px_3px_rgba(0,0,0,0.02)] transition-all text-text-primary placeholder:text-[#bbb5ae] text-[13px] resize-none min-h-[44px] max-h-[100px]"
style={{ height: 'auto' }}
/>
</div>
<button
onClick={handleSendMessage}
disabled={loading || !input.trim()}
className="absolute right-2 p-1.5 bg-blue-200 text-slate-800 rounded-xl hover:bg-blue-300 transition-colors shadow-sm disabled:opacity-50 cursor-pointer"
disabled={!input.trim()}
className="w-10 h-10 rounded-[10px] bg-accent text-white hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(156,175,136,0.25)] transition-all disabled:opacity-30 flex items-center justify-center flex-shrink-0"
>
<ChevronRight size={18} />
<ArrowUp size={16} />
</button>
</div>
</div>

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