chore: initial commit for Pretor v0.1.0-alpha
正式发布 Pretor 平台的首个 alpha 版本。本项目旨在构建一个基于分布式架构的多智能体协同工作流水线。 核心功能实现: 1. 建立基于 BaseIndividual 的动态插件加载机制。 2. 实现三类核心 worker_individual 子个体。 3. 集成 Ray 框架支持分布式集群调度。 4. 基于 PostgreSQL 的全量持久化存储方案。 5. 提供完整的 FastAPI 后端与 React 前端交互界面。
This commit is contained in:
commit
d84212f780
|
|
@ -0,0 +1,11 @@
|
|||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
docker-compose.yml
|
||||
.env
|
||||
.env.example
|
||||
.idea
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgrespassword
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=pretor
|
||||
SECRET_KEY=114514
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.idea
|
||||
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Stage 1: Build the React frontend
|
||||
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
|
||||
|
||||
# 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)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv package manager
|
||||
RUN pip install uv
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install python dependencies without the current package (speeds up layer caching)
|
||||
RUN uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Copy the built frontend static assets from Stage 1
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
||||
|
||||
# Expose FastAPI and Ray Dashboard ports
|
||||
EXPOSE 8000 8265
|
||||
|
||||
# Start the application
|
||||
CMD ["uv", "run", "python", "main.py"]
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 zhaoxi826
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<div align="center">
|
||||
|
||||
# Pretor (执政官)
|
||||
|
||||
一款基于 Python 的分布式多 Agent 协作系统
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://docs.ray.io/)
|
||||
[](https://ai.pydantic.dev/)
|
||||
[](LICENSE)
|
||||
|
||||
[**项目架构**](./docs/ARCHITECTURE.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**未来展望**](./changelogs/ROADMAP.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
**Pretor** 是一款基于 **Ray** 构建的下一代分布式多 Agent 协作系统。项目采用“中心监管 + 边缘执行”的异构集群模式,通过大参数 MoE 模型进行高层逻辑推理,并协同微调后的轻量化模型高效完成具体任务。借助 **Pydantic-AI** 提供的强类型约束与 FastAPI 异步网关,Pretor 实现了任务从需求拆解、资源调度到自动化执行的全链路闭环,为个人提供可靠的人工智能助手服务。
|
||||
|
||||
|
||||
---
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🧠 异构协作体系
|
||||
- **多智能体集群**:内置主管 (Supervisory)、意识 (Consciousness)、控制 (Control) 三大核心节点,实现比单 Agent 系统更严谨的决策链。
|
||||
- **Worker 动态派生**:根据任务需求动态拉起 Ordinary 或 Skill 类型的 Worker Individual,实现资源的按需分配。
|
||||
|
||||
### 🚀 分布式性能保障
|
||||
- **Ray 驱动**:底层基于 Ray 构建,支持跨进程、跨机器的 Actor 通讯,轻松应对高并发任务流。
|
||||
- **本地化优先**:深度适配 **vLLM**,支持本地私有化模型部署,在保障隐私的同时大幅降低 API 调用成本。
|
||||
|
||||
### 🛠️ 工业级工程设计
|
||||
- **强类型契约**:基于 Pydantic-AI 实现 Tool 与 Agent 的接口定义,确保 AI 输出的确定性与安全性。
|
||||
- **自动化流**:内置工作流引擎 (Workflow Engine),实现从需求发现到自动化执行的闭环。
|
||||
|
||||
### 📦 Pretor 生态子项目 (Sub-projects)
|
||||
|
||||
| 项目名称 | 代号 | 功能定位 | 当前状态 |
|
||||
|:-----------------------------------------------------------|:--------| :--- | :--- |
|
||||
| **[pretor-viceroy](https://github.com/zhaoxi826/viceroy)** | **总督** | **资源管理**:负责系统 Skill 的动态安装、元数据解析与全集群分发。 | ✅ 已发布 |
|
||||
| **pretor-stardomain** | **星域** | **安全沙箱**:为 Agent 自动生成的代码提供轻量化的隔离运行环境,防止逃逸。 | 📅 规划中 |
|
||||
| **pretor-explorer** | **探索者** | **网页感知**:自动化爬虫引擎,赋予智能体实时互联网信息搜索与内容抓取能力。 | 📅 规划中 |
|
||||
| **pretor-pioneer** | **先驱者** | **知识增强**:RAG 检索增强引擎,管理私有知识库的向量化、索引与精准检索。 | 📅 规划中 |
|
||||
|
||||
---
|
||||
## 🚀 快速开始 (Quick Start)
|
||||
|
||||
> **当前版本**:`v0.1.0-alpha` (开发预览版)
|
||||
> 本项目目前处于快速迭代阶段,欢迎提交 Issue 或 Pull Request。
|
||||
|
||||
### 方式一:使用 Docker Compose (推荐)
|
||||
这是部署 **Pretor 应用** 及其配套 **PostgreSQL 数据库** 最简单、最完整的方式。
|
||||
|
||||
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
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pretor:
|
||||
image: zhaoxi5699/pretor:v0.1.0alpha
|
||||
container_name: pretor
|
||||
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=pretor
|
||||
- SECRET_KEY=changethiskey12345 # 请在生产环境中修改此密钥
|
||||
```
|
||||
|
||||
2. **启动服务**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 方式二:使用 Docker
|
||||
1. **启动服务**
|
||||
```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
|
||||
```
|
||||
|
||||
## 🔍 访问与验证
|
||||
服务启动后,可以通过以下地址进行操作:
|
||||
- Web 控制台 / API 文档: http://localhost:8000
|
||||
- Ray 任务仪表盘: http://localhost:8265
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# ChangeLog
|
||||
|
||||
---
|
||||
## [v0.1.0Alpha] - 2026/4/28
|
||||
### 更新:
|
||||
#### 🚀 新增功能 (Added)
|
||||
- **分布式 Actor 骨架**:基于 Ray 框架构建了多智能体协作底座,支持节点跨进程通讯与资源调度。
|
||||
- **全局状态机 (GSM)**:实现了 `GlobalStateMachine` 模块,作为系统的“唯一真相来源”,管理所有 Individual、Skill 和 Provider 的注册信息。
|
||||
- **核心认知节点**:
|
||||
- `SupervisoryNode`:负责任务拆解与分发。
|
||||
- `ConsciousnessNode`:负责意图识别与语义理解。
|
||||
- `ControlNode`:负责工作流状态监控与逻辑卡点。
|
||||
- **异步工作流引擎**:实现 `WorkflowRunningEngine`,支持从数据库自动轮询并异步执行待办任务流。
|
||||
- **自适应适配器**:集成 `Pydantic-AI`,并封装了统一的 `AbstractAgent` 协议,支持 OpenAI、Gemini 和 Claude 等多模型后端。
|
||||
- **基础设施代理**:建立 `PostgresDatabase` Actor,提供分布式的数据库连接池支持。
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Roadmap
|
||||
|
||||
---
|
||||
## [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设计**: 对于 **GSM(global_state_machine全局状态机)** 进行重构,实现更高的并发
|
||||
- [ ] **工具及skill优化**: 完善前端获取工具或skill的逻辑,实现对于skill或者tool的配置改写以及详细信息获取
|
||||
- [ ] **前端优化**: 完善前端设置逻辑(如:调节语言等),以及使前端更加灵活智能
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
version: v0.1
|
||||
name:
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: pretor_db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
POSTGRES_DB: pretor
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pretor:
|
||||
build: .
|
||||
container_name: pretor
|
||||
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=pretor
|
||||
- SECRET_KEY=changethiskey12345
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
## 待解决问题
|
||||
|
||||
## 问题栏
|
||||
|
||||
#### 🔴 核心缺陷与修复 (Bug Fixes & Stability)
|
||||
|
||||
#### 🛡️ 安全与合规 (Security & Auth)
|
||||
|
||||
#### ⚡ 性能与资源优化 (Performance & Scalability)
|
||||
|
||||
#### 🏗️ 架构演进 (Architecture & Refactoring)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
## Pretor项目
|
||||
|
||||
#### 简介
|
||||
**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`进行执行。完成任务后返回给用户。
|
||||
|
||||
---
|
||||
#### 技术架构背景
|
||||
|
||||
- 分布式大脑:利用 Ray 框架实现 Actor 模型,将不同的智能体节点(Node)部署为独立运行的分布式 Actor,具备跨节点通信和动态调度的能力。
|
||||
- 强类型通信协议:引入 PydanticAI 作为智能体开发框架,核心目的在于将大语言模型(LLM)产生的非结构化文本,通过 Pydantic 模型转化为强类型的结构化数据(JSON),确保多智能体协作时数据传输的工业级稳定性。
|
||||
- 推理驱动路由:系统针对最新的**deepseek-v4**系列进行了适配,实现灵活调用
|
||||
|
||||
---
|
||||
#### 项目背景
|
||||
###### 1.多智能体架构的需求
|
||||
随着任务复杂度的提升,单一**Agent**一定程度上以及满足不了人们对于人工智能完成复杂任务的需求。模仿人类社会中的团队合作,Pretor以**Ray**作为底座,从而实现一种多智能体协作的设计。
|
||||
###### 2.对于大语言模型输出内容约束的需求
|
||||
LLM 输出的非结构化文本在多智能体交互中极易崩溃。所以,**Pretor**没有选择如**LangChain**这种老牌智能体开发框架,而是选择了新兴的**pydanticAI**这种强约束框架,使得多智能体协作避免黑盒化。
|
||||
**PydanticAI**是一款基于**Pydantic**的智能体开发框架,**Pydantic**是**python**中著名的数据类型约束库,**Pydantic**官方通过**Pydantic**的强约束,实现了对于LLM的生成约束。
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "pretor-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,69 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Sidebar } from './components/Layout/Sidebar';
|
||||
import { SettingsLayout } from './components/Settings/SettingsLayout';
|
||||
import { AgentLayout } from './components/Agent/AgentLayout';
|
||||
import { ResourceLayout } from './components/Resource/ResourceLayout';
|
||||
import { LeftPanel } from './components/Chat/LeftPanel';
|
||||
import { ChatPanel } from './components/Chat/ChatPanel';
|
||||
import { RightPanel } from './components/Chat/RightPanel';
|
||||
import { AuthPage } from './components/Auth/AuthPage';
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('chats'); // For LeftPanel
|
||||
const [currentView, setCurrentView] = useState('dashboard'); // 'dashboard', 'settings', 'agent', 'resource'
|
||||
const [settingsTab, setSettingsTab] = useState('users'); // For SettingsLayout
|
||||
const [agentTab, setAgentTab] = useState('worker'); // For AgentLayout
|
||||
const [resourceTab, setResourceTab] = useState('skill'); // For ResourceLayout
|
||||
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if token exists in localStorage on mount
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage onLoginSuccess={() => setIsAuthenticated(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
|
||||
|
||||
{/* 1. Sidebar (Leftmost) */}
|
||||
<Sidebar currentView={currentView} setCurrentView={setCurrentView} />
|
||||
|
||||
{/* Main Content Area depending on view */}
|
||||
{currentView === 'agent' ? (
|
||||
<AgentLayout agentTab={agentTab} setAgentTab={setAgentTab} />
|
||||
) : currentView === 'resource' ? (
|
||||
<ResourceLayout resourceTab={resourceTab} setResourceTab={setResourceTab} />
|
||||
) : currentView === 'dashboard' ? (
|
||||
<>
|
||||
{/* 2. Left Panel - Cluster Status & Workflows/Chats */}
|
||||
<LeftPanel
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedWorkflow={selectedWorkflow}
|
||||
setSelectedWorkflow={setSelectedWorkflow}
|
||||
/>
|
||||
|
||||
{/* 3. Middle Panel - AI Chat */}
|
||||
<ChatPanel />
|
||||
|
||||
{/* 4. Right Panel - Workflow Execution Status (Only show when viewing workflows) */}
|
||||
{activeTab === 'workflows' && <RightPanel selectedWorkflow={selectedWorkflow} />}
|
||||
</>
|
||||
) : (
|
||||
/* Settings View */
|
||||
<SettingsLayout settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import axios from 'axios';
|
||||
|
||||
// The base URL should typically come from an environment variable in a real app.
|
||||
// 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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor to attach token to requests if we have one
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Interceptor to catch 401 Unauthorized errors and force login
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
// Clear token
|
||||
localStorage.removeItem('token');
|
||||
// Reload the page to force the user back to the Auth view
|
||||
window.location.reload();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,43 @@
|
|||
import { Bot, Key } from 'lucide-react';
|
||||
import { ProvidersSettings } from './ProvidersSettings';
|
||||
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
|
||||
|
||||
interface AgentLayoutProps {
|
||||
agentTab: string;
|
||||
setAgentTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export function AgentLayout({ agentTab, setAgentTab }: AgentLayoutProps) {
|
||||
return (
|
||||
<div className="flex-1 flex bg-slate-50 overflow-hidden">
|
||||
{/* Agent Inner Sidebar */}
|
||||
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Agents</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setAgentTab('worker')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${agentTab === 'worker' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<Bot size={18} className="mr-3" />
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAgentTab('providers')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${agentTab === 'providers' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<Key size={18} className="mr-3" />
|
||||
Provider Management
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{agentTab === 'worker' && <WorkerIndividualSettings />}
|
||||
{agentTab === 'providers' && <ProvidersSettings />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Box, Plus, X } from 'lucide-react';
|
||||
import type { Provider } from '../../types';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
export function ProvidersSettings() {
|
||||
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 [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers", error);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setFormData({
|
||||
provider_type: 'openai',
|
||||
provider_title: '',
|
||||
provider_url: '',
|
||||
provider_apikey: ''
|
||||
});
|
||||
setError('');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.provider_title || !formData.provider_url || !formData.provider_apikey) {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post('/api/v1/provider', formData);
|
||||
await fetchProviders();
|
||||
handleCloseModal();
|
||||
} catch (err) {
|
||||
console.error("Error adding provider", err);
|
||||
setError('Failed to add provider. Please check your inputs and try again.');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-500 py-8">Loading providers...</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>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{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 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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Provider Modal */}
|
||||
{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} />
|
||||
</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}
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Base URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="provider_url"
|
||||
placeholder="e.g. https://api.openai.com/v1"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">API Key</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end space-x-3">
|
||||
<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"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
) : (
|
||||
'Add Provider'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react';
|
||||
import type { Provider } from '../../types';
|
||||
|
||||
interface WorkerIndividual {
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
agent_type: string;
|
||||
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
|
||||
}
|
||||
|
||||
export function WorkerIndividualSettings() {
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [workers, setWorkers] = useState<WorkerIndividual[]>([]);
|
||||
const [systemNodes, setSystemNodes] = useState<any[]>([]);
|
||||
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
|
||||
const [availableTools, setAvailableTools] = useState<string[]>([]);
|
||||
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 fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [provRes, workRes, sysRes, toolsRes, skillsRes] = 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')
|
||||
]);
|
||||
setProviders(Object.values(provRes.data.provider_list || {}));
|
||||
setWorkers(workRes.data.workers || []);
|
||||
|
||||
const allTools = toolsRes.data.tools || [];
|
||||
setAvailableTools(allTools);
|
||||
setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
|
||||
|
||||
const sysNodesData = sysRes.data.system_nodes || [];
|
||||
const defaultSysNodes = ['supervisory_node', 'consciousness_node', 'control_node'];
|
||||
|
||||
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
|
||||
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
|
||||
|
||||
const formattedSysNodes = 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) : '[]',
|
||||
is_system: true
|
||||
};
|
||||
});
|
||||
setSystemNodes(formattedSysNodes);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (worker: any) => { // Accept the backend object which might have objects instead of strings
|
||||
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 || [])
|
||||
});
|
||||
setIsNew(false);
|
||||
setIsEditing(true);
|
||||
setModalMessage('');
|
||||
};
|
||||
|
||||
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: '[]'
|
||||
});
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setModalMessage('');
|
||||
try {
|
||||
if ((editData as any).is_system) {
|
||||
const payload = {
|
||||
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);
|
||||
} 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 || '[]')
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 relative">
|
||||
<div className="mb-8 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>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Worker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-6 text-slate-500">Loading...</div>
|
||||
) : (workers.length === 0 && systemNodes.length === 0) ? (
|
||||
<div className="p-6 text-slate-500">No individuals found.</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
</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-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" title="Edit">
|
||||
<Edit2 size={16} />
|
||||
</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>
|
||||
</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-indigo-600 hover:bg-indigo-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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit/Create 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>
|
||||
|
||||
<form onSubmit={handleModalSave} className="p-6 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-indigo-500"
|
||||
disabled={(editData as any).is_system}
|
||||
/>
|
||||
</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-indigo-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>
|
||||
)}
|
||||
</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-indigo-500"
|
||||
>
|
||||
<option value="" disabled>Select Provider</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>
|
||||
{(() => {
|
||||
const selectedProvider = providers.find(p => p.provider_title === editData.provider_title);
|
||||
const models = selectedProvider?.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-indigo-500"
|
||||
>
|
||||
<option value="" disabled>Select a model</option>
|
||||
{models.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</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-indigo-500"
|
||||
/>
|
||||
</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-indigo-500 font-mono text-sm"
|
||||
/>
|
||||
</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-indigo-500 font-mono text-sm"
|
||||
/>
|
||||
</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-indigo-500"
|
||||
disabled={editData.agent_type !== 'skill_individual'}
|
||||
>
|
||||
<option value="">No Skill Bound</option>
|
||||
{availableSkills.map(skill => (
|
||||
<option key={skill} value={skill}>{skill}</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-indigo-500 font-mono text-sm"
|
||||
/>
|
||||
</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);
|
||||
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)});
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
|
||||
isSelected
|
||||
? 'bg-indigo-100 text-indigo-700 border border-indigo-200'
|
||||
: 'bg-slate-50 text-slate-600 border border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{tool}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{availableTools.length === 0 && (
|
||||
<span className="text-sm text-slate-500">No tools available</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-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
Save Worker
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import React, { useState } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
interface AuthPageProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AuthPage({ onLoginSuccess }: AuthPageProps) {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [userName, setUserName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.response?.data?.detail || err.response?.data?.message || 'Authentication failed');
|
||||
} 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'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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'}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
{loading ? 'Processing...' : (isLogin ? 'Sign In' : 'Sign Up')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">
|
||||
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLogin(!isLogin);
|
||||
setError('');
|
||||
}}
|
||||
className="text-blue-600 font-medium hover:text-blue-700 focus:outline-none"
|
||||
>
|
||||
{isLogin ? 'Sign up' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import React, { useState } from 'react';
|
||||
import { MessageSquare, Activity, Terminal, ChevronRight, Plus } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
sender: 'user' | 'ai';
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: '1',
|
||||
sender: 'ai',
|
||||
text: "Hello! I am Pretor Assistant. How can I help you today?",
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) 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: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
sender: 'ai',
|
||||
text: `已上传文件: ${response.data.filename}`,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading file", error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
sender: 'ai',
|
||||
text: "文件上传失败。",
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
sender: 'user',
|
||||
text: input,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Assuming a token might be needed, apiClient should handle it if set
|
||||
const promptModifier = mode === 'deploy' ? '[DEPLOY TASK] ' : '';
|
||||
const response = await apiClient.post('/api/v1/adapter/client', {
|
||||
message: promptModifier + userMessage.text
|
||||
});
|
||||
|
||||
const aiMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
sender: 'ai',
|
||||
text: typeof response.data.message === 'string' && response.data.message.includes('-')
|
||||
? "Task has been created." // It's an event ID
|
||||
: response.data.message || "I received your message.",
|
||||
eventId: typeof response.data.message === 'string' && response.data.message.includes('-') ? response.data.message : undefined,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
|
||||
// If we got an event_id, we could potentially open a websocket to listen to its stream
|
||||
if (aiMessage.eventId) {
|
||||
console.log(`Open WS to track event: ${aiMessage.eventId}`);
|
||||
// Implement WS tracking if needed
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error sending message", error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
sender: 'ai',
|
||||
text: "Sorry, I encountered an error communicating with the server.",
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [mode, setMode] = useState<'chat' | 'deploy'>('chat');
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-slate-50">
|
||||
<div className="h-14 border-b border-slate-200 bg-white flex items-center justify-between px-6 shadow-sm z-10">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare size={18} className="text-blue-600 mr-3" />
|
||||
<h1 className="font-semibold text-slate-800">Pretor Assistant</h1>
|
||||
</div>
|
||||
<div className="flex space-x-2 bg-slate-100 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'}`}
|
||||
>
|
||||
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'}`}
|
||||
>
|
||||
Deploy Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<span className="text-xs text-slate-400 bg-slate-200/50 px-3 py-1 rounded-full">Today</span>
|
||||
</div>
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.sender === 'ai' && (
|
||||
<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.sender === 'user' ? 'bg-blue-600 text-white rounded-2xl rounded-tr-sm' : 'bg-white 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-3">{msg.text}</p>
|
||||
{msg.eventId && (
|
||||
<div className="bg-slate-50 border border-slate-100 rounded-lg p-3 flex items-center text-sm">
|
||||
<Terminal size={16} className="text-slate-400 mr-2" />
|
||||
<span className="font-mono text-slate-600 text-xs">Task ID: {msg.eventId}</span>
|
||||
</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-white 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>
|
||||
|
||||
{/* Chat Input */}
|
||||
<div className="p-4 bg-white border-t border-slate-200">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
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-xl pl-12 pr-12 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-inner"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
className="absolute right-2 p-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Server, Box, Cpu, HardDrive, List, MessageCircle } from 'lucide-react';
|
||||
import { useClusterState } from '../../hooks/useClusterState';
|
||||
import apiClient from '../../api/client';
|
||||
import type { Workflow } from '../../types';
|
||||
|
||||
interface LeftPanelProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
selectedWorkflow: string | null;
|
||||
setSelectedWorkflow: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function LeftPanel({ activeTab, setActiveTab, selectedWorkflow, setSelectedWorkflow }: LeftPanelProps) {
|
||||
const { nodes } = useClusterState();
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
|
||||
|
||||
const totalNodes = nodes.length;
|
||||
const aliveNodes = nodes.filter(n => n.alive).length;
|
||||
|
||||
let totalCpu = 0;
|
||||
let usedCpu = 0;
|
||||
let totalMemory = 0;
|
||||
let usedMemory = 0;
|
||||
|
||||
nodes.forEach(node => {
|
||||
const nodeTotalCpu = node.resources?.CPU || 0;
|
||||
const nodeRemainingCpu = node.remaining?.CPU || 0;
|
||||
totalCpu += nodeTotalCpu;
|
||||
usedCpu += (nodeTotalCpu - nodeRemainingCpu);
|
||||
|
||||
const nodeTotalMem = node.resources?.memory || 0;
|
||||
const nodeRemainingMem = node.remaining?.memory || 0;
|
||||
totalMemory += nodeTotalMem;
|
||||
usedMemory += (nodeTotalMem - nodeRemainingMem);
|
||||
});
|
||||
|
||||
const cpuPercent = totalCpu > 0 ? (usedCpu / totalCpu) * 100 : 0;
|
||||
const memPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0;
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
const fetchWorkflows = async (isInitial = false) => {
|
||||
if (isInitial) setLoadingWorkflows(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/workflow/list');
|
||||
// Fallback parsing just in case it returns an object or array
|
||||
const data = response.data;
|
||||
let parsedWorkflows: Workflow[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
parsedWorkflows = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
// Suppose backend sends { workflows: [...] }
|
||||
parsedWorkflows = data.workflows || Object.values(data);
|
||||
}
|
||||
setWorkflows(parsedWorkflows);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch workflows", error);
|
||||
setWorkflows([]);
|
||||
} finally {
|
||||
if (isInitial) setLoadingWorkflows(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (activeTab === 'workflows') {
|
||||
fetchWorkflows(true);
|
||||
intervalId = setInterval(() => fetchWorkflows(false), 2000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-white border-r border-slate-200 flex flex-col z-0 shrink-0">
|
||||
{/* Top: Cluster Status */}
|
||||
<div className="h-1/3 p-4 border-b border-slate-100 flex flex-col">
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-4 flex items-center">
|
||||
<Server size={16} className="mr-2" />
|
||||
Cluster Status
|
||||
</h2>
|
||||
<div className="space-y-4 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center text-slate-600">
|
||||
<Box size={16} className="mr-2 text-blue-500" />
|
||||
<span className="text-sm">Active Nodes</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-800">{aliveNodes} / {totalNodes || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center text-slate-600">
|
||||
<Cpu size={16} className="mr-2 text-indigo-500" />
|
||||
<span className="text-sm">Cluster CPU</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-800">{cpuPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-1.5">
|
||||
<div className="bg-indigo-500 h-1.5 rounded-full" style={{ width: `${cpuPercent}%` }}></div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center text-slate-600">
|
||||
<HardDrive size={16} className="mr-2 text-green-500" />
|
||||
<span className="text-sm">Cluster RAM</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-800">
|
||||
{(totalMemory > 0 ? usedMemory / (1024 ** 3) : 0).toFixed(1)} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-1.5">
|
||||
<div className="bg-green-500 h-1.5 rounded-full" style={{ width: `${memPercent}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Tabs for Workflows & Basic Chats */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-slate-100">
|
||||
<button
|
||||
onClick={() => setActiveTab('chats')}
|
||||
className={`flex-1 py-3 text-xs font-medium text-center uppercase tracking-wider transition-colors ${activeTab === 'chats' ? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>
|
||||
<MessageCircle size={14} className="inline mr-1.5 -mt-0.5" />
|
||||
Chats
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('workflows')}
|
||||
className={`flex-1 py-3 text-xs font-medium text-center uppercase tracking-wider transition-colors ${activeTab === 'workflows' ? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>
|
||||
<List size={14} className="inline mr-1.5 -mt-0.5" />
|
||||
Workflows
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
{activeTab === 'workflows' && (
|
||||
<div className="space-y-2">
|
||||
{loadingWorkflows ? (
|
||||
<div className="text-center text-slate-400 text-sm py-4">Loading workflows...</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className="text-center text-slate-400 text-sm py-4">暂无工作流</div>
|
||||
) : (
|
||||
workflows.map((wf) => (
|
||||
<div
|
||||
key={wf.event_id}
|
||||
onClick={() => setSelectedWorkflow(wf.event_id)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedWorkflow === wf.event_id ? 'border-blue-200 bg-blue-50 shadow-sm' : 'border-slate-100 hover:border-blue-200 hover:bg-slate-50'}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`font-medium text-sm ${selectedWorkflow === wf.event_id ? 'text-blue-700' : 'text-slate-700'}`}>{wf.workflow_title || 'Unnamed Workflow'}</span>
|
||||
<span className={`flex h-2 w-2 rounded-full ${wf.status === 'llm_working' || wf.status === 'tool_working' ? 'bg-green-400 animate-pulse' : wf.status === 'failed' ? 'bg-red-400' : 'bg-slate-300'}`}></span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-mono line-clamp-1">ID: {wf.event_id}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'chats' && (
|
||||
<div className="space-y-2">
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Terminal, Activity, RefreshCw, CheckCircle2, Circle, XCircle, Clock, Loader2 } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
import type { WorkflowDetail, WorkflowStep } from '../../types';
|
||||
|
||||
interface RightPanelProps {
|
||||
selectedWorkflow: string | null;
|
||||
}
|
||||
|
||||
function stepStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={14} className="text-green-500" />;
|
||||
case 'running':
|
||||
return <Loader2 size={14} className="text-blue-500 animate-spin" />;
|
||||
case 'failed':
|
||||
return <XCircle size={14} className="text-red-500" />;
|
||||
default:
|
||||
return <Circle size={14} className="text-slate-300" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [sseConnected, setSseConnected] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const fetchDetail = async (traceId: string) => {
|
||||
setLoading(true);
|
||||
setLogs([]);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/workflow/${traceId}`);
|
||||
setDetail(response.data);
|
||||
} catch {
|
||||
setDetail(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedWorkflow) {
|
||||
setDetail(null);
|
||||
setLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDetail(selectedWorkflow);
|
||||
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || `${protocol}//${host}`;
|
||||
const es = new EventSource(`${apiBase}/api/v1/workflow/sse/${selectedWorkflow}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => {
|
||||
setSseConnected(true);
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
setLogs(prev => [...prev, event.data]);
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setSseConnected(false);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchDetail(selectedWorkflow);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [selectedWorkflow]);
|
||||
|
||||
const isActive = detail?.status === 'llm_working' || detail?.status === 'tool_working';
|
||||
|
||||
if (!selectedWorkflow) {
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 flex flex-col z-0 justify-center items-center p-6 text-center">
|
||||
<Activity size={32} className="text-slate-300 mb-4" />
|
||||
<h3 className="text-sm font-semibold text-slate-600">No Workflow Selected</h3>
|
||||
<p className="text-xs text-slate-400 mt-2">Select a workflow from the left panel to view its details.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 flex flex-col z-0">
|
||||
<div className="h-14 border-b border-slate-100 flex items-center px-4 justify-between bg-slate-50/50">
|
||||
<h2 className="font-semibold text-slate-800 text-sm flex items-center">
|
||||
<Terminal size={16} className="mr-2 text-slate-500" />
|
||||
Workflow Detail
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-md font-medium border ${sseConnected ? 'bg-green-100 text-green-700 border-green-200' : 'bg-slate-100 text-slate-500 border-slate-200'}`}>
|
||||
{sseConnected ? 'Live' : '--'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => selectedWorkflow && fetchDetail(selectedWorkflow)}
|
||||
className="p-1 text-slate-400 hover:text-blue-600 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
{loading && !detail ? (
|
||||
<div className="text-center text-slate-400 text-sm py-8">
|
||||
<Loader2 size={24} className="animate-spin mx-auto mb-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className="text-center text-slate-400 text-sm py-8">Failed to load workflow details</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-bold text-slate-800">
|
||||
{detail.workflow_title || 'Workflow'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 font-mono mt-1">ID: {detail.event_id}</p>
|
||||
{detail.command && (
|
||||
<p className="text-xs text-slate-500 mt-1 truncate">Command: {detail.command}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
detail.status === 'failed' ? 'bg-red-100 text-red-700' :
|
||||
isActive ? 'bg-blue-100 text-blue-700' :
|
||||
detail.status === 'waiting_llm_working' || detail.status === 'waiting_tool_working' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{detail.status}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
Step {detail.current_step}/{detail.steps.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{detail.steps.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Steps</h4>
|
||||
<div className="space-y-1.5">
|
||||
{detail.steps.map((step: WorkflowStep) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs border ${
|
||||
step.step === detail.current_step && isActive
|
||||
? 'border-blue-200 bg-blue-50'
|
||||
: step.status === 'completed'
|
||||
? 'border-green-100 bg-green-50/50'
|
||||
: step.status === 'failed'
|
||||
? 'border-red-100 bg-red-50/50'
|
||||
: 'border-slate-100 bg-white'
|
||||
}`}
|
||||
>
|
||||
{stepStatusIcon(step.status)}
|
||||
<span className="font-medium text-slate-700 w-5 text-right">{step.step}</span>
|
||||
<span className="text-slate-600 truncate flex-1">{step.name}</span>
|
||||
<span className="text-slate-400 text-[10px]">{step.node}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.steps.length === 0 && (
|
||||
<div className="text-center py-4">
|
||||
<Clock size={24} className="text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-xs text-slate-400">Workflow is being generated...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSE Logs */}
|
||||
{logs.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Live Logs</h4>
|
||||
<div className="relative border-l-2 border-slate-200 ml-3 pl-5 space-y-3">
|
||||
{logs.map((msg, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
<div className={`absolute -left-[27px] top-1 w-3 h-3 rounded-full border-2 border-white shadow-sm ${idx === logs.length - 1 && sseConnected ? 'bg-blue-500 animate-pulse' : 'bg-green-500'}`} />
|
||||
<p className="text-[11px] font-mono text-slate-600 leading-relaxed break-all">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length === 0 && sseConnected && isActive && (
|
||||
<div className="text-xs text-slate-400 italic mt-2">Waiting for live events...</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
import { Activity, MessageSquare, Settings, Bot, Box } from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: string;
|
||||
setCurrentView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ currentView, setCurrentView }: SidebarProps) {
|
||||
return (
|
||||
<div className="w-12 bg-white border-r border-slate-200 flex flex-col items-center py-4 space-y-6 shadow-sm z-10 shrink-0">
|
||||
<div
|
||||
className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-md shadow-blue-200 cursor-pointer hover:bg-blue-700 transition-colors"
|
||||
onClick={() => setCurrentView('dashboard')}
|
||||
>
|
||||
<Activity size={18} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 flex-1 mt-8">
|
||||
<button
|
||||
onClick={() => setCurrentView('dashboard')}
|
||||
className={`p-1.5 rounded-lg transition-colors ${currentView === 'dashboard' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
|
||||
title="Chat"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentView('agent')}
|
||||
className={`p-1.5 rounded-lg transition-colors ${currentView === 'agent' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
|
||||
title="Agents"
|
||||
>
|
||||
<Bot size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentView('resource')}
|
||||
className={`p-1.5 rounded-lg transition-colors ${currentView === 'resource' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
|
||||
title="Resources"
|
||||
>
|
||||
<Box size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentView('settings')}
|
||||
className={`p-1.5 rounded-lg transition-colors ${currentView === 'settings' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Wrench, Database, FileCode } from 'lucide-react';
|
||||
import { SkillSettings } from './SkillSettings';
|
||||
import { ToolSettings } from './ToolSettings';
|
||||
import { WorkflowTemplateSettings } from './WorkflowTemplateSettings';
|
||||
|
||||
interface ResourceLayoutProps {
|
||||
resourceTab: string;
|
||||
setResourceTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export function ResourceLayout({ resourceTab, setResourceTab }: ResourceLayoutProps) {
|
||||
return (
|
||||
<div className="flex-1 flex bg-slate-50 overflow-hidden">
|
||||
{/* Resource Inner Sidebar */}
|
||||
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Resources</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setResourceTab('skill')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${resourceTab === 'skill' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<Wrench size={18} className="mr-3" />
|
||||
Skills
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResourceTab('workflow_template')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${resourceTab === 'workflow_template' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<FileCode size={18} className="mr-3" />
|
||||
Workflow Templates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResourceTab('tool')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${resourceTab === 'tool' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<Database size={18} className="mr-3" />
|
||||
Tools
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{resourceTab === 'skill' && <SkillSettings />}
|
||||
{resourceTab === 'workflow_template' && <WorkflowTemplateSettings />}
|
||||
{resourceTab === 'tool' && <ToolSettings />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { Download, Trash2, Plus, Box } from 'lucide-react';
|
||||
|
||||
export function SkillSettings() {
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchSkills = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/resource/skill');
|
||||
const skillsData = response.data.skills || {};
|
||||
// skillsData might be an object mapping skill names to their details, or it might be an array in some versions.
|
||||
// We ensure it is an array of strings (skill names)
|
||||
if (Array.isArray(skillsData)) {
|
||||
setSkills(skillsData);
|
||||
} else {
|
||||
setSkills(Object.keys(skillsData));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch skills:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills();
|
||||
}, []);
|
||||
|
||||
const handleInstall = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!repoUrl) return;
|
||||
|
||||
setInstalling(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/v1/resource/skill', {
|
||||
repo_url: repoUrl,
|
||||
path: path || null
|
||||
});
|
||||
setMessage('Skill installed successfully');
|
||||
setRepoUrl('');
|
||||
setPath('');
|
||||
fetchSkills();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.response?.data?.message || 'Failed to install skill');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (skillName: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${skillName}?`)) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/resource/skill/${skillName}`);
|
||||
fetchSkills();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete skill:', err);
|
||||
alert('Failed to delete skill');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Skill Management</h1>
|
||||
<p className="text-slate-500 mt-1">Manage agent skills and functions.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Download size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Install Skill</h2>
|
||||
<p className="text-sm text-slate-500">Install a new skill from a repository.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleInstall} className="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">Repository URL</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/user/repo"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Path (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="e.g. subfolder/path"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && <div className="text-green-600 text-sm">{message}</div>}
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={installing}
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus size={16} className="mr-2" />
|
||||
{installing ? 'Installing...' : 'Install'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Installed Skills</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-slate-500 text-sm">Loading skills...</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="text-slate-500 text-sm">No skills installed yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<div key={skill} className="p-4 border border-slate-200 rounded-xl flex items-center justify-between hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
<Box size={16} />
|
||||
</div>
|
||||
<span className="font-medium text-slate-800">{skill}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(skill)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete Skill"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
export function ToolSettings() {
|
||||
const [tools, setTools] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTools();
|
||||
}, []);
|
||||
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get('/api/v1/resource/tool');
|
||||
const toolsData = response.data.tools || [];
|
||||
setTools(toolsData);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tools:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-slate-800">Installed Tools</h3>
|
||||
<p className="text-slate-500 mt-1">Manage agent tools and functions.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-800">Available Tools</h4>
|
||||
<p className="text-sm text-slate-500">List of installed tools available for agents.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-slate-500 text-sm">Loading tools...</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="text-slate-500 text-sm">No tools installed yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{tools.map((tool) => (
|
||||
<div key={tool} className="p-4 border border-slate-200 rounded-xl flex items-center justify-between hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center mr-3">
|
||||
<Package size={20} className="text-purple-600" />
|
||||
</div>
|
||||
<span className="font-medium text-slate-800">{tool}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { FileCode, Trash2, Plus, LayoutTemplate } from 'lucide-react';
|
||||
|
||||
import type { WorkflowTemplate as ParsedWorkflowTemplate } from '../../types';
|
||||
|
||||
export function WorkflowTemplateSettings() {
|
||||
const [templates, setTemplates] = useState<Record<string, ParsedWorkflowTemplate>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [templateJson, setTemplateJson] = useState('{\n "name": "my_template",\n "steps": [\n {\n "name": "step1",\n "actor": "actor_name"\n }\n ]\n}');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const validateTemplate = (data: any): data is ParsedWorkflowTemplate => {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
if (typeof data.name !== 'string') return false;
|
||||
if (!Array.isArray(data.steps)) return false;
|
||||
for (const step of data.steps) {
|
||||
if (typeof step.name !== 'string') return false;
|
||||
if (typeof step.actor !== 'string') return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/resource/workflow_template');
|
||||
setTemplates(response.data.templates || {});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch templates:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const parsedJson = JSON.parse(templateJson);
|
||||
|
||||
if (!validateTemplate(parsedJson)) {
|
||||
throw new Error('JSON structure does not match WorkflowTemplate schema (requires name and steps array with name and actor).');
|
||||
}
|
||||
|
||||
await apiClient.post('/api/v1/resource/workflow_template', parsedJson);
|
||||
setMessage('Workflow template created successfully');
|
||||
setTemplateJson('{\n "name": "my_template",\n "steps": []\n}');
|
||||
fetchTemplates();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
if (err instanceof SyntaxError) {
|
||||
setError('Invalid JSON format');
|
||||
} else {
|
||||
setError(err.message || err.response?.data?.message || 'Failed to create workflow template');
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (templateName: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${templateName}?`)) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/resource/workflow_template/${templateName}`);
|
||||
fetchTemplates();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete template:', err);
|
||||
alert('Failed to delete template');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Workflow Templates</h1>
|
||||
<p className="text-slate-500 mt-1">Manage and create reusable workflow templates.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<FileCode size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Create Template</h2>
|
||||
<p className="text-sm text-slate-500">Provide the JSON definition for a new workflow template.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Template JSON Definition</label>
|
||||
<textarea
|
||||
required
|
||||
rows={8}
|
||||
value={templateJson}
|
||||
onChange={(e) => setTemplateJson(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && <div className="text-green-600 text-sm">{message}</div>}
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus size={16} className="mr-2" />
|
||||
{creating ? 'Creating...' : 'Create Template'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Available Templates</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-slate-500 text-sm">Loading templates...</div>
|
||||
) : Object.keys(templates).length === 0 ? (
|
||||
<div className="text-slate-500 text-sm">No workflow templates created yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.keys(templates).map((name) => (
|
||||
<div key={name} className="p-4 border border-slate-200 rounded-xl flex items-center justify-between hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
<LayoutTemplate size={16} />
|
||||
</div>
|
||||
<span className="font-medium text-slate-800">{name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(name)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete Template"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
|
||||
import { Users, Sliders } from 'lucide-react';
|
||||
import { UsersSettings } from './UsersSettings';
|
||||
import { SystemSettings } from './SystemSettings';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
settingsTab: string;
|
||||
setSettingsTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsLayout({ settingsTab, setSettingsTab }: SettingsLayoutProps) {
|
||||
return (
|
||||
<div className="flex-1 flex bg-slate-50 overflow-hidden">
|
||||
{/* Settings Inner Sidebar */}
|
||||
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Settings</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setSettingsTab('users')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'users' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<Users size={18} className="mr-3" />
|
||||
User Management
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettingsTab('system')}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'system' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
||||
>
|
||||
<Sliders size={18} className="mr-3" />
|
||||
System Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{settingsTab === 'users' && <UsersSettings />}
|
||||
{settingsTab === 'system' && <SystemSettings />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Globe, Server, Save } from 'lucide-react';
|
||||
|
||||
export function SystemSettings() {
|
||||
const [language, setLanguage] = useState(localStorage.getItem('language') || 'English');
|
||||
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'Light');
|
||||
const [debugMode, setDebugMode] = useState(true);
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem('language', language);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Apply theme
|
||||
if (theme === 'Dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// In a real app, you would dispatch a language change event or context update here
|
||||
alert(`Settings saved!\nLanguage: ${language}\nTheme: ${theme}`);
|
||||
};
|
||||
|
||||
// Initialize theme on mount if needed
|
||||
useEffect(() => {
|
||||
if (theme === 'Dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-slate-800">System Settings</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">Global platform configurations.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
<h4 className="text-sm font-semibold text-slate-800 mb-4 flex items-center">
|
||||
<Globe size={16} className="mr-2 text-slate-500" />
|
||||
General
|
||||
</h4>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">System Language</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
||||
>
|
||||
<option value="English">English</option>
|
||||
<option value="简体中文">简体中文</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Theme</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
||||
>
|
||||
<option value="Light">Light</option>
|
||||
<option value="Dark">Dark</option>
|
||||
<option value="System Default">System Default</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
<h4 className="text-sm font-semibold text-slate-800 mb-4 flex items-center">
|
||||
<Server size={16} className="mr-2 text-slate-500" />
|
||||
Cluster & Runtime
|
||||
</h4>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="flex items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="debug_mode"
|
||||
checked={debugMode}
|
||||
onChange={(e) => setDebugMode(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="debug_mode" className="ml-2 block text-sm text-slate-700">
|
||||
Enable debug logging
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Plus, Edit2, Trash2, X } from 'lucide-react';
|
||||
import type { User } from '../../types';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
export function UsersSettings() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [editRole, setEditRole] = useState('User');
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/auth/list');
|
||||
setUsers(response.data.users || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setFormData({ username: '', password: '' });
|
||||
setError('');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.username || !formData.password) {
|
||||
setError('Please fill in both username and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/register', {
|
||||
user_name: formData.username,
|
||||
password: formData.password
|
||||
});
|
||||
await fetchUsers();
|
||||
handleCloseModal();
|
||||
} catch (err) {
|
||||
console.error("Failed to register user", err);
|
||||
setError('Registration failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string | undefined) => {
|
||||
if (!userId) return;
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/auth/${userId}`);
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete user", err);
|
||||
alert("Failed to delete user");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setEditRole(user.role || 'User');
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingUser?.user_id) return;
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
await apiClient.put('/api/v1/auth/authority', {
|
||||
user_id: editingUser.user_id,
|
||||
new_authority: editRole
|
||||
});
|
||||
await fetchUsers();
|
||||
setIsEditModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to update user role", err);
|
||||
alert("Failed to update user role");
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-slate-800">User Management</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">Manage system users and their roles.</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 User
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">Username</th>
|
||||
<th className="px-6 py-4 font-medium">Role</th>
|
||||
<th className="px-6 py-4 font-medium">Status</th>
|
||||
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-800">{user.user_name}</td>
|
||||
<td className="px-6 py-4 text-slate-600">{user.role || 'User'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${user.status === 'Active' ? 'bg-green-100 text-green-700 border border-green-200' : 'bg-slate-100 text-slate-600 border border-slate-200'}`}>
|
||||
{user.status || 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => handleEditClick(user)} className="text-slate-400 hover:text-blue-600 mr-3 transition-colors cursor-pointer" title="Edit"><Edit2 size={16} /></button>
|
||||
<button onClick={() => handleDeleteUser(user.user_id)} className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer" title="Delete"><Trash2 size={16} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add User Modal */}
|
||||
{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 User</h3>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={20} />
|
||||
</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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="e.g. jsmith"
|
||||
value={formData.username}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter secure password"
|
||||
value={formData.password}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end space-x-3">
|
||||
<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"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
Creating...
|
||||
</span>
|
||||
) : (
|
||||
'Add User'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit User Role Modal */}
|
||||
{isEditModalOpen && editingUser && (
|
||||
<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">Edit User Role</h3>
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEditSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={editingUser.user_name}
|
||||
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Role</label>
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
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 cursor-pointer"
|
||||
>
|
||||
<option value="User">User</option>
|
||||
<option value="Administrator">Administrator</option>
|
||||
<option value="SuperAdministrator">SuperAdministrator</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</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 ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { ClusterNode } from '../types';
|
||||
|
||||
export function useClusterState() {
|
||||
const [nodes, setNodes] = useState<ClusterNode[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout>;
|
||||
let retryCount = 0;
|
||||
const maxRetryCount = 10;
|
||||
const baseDelay = 1000;
|
||||
|
||||
const connect = () => {
|
||||
// Determine WS URL based on API base URL or window location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
|
||||
const wsBase = import.meta.env.VITE_API_BASE_URL
|
||||
? import.meta.env.VITE_API_BASE_URL.replace(/^http/, 'ws')
|
||||
: `${protocol}//${host}`;
|
||||
|
||||
ws = new WebSocket(`${wsBase}/api/v1/cluster/ws/state`);
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
retryCount = 0; // Reset retry count on successful connection
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (Array.isArray(data)) {
|
||||
setNodes(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing cluster state websocket message", e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
if (retryCount < maxRetryCount) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
retryCount++;
|
||||
console.log(`WebSocket closed. Reconnecting in ${delay}ms... (Attempt ${retryCount})`);
|
||||
reconnectTimeout = setTimeout(connect, delay);
|
||||
} else {
|
||||
console.error("Max WebSocket reconnect attempts reached.");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimeout);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { nodes, isConnected };
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// General types
|
||||
|
||||
export interface TokenData {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
user_id?: string;
|
||||
user_name: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// Provider types
|
||||
export interface Provider {
|
||||
provider_type: 'openai' | 'claude' | 'local' | 'deepseek';
|
||||
provider_title: string;
|
||||
provider_url?: string;
|
||||
provider_owner?: string;
|
||||
provider_models?: string[];
|
||||
// Based on your UI needs we might infer some local status fields
|
||||
status?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ProviderRegisterRequest {
|
||||
provider_type: 'openai' | 'claude' | 'local' | 'deepseek';
|
||||
provider_title: string;
|
||||
provider_url: string;
|
||||
provider_apikey: string;
|
||||
}
|
||||
|
||||
export interface ProviderListResponse {
|
||||
provider_list: Record<string, Provider>;
|
||||
}
|
||||
|
||||
// Cluster types (Websocket response)
|
||||
export interface ClusterResources {
|
||||
CPU?: number;
|
||||
memory?: number;
|
||||
GPU?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
export interface ClusterNode {
|
||||
node_id: string;
|
||||
node_name: string;
|
||||
alive: boolean;
|
||||
resources: ClusterResources;
|
||||
remaining: ClusterResources;
|
||||
}
|
||||
|
||||
// Chat types
|
||||
export interface ChatMessageRequest {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageResponse {
|
||||
message: string; // Either event_id or text
|
||||
}
|
||||
|
||||
// Workflow types
|
||||
export interface Workflow {
|
||||
event_id: string;
|
||||
workflow_title: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
step: number;
|
||||
name: string;
|
||||
node: string;
|
||||
action: string;
|
||||
desc: string;
|
||||
status: string;
|
||||
agent_id?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowDetail {
|
||||
event_id: string;
|
||||
workflow_title: string | null;
|
||||
status: string;
|
||||
command?: string;
|
||||
current_step: number;
|
||||
user_name: string;
|
||||
message: string;
|
||||
create_time: string;
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
// Workflow Template Validation
|
||||
export interface WorkStep {
|
||||
name: string;
|
||||
desc?: string;
|
||||
actor: string; // the name of the worker individual
|
||||
inputs?: string[];
|
||||
outputs?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowTemplate {
|
||||
name: string;
|
||||
desc?: string;
|
||||
steps: WorkStep[];
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import asyncio
|
||||
import ray
|
||||
from pretor.worker_individual.worker_cluster import WorkerCluster
|
||||
from pretor.utils.banner import print_banner
|
||||
from pretor.core.database.postgres import PostgresDatabase
|
||||
from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine
|
||||
from pretor.core.individual.supervisory_node.supervisory_node import SupervisoryNode
|
||||
from pretor.core.individual.consciousness_node.consciousness_node import ConsciousnessNode
|
||||
from pretor.core.individual.control_node.control_node import ControlNode
|
||||
from pretor.core.workflow.workflow_runner import WorkflowRunningEngine
|
||||
from pretor.core.api import PretorGateway
|
||||
from ray import serve
|
||||
import os
|
||||
|
||||
|
||||
|
||||
async def start_system():
|
||||
env_vars = {
|
||||
"POSTGRES_USER": os.getenv("POSTGRES_USER", "postgres"),
|
||||
"POSTGRES_PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
|
||||
"POSTGRES_HOST": os.getenv("POSTGRES_HOST", "db"),
|
||||
"POSTGRES_PORT": os.getenv("POSTGRES_PORT", "5432"),
|
||||
"POSTGRES_DB": os.getenv("POSTGRES_DB", "postgres"),
|
||||
"SECRET_KEY": os.getenv("SECRET_KEY", "secret"),
|
||||
}
|
||||
|
||||
ray.init(ignore_reinit_error=True,
|
||||
namespace="pretor",
|
||||
dashboard_host="0.0.0.0",
|
||||
dashboard_port=8265,
|
||||
runtime_env={"env_vars": env_vars})
|
||||
|
||||
|
||||
# 2. 启动数据库组件
|
||||
postgres_database = PostgresDatabase.options(name='postgres_database').remote()
|
||||
await postgres_database.init_db.remote()
|
||||
|
||||
global_state_machine = GlobalStateMachine.options(
|
||||
name='global_state_machine',
|
||||
namespace='pretor',
|
||||
lifetime='detached'
|
||||
).remote(postgres_database)
|
||||
|
||||
print("正在等待 GlobalStateMachine 初始化并加载注册表...")
|
||||
try:
|
||||
# 强制执行初始化方法并阻塞等待结果。
|
||||
# 如果 __init__ 或 init_state_machine 中有任何报错,会立刻在这里抛出!
|
||||
await global_state_machine.init_state_machine.remote()
|
||||
print("GlobalStateMachine 初始化成功!")
|
||||
except Exception as e:
|
||||
print(f"\n[致命错误] GlobalStateMachine 启动失败!真实报错如下:\n{e}\n")
|
||||
return
|
||||
|
||||
# 4. 启动核心节点
|
||||
supervisory_node = SupervisoryNode.options(name='supervisory_node').remote()
|
||||
consciousness_node = ConsciousnessNode.options(name='consciousness_node').remote()
|
||||
control_node = ControlNode.options(name='control_node').remote()
|
||||
|
||||
try:
|
||||
worker_cluster_actor = WorkerCluster.options(
|
||||
name="worker_cluster",
|
||||
lifetime="detached" # 保证它在后台一直运行
|
||||
).remote()
|
||||
print("✅ WorkerCluster 已成功启动并注册!")
|
||||
except ValueError:
|
||||
print("WorkerCluster 已经存在。")
|
||||
# 5. 启动工作流运行引擎
|
||||
workflow_engine = WorkflowRunningEngine.options(name='workflow_running_engine').remote(
|
||||
consciousness_node=consciousness_node,
|
||||
control_node=control_node,
|
||||
supervisory_node=supervisory_node
|
||||
)
|
||||
# 异步拉起 runner 协程群
|
||||
workflow_engine.run.remote()
|
||||
|
||||
# 6. 启动 FastAPI 网关 (使用 Ray Serve)
|
||||
serve.start(http_options={"host": "0.0.0.0", "port": 8000})
|
||||
serve.run(PretorGateway.bind())
|
||||
|
||||
# 挂起主线程以保持系统运行
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
def main():
|
||||
print_banner()
|
||||
try:
|
||||
asyncio.run(start_system())
|
||||
except KeyboardInterrupt:
|
||||
print("系统已退出。")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.models.anthropic import AnthropicModel
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||
from pretor.adapter.model_adapter.deepseek_reasoner import DeepSeekReasonerAgent
|
||||
from pretor.core.global_state_machine.model_provider import Provider
|
||||
from pretor.utils.agent_model import ResponseModel, DepsModel
|
||||
from pretor.utils.error import ModelNotExistError
|
||||
|
||||
class AgentFactory:
|
||||
def __init__(self):
|
||||
self._models_mapping = {"openai": (OpenAIChatModel, OpenAIProvider),
|
||||
"claude": (AnthropicModel, AnthropicProvider),
|
||||
"deepseek": (OpenAIChatModel, OpenAIProvider),}
|
||||
|
||||
def create_agent(self,
|
||||
provider: Provider,
|
||||
model_id: str,
|
||||
output_type: ResponseModel,
|
||||
system_prompt: str,
|
||||
deps_type: DepsModel,
|
||||
agent_name: str,
|
||||
tools: list = None) -> Agent:
|
||||
"""
|
||||
create_agent方法,将输入的provider对象实例化为一个pydantic-ai的agent对象
|
||||
|
||||
Args:
|
||||
provider: Provider对象,从global_state_machine中获取
|
||||
model_id: 模型名
|
||||
output_type: 输出格式
|
||||
system_prompt: 系统提示词
|
||||
deps_type: 依赖类型,在agent运行时动态输入的格式化消息
|
||||
agent_name: agent的名字
|
||||
tools: 工具列表
|
||||
|
||||
Returns:
|
||||
返回被实例化的pydantic-ai的Agent对象
|
||||
"""
|
||||
if model_id not in provider.provider_models:
|
||||
raise ModelNotExistError("模型不存在")
|
||||
if provider.provider_type not in self._models_mapping:
|
||||
raise ValueError(f"不支持的协议类型: {provider.provider_type}")
|
||||
model_class, provider_class = self._models_mapping[provider.provider_type]
|
||||
model = model_class(model_id, provider=provider_class(api_key=provider.provider_apikey, base_url=provider.provider_url))
|
||||
match provider.provider_type:
|
||||
case "deepseek":
|
||||
agent = DeepSeekReasonerAgent(model=model,
|
||||
name=agent_name,
|
||||
output_type=output_type,
|
||||
deps_type=deps_type,
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
retries=3,
|
||||
)
|
||||
case _:
|
||||
agent = Agent(model=model,
|
||||
name=agent_name,
|
||||
system_prompt=system_prompt,
|
||||
output_type=output_type,
|
||||
deps_type=deps_type,
|
||||
tools=tools)
|
||||
return agent
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import re
|
||||
import json
|
||||
from typing import Type, TypeVar, Any, Generic
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.run import AgentRunResult
|
||||
|
||||
T = TypeVar('T', bound=BaseModel)
|
||||
|
||||
class AgentRunResultProxy:
|
||||
def __init__(self, original, parsed):
|
||||
self._original = original
|
||||
self._parsed = parsed
|
||||
def __getattr__(self, name):
|
||||
if name == 'data':
|
||||
return self._parsed
|
||||
if name == 'output':
|
||||
return self._parsed
|
||||
return getattr(self._original, name)
|
||||
|
||||
class DeepSeekReasonerAgent(Generic[T]):
|
||||
"""
|
||||
专为 DeepSeek-V4/R1 设计的适配器。
|
||||
将结构化输出降级为文本解析模式,并支持重试逻辑以确保系统兼容性。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model,
|
||||
name,
|
||||
output_type: Any = str,
|
||||
system_prompt: str = "",
|
||||
deps_type: Type[Any] = None,
|
||||
tools: list = None,
|
||||
retries: int = 3,
|
||||
**kwargs
|
||||
):
|
||||
self.output_schema = output_type
|
||||
self.has_custom_output = output_type is not str and output_type is not None
|
||||
self.tools = tools or []
|
||||
self.retries = retries
|
||||
|
||||
format_instruction = ""
|
||||
if self.has_custom_output:
|
||||
try:
|
||||
from pydantic import TypeAdapter
|
||||
schema_dict = TypeAdapter(self.output_schema).json_schema()
|
||||
schema_str = json.dumps(schema_dict, ensure_ascii=False)
|
||||
format_instruction = (
|
||||
f"\n\nCRITICAL: 你必须输出且只能输出一段纯 JSON 格式的数据,"
|
||||
f"并包裹在 ```json 和 ``` 之间。格式必须符合以下 JSON Schema 结构(或对应数据类型):\n"
|
||||
f"{schema_str}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tool_instruction = ""
|
||||
if self.tools:
|
||||
tool_descs = []
|
||||
for t in self.tools:
|
||||
desc = getattr(t, '__name__', str(t))
|
||||
if hasattr(t, '__doc__') and t.__doc__:
|
||||
desc += f": {t.__doc__.strip()}"
|
||||
tool_descs.append(f"- {desc}")
|
||||
tool_instruction = (
|
||||
"\n\n系统为您提供了以下工具。由于当前处于结构化降级模式,无法原生调用。"
|
||||
"但如果您在思考过程中判断必须使用这些工具,请在返回的结构中(或如果是自由文本)注明意图,由外层逻辑进行调度:\n" +
|
||||
"\n".join(tool_descs)
|
||||
)
|
||||
|
||||
self.agent = Agent(
|
||||
model=model,
|
||||
name=name,
|
||||
output_type=str, # Force native agent to return str to disable function calling
|
||||
system_prompt=system_prompt + format_instruction + tool_instruction,
|
||||
deps_type=deps_type,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def _parse_output(self, text: str) -> Any:
|
||||
if not self.has_custom_output:
|
||||
return text
|
||||
|
||||
match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
|
||||
json_str = match.group(1).strip() if match else text
|
||||
|
||||
if not json_str.startswith('{') and not json_str.startswith('['):
|
||||
start_obj = json_str.find('{')
|
||||
start_arr = json_str.find('[')
|
||||
start = -1
|
||||
end = -1
|
||||
if start_obj != -1 and (start_arr == -1 or start_obj < start_arr):
|
||||
start = start_obj
|
||||
end = json_str.rfind('}')
|
||||
elif start_arr != -1:
|
||||
start = start_arr
|
||||
end = json_str.rfind(']')
|
||||
|
||||
if start != -1 and end != -1 and end > start:
|
||||
json_str = json_str[start:end+1]
|
||||
|
||||
if not json_str:
|
||||
raise ValueError("未找到有效的 JSON 块。请将结果包装在 ```json 中。")
|
||||
|
||||
try:
|
||||
from pydantic import TypeAdapter
|
||||
adapter = TypeAdapter(self.output_schema)
|
||||
return adapter.validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"返回的 JSON 无法匹配所需结构:{e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"返回的不是合法的 JSON:{e}")
|
||||
|
||||
|
||||
def __getattr__(self, item):
|
||||
# Delegate any unknown attributes (like .system_prompt, .tool) to the underlying pydantic_ai Agent
|
||||
return getattr(self.agent, item)
|
||||
|
||||
async def run(self, user_prompt: str, deps: Any = None, message_history: list = None, **kwargs) -> Any:
|
||||
# Custom retry loop
|
||||
current_history = message_history or []
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(self.retries + 1):
|
||||
result = await self.agent.run(
|
||||
user_prompt,
|
||||
deps=deps,
|
||||
message_history=current_history,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
raw_text = result.data if hasattr(result, 'data') else getattr(result, 'output', str(result))
|
||||
|
||||
try:
|
||||
parsed_data = self._parse_output(raw_text)
|
||||
|
||||
# Proxy the result to inject the parsed data seamlessly
|
||||
return AgentRunResultProxy(result, parsed_data)
|
||||
|
||||
except ValueError as e:
|
||||
last_exception = e
|
||||
# Prepare retry prompt
|
||||
user_prompt = f"你的上一次输出解析失败,错误原因是: {e}\n请修正格式后重新输出。"
|
||||
|
||||
# We need to maintain history manually so the model sees what it did wrong
|
||||
# Actually, pydantic-ai manages history inside the result. Let's use the all_messages from result
|
||||
if hasattr(result, 'all_messages'):
|
||||
current_history = result.all_messages()
|
||||
|
||||
raise ValueError(f"Exceeded maximum retries ({self.retries}) for output validation. Last error: {last_exception}")
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from typing import Union
|
||||
from pretor.utils.ray_hook import ray_actor_hook
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from pretor.utils.access import Accessor, TokenData
|
||||
from pretor.core.database.table.individual import AgentType
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, List, Dict
|
||||
from pretor.utils.check_user.role_check import RoleChecker
|
||||
from pretor.core.database.table.user import UserAuthority
|
||||
|
||||
agent_router = APIRouter(prefix="/api/v1/agent", tags=["agent"])
|
||||
|
||||
class AgentRegister(BaseModel):
|
||||
provider_title: str
|
||||
model_id: str
|
||||
individual_name: str
|
||||
tools: Optional[List[str]] = None
|
||||
|
||||
class AgentLocalRegister(BaseModel):
|
||||
path: str
|
||||
individual_name: str
|
||||
tools: Optional[List[str]] = None
|
||||
|
||||
@agent_router.get("")
|
||||
async def get_system_nodes(_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
configs = await postgres_database.get_all_system_node_configs.remote()
|
||||
return {"system_nodes": configs}
|
||||
|
||||
@agent_router.post("")
|
||||
async def load_agent(agent_register: Union[AgentRegister, AgentLocalRegister],
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
|
||||
if isinstance(agent_register, AgentLocalRegister):
|
||||
pass
|
||||
|
||||
elif isinstance(agent_register, AgentRegister):
|
||||
try:
|
||||
# Persist configuration
|
||||
await postgres_database.upsert_system_node_config.remote(
|
||||
agent_register.individual_name,
|
||||
agent_register.provider_title,
|
||||
agent_register.model_id,
|
||||
agent_register.tools
|
||||
)
|
||||
|
||||
# Load agent into state machine
|
||||
match agent_register.individual_name:
|
||||
case "supervisory_node":
|
||||
node = ray_actor_hook("supervisory_node").supervisory_node
|
||||
await node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id, agent_register.tools)
|
||||
case "consciousness_node":
|
||||
node = ray_actor_hook("consciousness_node").consciousness_node
|
||||
await node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id, agent_register.tools)
|
||||
case "control_node":
|
||||
node = ray_actor_hook("control_node").control_node
|
||||
await node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id, agent_register.tools)
|
||||
case _:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"加载节点失败: {str(e)}")
|
||||
return {"message": "创建成功"}
|
||||
|
||||
|
||||
class WorkerIndividualCreate(BaseModel):
|
||||
agent_name: str
|
||||
agent_type: AgentType
|
||||
description: str
|
||||
provider_title: str
|
||||
model_id: str
|
||||
system_prompt: str
|
||||
output_template: dict
|
||||
bound_skill: Dict[str, List[str]]
|
||||
workspace: List[str]
|
||||
tools: Optional[List[str]] = None
|
||||
|
||||
|
||||
class WorkerIndividualUpdate(BaseModel):
|
||||
agent_name: Optional[str] = None
|
||||
agent_type: Optional[AgentType] = None
|
||||
description: Optional[str] = None
|
||||
provider_title: Optional[str] = None
|
||||
model_id: Optional[str] = None
|
||||
system_prompt: Optional[str] = None
|
||||
output_template: Optional[dict] = None
|
||||
bound_skill: Optional[Dict[str, List[str]]] = None
|
||||
workspace: Optional[List[str]] = None
|
||||
tools: Optional[List[str]] = None
|
||||
|
||||
|
||||
@agent_router.post("/worker")
|
||||
async def create_worker_individual(worker_data: WorkerIndividualCreate,
|
||||
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
data_dict = worker_data.model_dump()
|
||||
data_dict["owner_id"] = token_data.user_id
|
||||
worker = await postgres_database.add_worker_individual.remote( **data_dict)
|
||||
return {"message": "success", "agent_id": worker.agent_id}
|
||||
|
||||
|
||||
@agent_router.get("/worker")
|
||||
async def get_worker_individual_list(token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
workers = await postgres_database.get_worker_individual_list.remote( owner_id=token_data.user_id)
|
||||
return {"workers": workers}
|
||||
|
||||
|
||||
@agent_router.get("/worker/{agent_id}")
|
||||
async def get_worker_individual(agent_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
worker = await postgres_database.get_worker_individual.remote( agent_id=agent_id)
|
||||
if not worker:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if worker.owner_id != token_data.user_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden: You do not own this agent")
|
||||
return worker
|
||||
|
||||
|
||||
@agent_router.put("/worker/{agent_id}")
|
||||
async def update_worker_individual(agent_id: str,
|
||||
worker_data: WorkerIndividualUpdate,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
worker = await postgres_database.get_worker_individual.remote( agent_id=agent_id)
|
||||
if not worker:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if worker.owner_id != token_data.user_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden: You do not own this agent")
|
||||
|
||||
update_data = worker_data.model_dump(exclude_unset=True)
|
||||
updated_worker = await postgres_database.update_worker_individual.remote( agent_id=agent_id, **update_data)
|
||||
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
try:
|
||||
await global_state_machine.remove_individual.remote(agent_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"message": "success", "worker": updated_worker}
|
||||
|
||||
@agent_router.post("/worker/{agent_id}/reload")
|
||||
async def reload_worker_individual(agent_id: str, token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
worker = await postgres_database.get_worker_individual.remote(agent_id=agent_id)
|
||||
if not worker:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if worker.owner_id != token_data.user_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden: You do not own this agent")
|
||||
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
await global_state_machine.remove_individual.remote(agent_id)
|
||||
|
||||
return {"message": "Worker will be reloaded on next use"}
|
||||
|
||||
|
||||
@agent_router.delete("/worker/{agent_id}")
|
||||
async def delete_worker_individual(agent_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
worker = await postgres_database.get_worker_individual.remote( agent_id=agent_id)
|
||||
if not worker:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if worker.owner_id != token_data.user_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden: You do not own this agent")
|
||||
await postgres_database.delete_worker_individual.remote( agent_id=agent_id)
|
||||
return {"message": "success"}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel
|
||||
from pretor.utils.access import Accessor, TokenData
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pretor.utils.ray_hook import ray_actor_hook
|
||||
from pretor.utils.check_user.role_check import RoleChecker
|
||||
from pretor.core.database.table.user import UserAuthority
|
||||
from pretor.utils.error import UserNotExistError
|
||||
|
||||
auth_router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
user_name: str
|
||||
password: str
|
||||
|
||||
@auth_router.post("/register")
|
||||
async def create_user(user_register: UserRegister):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
hashed_password = await run_in_threadpool(Accessor.hash_password, user_register.password)
|
||||
user = await postgres_database.add_user.remote( user_register.user_name, hashed_password)
|
||||
return {"message": "success", "user_id": user.user_id}
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
user_name: str
|
||||
password: str
|
||||
|
||||
@auth_router.post("/login")
|
||||
async def login_user(user_login: UserLogin):
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
user = await postgres_database.login_user.remote( user_login.user_name)
|
||||
if not user:
|
||||
raise UserNotExistError()
|
||||
token = await run_in_threadpool(Accessor.login_hashed_password, user, user_login.password)
|
||||
return {"message":"success", "token":token}
|
||||
|
||||
class ChangeAuthorityRequest(BaseModel):
|
||||
user_id: str
|
||||
new_authority: UserAuthority
|
||||
|
||||
@auth_router.put("/authority")
|
||||
async def change_authority(
|
||||
request: ChangeAuthorityRequest,
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))
|
||||
):
|
||||
"""
|
||||
Update a user's authority level. Only accessible by SUPER_ADMINISTRATOR.
|
||||
"""
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
user = await postgres_database.change_user_authority.remote( user_id=request.user_id, new_authority=request.new_authority)
|
||||
return {"message": "success", "user_id": user.user_id, "new_authority": user.user_authority}
|
||||
|
||||
@auth_router.get("/list")
|
||||
async def get_user_list(
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))
|
||||
):
|
||||
"""
|
||||
Get a list of all users. Only accessible by SUPER_ADMINISTRATOR.
|
||||
"""
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
users = await postgres_database.get_all_users.remote()
|
||||
return {"users": [{"user_id": u.user_id, "user_name": u.user_name, "role": u.user_authority} for u in users]}
|
||||
|
||||
@auth_router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))
|
||||
):
|
||||
"""
|
||||
Delete a user. Only accessible by SUPER_ADMINISTRATOR.
|
||||
"""
|
||||
postgres_database = ray_actor_hook("postgres_database").postgres_database
|
||||
await postgres_database.delete_user_by_id.remote( user_id=user_id)
|
||||
return {"message": "success"}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
cluster_router = APIRouter(prefix="/api/v1/cluster", tags=["cluster"])
|
||||
|
||||
# Monitor websocket API temporarily removed
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from .frontend import client_router
|
||||
__all__ = ["client_router"]
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import datetime
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from ulid import ULID
|
||||
from typing import Any, Dict
|
||||
from pretor.core.workflow.workflow import PretorWorkflow
|
||||
import asyncio
|
||||
|
||||
class PretorEvent(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
trace_id: str = Field(default_factory=lambda: str(ULID()), description="事件的唯一标识符")
|
||||
platform: str = Field(description="消息来源的平台")
|
||||
user_id: str = Field(description="用户id")
|
||||
user_name: str = Field(description="用户名")
|
||||
create_time: str = Field(default_factory=lambda: str(datetime.datetime.now(datetime.timezone.utc).isoformat()),
|
||||
description="事件创建时间")
|
||||
message: str = Field(description="用户发来的消息")
|
||||
attachment: Dict[str, str] | None = Field(default=None,description="附件")
|
||||
#--------------------------------------------------------------------------------------------------------------
|
||||
context: Dict[str, Any] = Field(default_factory=dict, description="事件上下文内容,可包含工作流模板等信息")
|
||||
workflow: PretorWorkflow | None = Field(default=None,description="工作流")
|
||||
pending_queue: asyncio.Queue[str] | None= Field(default=None,description="待处理队列")
|
||||
receive_queue: asyncio.Queue[str] | None = Field(default=None,description="待接收队列")
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from pretor.utils.access import Accessor, TokenData
|
||||
from pretor.api.platform.event import PretorEvent
|
||||
from pretor.utils.ray_hook import ray_actor_hook
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from pretor.utils.logger import get_logger
|
||||
logger = get_logger('frontend')
|
||||
client_router = APIRouter(prefix="/api/v1/adapter/client", tags=["client"])
|
||||
|
||||
class Message(BaseModel):
|
||||
message: str
|
||||
|
||||
@client_router.post("")
|
||||
async def create_message(message: Message,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
logger.info("收到消息,来源:客户端")
|
||||
logger.debug(f"消息内容:{message.message}")
|
||||
event = PretorEvent(platform="client",
|
||||
user_id=str(token_data.user_id),
|
||||
user_name=token_data.username,
|
||||
message=message.message)
|
||||
supervisory_node = ray_actor_hook("supervisory_node").supervisory_node
|
||||
message = await supervisory_node.working.remote(event)
|
||||
if message == "任务已创建":
|
||||
return {"message": event.trace_id}
|
||||
elif message == "未知相应类型":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="模型回复错误")
|
||||
else:
|
||||
return {"message": message}
|
||||
|
||||
@client_router.post("/upload")
|
||||
async def upload_file(file: UploadFile = File(...),
|
||||
token_data: TokenData = Depends(Accessor.get_current_user)):
|
||||
try:
|
||||
upload_dir = "uploads"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
file_path = os.path.join(upload_dir, file.filename)
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
logger.info(f"用户 {token_data.username} 上传了文件: {file.filename}")
|
||||
return {"filename": file.filename, "message": f"File {file.filename} uploaded successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"文件上传失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="文件上传失败")
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
from pretor.utils.access import TokenData, Accessor
|
||||
from pretor.utils.check_user.role_check import RoleChecker
|
||||
from pretor.core.database.table.user import UserAuthority
|
||||
from typing import Dict
|
||||
from pretor.core.global_state_machine.model_provider.base_provider import Provider
|
||||
from pretor.utils.ray_hook import ray_actor_hook
|
||||
|
||||
provider_router = APIRouter(prefix="/api/v1/provider", tags=["provider"])
|
||||
|
||||
class ProviderRegister(BaseModel):
|
||||
provider_type: Literal["openai", "claude", "deepseek"]
|
||||
provider_title: str
|
||||
provider_url: str
|
||||
provider_apikey: str
|
||||
|
||||
@provider_router.post("")
|
||||
async def create_provider(provider_register: ProviderRegister,
|
||||
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))) -> None:
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
await global_state_machine.add_provider_wrap.remote(provider_type=provider_register.provider_type,
|
||||
provider_title=provider_register.provider_title,
|
||||
provider_url=provider_register.provider_url,
|
||||
provider_apikey=provider_register.provider_apikey,
|
||||
provider_owner=token_data.user_id)
|
||||
|
||||
|
||||
@provider_router.get("/list")
|
||||
async def get_provider_list(_: TokenData = Depends(Accessor.get_current_user)) -> Dict[str, Dict[str, Provider]]:
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
provider_list: Dict[str, Provider] = await global_state_machine.get_provider_list.remote()
|
||||
return {"provider_list": provider_list}
|
||||
|
||||
@provider_router.delete("/{provider_title}")
|
||||
async def delete_provider(provider_title: str, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))) -> dict:
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
await global_state_machine.delete_provider.remote(provider_title=provider_title)
|
||||
return {"message": "success"}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pydantic import BaseModel
|
||||
import viceroy
|
||||
from pretor.core.workflow.workflow_template_generator.workflow_template import WorkflowTemplate
|
||||
from pretor.utils.ray_hook import ray_actor_hook
|
||||
from fastapi import APIRouter, Depends
|
||||
from pretor.utils.access import TokenData
|
||||
from pretor.utils.check_user.role_check import RoleChecker
|
||||
from pretor.core.database.table.user import UserAuthority
|
||||
|
||||
resource_router = APIRouter(prefix="/api/v1/resource")
|
||||
|
||||
@resource_router.post("/workflow_template")
|
||||
async def create_workflow_template(workflow_template: WorkflowTemplate,
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
await global_state_machine.add_workflow_template.remote( workflow_template.name, workflow_template)
|
||||
return {"message": "创建成功"}
|
||||
|
||||
@resource_router.get("/workflow_template")
|
||||
async def get_workflow_templates(_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
templates = await global_state_machine.get_all_workflow_templates.remote()
|
||||
return {"templates": templates}
|
||||
|
||||
@resource_router.delete("/workflow_template/{template_name}")
|
||||
async def delete_workflow_template(template_name: str, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
await global_state_machine.delete_workflow_template.remote( template_name)
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
|
||||
class Skill(BaseModel):
|
||||
repo_url: str
|
||||
path: str | None
|
||||
|
||||
@resource_router.post("/skill")
|
||||
async def install_skill(skill: Skill,
|
||||
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
# noinspection PyUnresolvedReferences
|
||||
import os
|
||||
skill_output_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "plugin", "skill"))
|
||||
os.makedirs(skill_output_dir, exist_ok=True)
|
||||
await viceroy.install_skill_async(url = skill.repo_url,
|
||||
path = skill.path,
|
||||
output = skill_output_dir)
|
||||
if skill.path:
|
||||
skill_name = skill.path.split("/")[-1]
|
||||
else:
|
||||
skill_name = skill.repo_url.split("/")[-1]
|
||||
await global_state_machine.add_skill.remote( skill_name)
|
||||
return {"message": "创建成功"}
|
||||
|
||||
@resource_router.get("/skill")
|
||||
async def get_skills(_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
skills = await global_state_machine.get_skill_list.remote()
|
||||
return {"skills": skills}
|
||||
|
||||
@resource_router.delete("/skill/{skill_name}")
|
||||
async def delete_skill(skill_name: str, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
# Note: this only removes it from the state machine manager.
|
||||
await global_state_machine.remove_skill.remote( skill_name)
|
||||
return {"message": "success"}
|
||||
|
||||
@resource_router.get("/tool")
|
||||
async def get_tools(_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
tool_mapper = await global_state_machine.get_tool_mapper.remote()
|
||||
all_tool_names = set()
|
||||
for scope_tools in tool_mapper.values():
|
||||
all_tool_names.update(scope_tools.keys())
|
||||
return {"tools": list(all_tool_names)}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from pretor.utils.ray_hook import ray_actor_hook
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncio
|
||||
|
||||
workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"])
|
||||
|
||||
@workflow_router.get("/list")
|
||||
async def get_workflow_list():
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
events = await global_state_machine.list_events.remote()
|
||||
return events
|
||||
|
||||
|
||||
@workflow_router.get("/{trace_id}")
|
||||
async def get_workflow_detail(trace_id: str):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
event = await global_state_machine.get_event.remote(trace_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
workflow = event.workflow
|
||||
if not workflow:
|
||||
return {
|
||||
"event_id": trace_id,
|
||||
"workflow_title": None,
|
||||
"status": "waiting",
|
||||
"user_name": event.user_name,
|
||||
"message": event.message,
|
||||
"create_time": event.create_time,
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
steps = []
|
||||
for step in workflow.work_link:
|
||||
steps.append({
|
||||
"step": step.step,
|
||||
"name": step.name,
|
||||
"node": step.node,
|
||||
"action": step.action,
|
||||
"desc": step.desc,
|
||||
"status": step.status,
|
||||
"agent_id": step.agent_id,
|
||||
})
|
||||
return {
|
||||
"event_id": trace_id,
|
||||
"workflow_title": workflow.title,
|
||||
"status": workflow.status.status,
|
||||
"command": workflow.command,
|
||||
"current_step": workflow.status.step,
|
||||
"user_name": event.user_name,
|
||||
"message": event.message,
|
||||
"create_time": event.create_time,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
@workflow_router.get("/sse/{trace_id}")
|
||||
async def get_workflow_sse(trace_id: str, request: Request):
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
# You might also want to send the workflow state periodically or when updated
|
||||
# Here we just wait for pending messages and send them
|
||||
message = await global_state_machine.get_pending.remote(trace_id)
|
||||
# Ensure the message is formatted as SSE
|
||||
yield f"data: {message}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
@workflow_router.post("/reply/{trace_id}")
|
||||
async def post_workflow_reply(trace_id: str, request: Request):
|
||||
data = await request.json()
|
||||
reply_msg = data.get("message", "")
|
||||
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
|
||||
await global_state_machine.put_received.remote(trace_id, reply_msg)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
from typing import Dict
|
||||
from fastapi import FastAPI, WebSocket, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from pretor.api.platform.frontend import client_router
|
||||
from pretor.api.auth import auth_router
|
||||
from pretor.api.provider import provider_router
|
||||
from pretor.api.resource import resource_router
|
||||
from pretor.api.cluster import cluster_router
|
||||
from pretor.api.agent import agent_router
|
||||
from pretor.utils.error import (
|
||||
DemandError, ModelNotExistError, UserError,
|
||||
UserNotExistError, UserPasswordError, ProviderError,
|
||||
ProviderNotExistError, WorkflowError, WorkflowExit
|
||||
)
|
||||
|
||||
from ray import serve
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(client_router) # 客户端路径
|
||||
app.include_router(auth_router) # 用户路径
|
||||
app.include_router(provider_router) # 供应商路径
|
||||
app.include_router(resource_router) # 资源路径
|
||||
app.include_router(cluster_router) # 集群信息路径
|
||||
app.include_router(agent_router) # agent路径
|
||||
|
||||
@app.exception_handler(UserNotExistError)
|
||||
async def user_not_exist_handler(request: Request, exc: UserNotExistError):
|
||||
return JSONResponse(status_code=404, content={"message": "用户不存在"})
|
||||
|
||||
|
||||
@app.exception_handler(UserPasswordError)
|
||||
async def user_password_handler(request: Request, exc: UserPasswordError):
|
||||
return JSONResponse(status_code=401, content={"message": "密码错误"})
|
||||
|
||||
|
||||
@app.exception_handler(UserError)
|
||||
async def user_error_handler(request: Request, exc: UserError):
|
||||
return JSONResponse(status_code=400, content={"message": "用户相关错误"})
|
||||
|
||||
|
||||
@app.exception_handler(ProviderNotExistError)
|
||||
async def provider_not_exist_handler(request: Request, exc: ProviderNotExistError):
|
||||
return JSONResponse(status_code=404, content={"message": "服务提供商不存在"})
|
||||
|
||||
|
||||
@app.exception_handler(ProviderError)
|
||||
async def provider_error_handler(request: Request, exc: ProviderError):
|
||||
return JSONResponse(status_code=400, content={"message": "服务提供商错误"})
|
||||
|
||||
|
||||
@app.exception_handler(ModelNotExistError)
|
||||
async def model_not_exist_handler(request: Request, exc: ModelNotExistError):
|
||||
return JSONResponse(status_code=404, content={"message": "模型不存在"})
|
||||
|
||||
|
||||
@app.exception_handler(DemandError)
|
||||
async def demand_error_handler(request: Request, exc: DemandError):
|
||||
return JSONResponse(status_code=400, content={"message": "需求格式错误或不满足"})
|
||||
|
||||
|
||||
@app.exception_handler(WorkflowExit)
|
||||
async def workflow_exit_handler(request: Request, exc: WorkflowExit):
|
||||
return JSONResponse(status_code=400, content={"message": "工作流已退出"})
|
||||
|
||||
|
||||
@app.exception_handler(WorkflowError)
|
||||
async def workflow_error_handler(request: Request, exc: WorkflowError):
|
||||
return JSONResponse(status_code=500, content={"message": "工作流执行错误"})
|
||||
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
frontend_dir = os.path.join(base_dir, "frontend", "dist")
|
||||
|
||||
if os.path.exists(frontend_dir):
|
||||
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets")
|
||||
|
||||
|
||||
@app.get("/favicon.svg", include_in_schema=False)
|
||||
async def serve_favicon():
|
||||
return FileResponse(os.path.join(frontend_dir, "favicon.svg"))
|
||||
|
||||
|
||||
@app.get("/icons.svg", include_in_schema=False)
|
||||
async def serve_icons():
|
||||
return FileResponse(os.path.join(frontend_dir, "icons.svg"))
|
||||
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_frontend(full_path: str):
|
||||
# 【重要安全修复】避免拦截不存在的 API 路由。如果是调用了不存在的 /api/ 接口,直接返回 404,不返回前端页面
|
||||
if full_path.startswith("api/"):
|
||||
return JSONResponse(status_code=404, content={"detail": "API endpoint not found"})
|
||||
|
||||
index_path = os.path.join(frontend_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
return JSONResponse(status_code=404, content={"detail": "Frontend build not found"})
|
||||
else:
|
||||
import logging
|
||||
|
||||
logging.getLogger("pretor").warning(f"Frontend dist folder not found at {frontend_dir}. Skipping frontend mount.")
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class PretorGateway:
|
||||
gateway: Dict[str, WebSocket]
|
||||
|
||||
def __init__(self):
|
||||
self.gateway = {}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from pydantic import ValidationError
|
||||
from pretor.utils.error import UserNotExistError
|
||||
|
||||
from pretor.utils.logger import get_logger
|
||||
logger = get_logger('database_exception')
|
||||
def database_exception(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except ValidationError as e:
|
||||
logger.error(f"对象校验失败:{e}")
|
||||
raise e
|
||||
except IntegrityError as e:
|
||||
logger.error(f"数据库完整性错误 (如重复记录): {e}")
|
||||
raise e
|
||||
except OperationalError as e:
|
||||
logger.error(f"数据库连接异常: {e}")
|
||||
raise e
|
||||
except UserNotExistError as e:
|
||||
logger.error(f"更改密码失败,用户不存在:{e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"未预期的数据库错误: {e}")
|
||||
raise e
|
||||
return wrapper
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.database.table.individual import WorkerIndividual
|
||||
from sqlmodel import select
|
||||
from typing import List, Optional
|
||||
from pretor.core.database.database_exception import database_exception
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
class IndividualDatabase:
|
||||
def __init__(self, async_session_maker):
|
||||
self.async_session_maker = async_session_maker
|
||||
|
||||
@database_exception
|
||||
async def add_worker_individual(self, **kwargs) -> WorkerIndividual:
|
||||
async with self.async_session_maker() as session:
|
||||
agent_id = str(ULID())
|
||||
individual = WorkerIndividual(agent_id=agent_id, **kwargs)
|
||||
session.add(individual)
|
||||
await session.commit()
|
||||
await session.refresh(individual)
|
||||
return individual
|
||||
|
||||
@database_exception
|
||||
async def get_worker_individual(self, agent_id: str) -> Optional[WorkerIndividual]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(WorkerIndividual).where(WorkerIndividual.agent_id == agent_id)
|
||||
results = await session.execute(statement)
|
||||
return results.scalar_one_or_none()
|
||||
|
||||
@database_exception
|
||||
async def get_worker_individual_list(self, owner_id: str) -> List[WorkerIndividual]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(WorkerIndividual).where(WorkerIndividual.owner_id == owner_id)
|
||||
results = await session.execute(statement)
|
||||
return list(results.scalars().all())
|
||||
|
||||
@database_exception
|
||||
async def update_worker_individual(self, agent_id: str, **kwargs) -> Optional[WorkerIndividual]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(WorkerIndividual).where(WorkerIndividual.agent_id == agent_id)
|
||||
results = await session.execute(statement)
|
||||
individual = results.scalar_one_or_none()
|
||||
if not individual:
|
||||
return None
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
setattr(individual, key, value)
|
||||
session.add(individual)
|
||||
await session.commit()
|
||||
await session.refresh(individual)
|
||||
return individual
|
||||
|
||||
@database_exception
|
||||
async def delete_worker_individual(self, agent_id: str) -> bool:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(WorkerIndividual).where(WorkerIndividual.agent_id == agent_id)
|
||||
results = await session.execute(statement)
|
||||
individual = results.scalar_one_or_none()
|
||||
if not individual:
|
||||
return False
|
||||
session.delete(individual)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@database_exception
|
||||
async def get_all_worker_individual(self) -> List[WorkerIndividual]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(WorkerIndividual)
|
||||
results = await session.execute(statement)
|
||||
return list(results.scalars().all())
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from sqlmodel import SQLModel, Field, select
|
||||
from typing import Optional, List
|
||||
import json
|
||||
|
||||
class WorkflowRecord(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workflow_id: str = Field(index=True)
|
||||
workflow_data_json: str
|
||||
|
||||
class MemoryRecord(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
memory_text: str
|
||||
embedding: List[float] = Field(sa_column_kwargs={"type_": "VECTOR"}) # Requires pgvector extension setup in DB
|
||||
|
||||
class MemoryRAG:
|
||||
def __init__(self, async_session_maker):
|
||||
self.async_session_maker = async_session_maker
|
||||
|
||||
async def save_workflow(self, workflow_id: str, workflow_data: dict):
|
||||
async with self.async_session_maker() as session:
|
||||
record = WorkflowRecord(
|
||||
workflow_id=workflow_id,
|
||||
workflow_data_json=json.dumps(workflow_data)
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def get_workflow(self, workflow_id: str):
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(WorkflowRecord).where(WorkflowRecord.workflow_id == workflow_id)
|
||||
results = await session.execute(statement)
|
||||
record = results.scalar_one_or_none()
|
||||
if record:
|
||||
return json.loads(record.workflow_data_json)
|
||||
return None
|
||||
|
||||
async def add_memory(self, memory_text: str, embedding: List[float]):
|
||||
async with self.async_session_maker() as session:
|
||||
record = MemoryRecord(memory_text=memory_text, embedding=embedding)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def retrieve_memory(self, query_embedding: List[float], limit: int = 5):
|
||||
# Requires pgvector specific operations; simplified retrieval simulation here
|
||||
async with self.async_session_maker() as session:
|
||||
# A true pgvector query would use an ORDER BY using `<->` operator
|
||||
# e.g. statement = select(MemoryRecord).order_by(MemoryRecord.embedding.l2_distance(query_embedding)).limit(limit)
|
||||
statement = select(MemoryRecord).limit(limit)
|
||||
results = await session.execute(statement)
|
||||
return results.all()
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from typing import List
|
||||
|
||||
from pretor.core.database.table.provider import Provider
|
||||
from sqlmodel import select
|
||||
from pretor.core.database.database_exception import database_exception
|
||||
|
||||
class ProviderDatabase:
|
||||
def __init__(self, async_session_maker):
|
||||
self.async_session_maker = async_session_maker
|
||||
|
||||
@database_exception
|
||||
async def get_provider(self) -> List[Provider]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(Provider)
|
||||
results = await session.execute(statement)
|
||||
results = results.scalars().all()
|
||||
providers = [Provider(provider_title=provider.provider_title,
|
||||
provider_url=provider.provider_url,
|
||||
provider_apikey=provider.provider_apikey,
|
||||
provider_models=provider.provider_models,
|
||||
provider_type=provider.provider_type) for provider in results]
|
||||
return providers
|
||||
|
||||
@database_exception
|
||||
async def add_provider(self, **kwargs) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
provider = Provider(**kwargs)
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def delete_provider(self, provider_id: str) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
provider = await session.get(Provider, provider_id)
|
||||
if provider is not None:
|
||||
session.delete(provider)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def update_provider(self, provider_id: str, **kwargs) -> Provider:
|
||||
async with self.async_session_maker() as session:
|
||||
provider = await session.get(Provider, provider_id)
|
||||
if provider is not None:
|
||||
for key, value in kwargs.items():
|
||||
setattr(provider, key, value)
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
return provider
|
||||
return None
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.database.table.system_node import SystemNodeConfig
|
||||
from sqlmodel import select
|
||||
from typing import List, Optional
|
||||
from pretor.core.database.database_exception import database_exception
|
||||
|
||||
class SystemNodeDatabase:
|
||||
def __init__(self, async_session_maker):
|
||||
self.async_session_maker = async_session_maker
|
||||
|
||||
@database_exception
|
||||
async def upsert_system_node_config(self, node_name: str, provider_title: str, model_id: str, tools: Optional[List[str]] = None) -> SystemNodeConfig:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(SystemNodeConfig).where(SystemNodeConfig.node_name == node_name)
|
||||
results = await session.execute(statement)
|
||||
config = results.scalar_one_or_none()
|
||||
if config:
|
||||
config.provider_title = provider_title
|
||||
config.model_id = model_id
|
||||
if tools is not None:
|
||||
config.tools = tools
|
||||
else:
|
||||
config = SystemNodeConfig(node_name=node_name, provider_title=provider_title, model_id=model_id, tools=tools)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
@database_exception
|
||||
async def get_all_system_node_configs(self) -> List[SystemNodeConfig]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(SystemNodeConfig)
|
||||
results = await session.execute(statement)
|
||||
return list(results.scalars().all())
|
||||
|
||||
@database_exception
|
||||
async def get_system_node_config(self, node_name: str) -> Optional[SystemNodeConfig]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(SystemNodeConfig).where(SystemNodeConfig.node_name == node_name)
|
||||
results = await session.execute(statement)
|
||||
return results.scalar_one_or_none()
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.database.table.user import User
|
||||
from sqlmodel import select
|
||||
from pretor.utils.error import UserNotExistError, UserPasswordError
|
||||
from pretor.core.database.database_exception import database_exception
|
||||
from pretor.core.database.table.user import UserAuthority
|
||||
from pretor.utils.access import Accessor
|
||||
|
||||
class AuthDatabase:
|
||||
def __init__(self, async_session_maker):
|
||||
self.async_session_maker = async_session_maker
|
||||
|
||||
@database_exception
|
||||
async def add_user(self, user_name: str, hashed_password: str) -> User:
|
||||
from ulid import ULID
|
||||
async with self.async_session_maker() as session:
|
||||
# Check if any users exist
|
||||
statement = select(User).limit(1)
|
||||
results = await session.execute(statement)
|
||||
existing_user = results.first()
|
||||
|
||||
authority = UserAuthority.USER
|
||||
if existing_user is None:
|
||||
authority = UserAuthority.SUPER_ADMINISTRATOR
|
||||
|
||||
user = User(
|
||||
user_id=str(ULID()),
|
||||
user_name=user_name,
|
||||
hashed_password=hashed_password,
|
||||
user_authority=authority
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
@database_exception
|
||||
async def change_password(self, user_name, old_password, new_password) -> User:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(User).where(User.user_name == user_name)
|
||||
results = await session.execute(statement)
|
||||
user = results.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise UserNotExistError()
|
||||
if not Accessor.verify_password(old_password, user.hashed_password):
|
||||
raise UserPasswordError()
|
||||
user.hashed_password = new_password
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
@database_exception
|
||||
async def delete_user(self, user_name: str) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(User).where(User.user_name == user_name)
|
||||
results = await session.execute(statement)
|
||||
user = results.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise UserNotExistError()
|
||||
session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def delete_user_by_id(self, user_id: str) -> None:
|
||||
async with self.async_session_maker() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise UserNotExistError()
|
||||
session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
@database_exception
|
||||
async def login_user(self, user_name: str) -> str:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(User).where(User.user_name == user_name)
|
||||
results = await session.execute(statement)
|
||||
user = results.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise UserNotExistError()
|
||||
return user
|
||||
|
||||
@database_exception
|
||||
async def get_all_users(self) -> list[User]:
|
||||
async with self.async_session_maker() as session:
|
||||
statement = select(User)
|
||||
results = await session.execute(statement)
|
||||
users = results.scalars().all()
|
||||
return list(users)
|
||||
|
||||
@database_exception
|
||||
async def get_user_authority(self, user_id: str) -> UserAuthority:
|
||||
async with self.async_session_maker() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise UserNotExistError()
|
||||
return user.user_authority
|
||||
|
||||
@database_exception
|
||||
async def change_user_authority(self, user_id: str, new_authority: UserAuthority) -> User:
|
||||
"""
|
||||
Changes the authority level of a specific user.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user whose authority is to be changed.
|
||||
new_authority: The new authority level to assign to the user.
|
||||
|
||||
Returns:
|
||||
User: The updated user object.
|
||||
|
||||
Raises:
|
||||
UserNotExistError: If the specified user does not exist.
|
||||
"""
|
||||
async with self.async_session_maker() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise UserNotExistError()
|
||||
user.user_authority = new_authority
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
import ray
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from pretor.core.database.module.individual import IndividualDatabase
|
||||
from pretor.core.database.module.user import AuthDatabase
|
||||
from pretor.core.database.module.provider import ProviderDatabase
|
||||
from pretor.core.database.module.system_node import SystemNodeDatabase
|
||||
|
||||
@ray.remote
|
||||
class PostgresDatabase:
|
||||
def __init__(self):
|
||||
user = os.environ.get('POSTGRES_USER')
|
||||
password = os.environ.get('POSTGRES_PASSWORD')
|
||||
host = os.environ.get('POSTGRES_HOST')
|
||||
port = os.environ.get('POSTGRES_PORT')
|
||||
database = os.environ.get('POSTGRES_DB')
|
||||
database_url = f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}"
|
||||
self.async_engine = create_async_engine(database_url, echo=True)
|
||||
self.async_session_maker = sessionmaker(self.async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
self._auth_database = AuthDatabase(self.async_session_maker)
|
||||
self._provider_database = ProviderDatabase(self.async_session_maker)
|
||||
self._individual_database = IndividualDatabase(self.async_session_maker)
|
||||
self._system_node_database = SystemNodeDatabase(self.async_session_maker)
|
||||
|
||||
self.ready_event = asyncio.Event()
|
||||
|
||||
async def init_db(self) -> None:
|
||||
try:
|
||||
async with self.async_engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
except Exception as e:
|
||||
# Provide a warning if the database is not accessible, allowing
|
||||
# the app to start up for development/UI tests without crashing immediately.
|
||||
print(f"Warning: Failed to initialize PostgreSQL database: {e}")
|
||||
finally:
|
||||
self.ready_event.set()
|
||||
|
||||
# Auth Database Methods
|
||||
async def add_user(self, user_name: str, hashed_password: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.add_user(user_name, hashed_password)
|
||||
|
||||
async def change_password(self, user_name, old_password, new_password):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.change_password(user_name, old_password, new_password)
|
||||
|
||||
async def delete_user(self, user_name: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.delete_user(user_name)
|
||||
|
||||
async def delete_user_by_id(self, user_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.delete_user_by_id(user_id)
|
||||
|
||||
async def login_user(self, user_name: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.login_user(user_name)
|
||||
|
||||
async def get_all_users(self):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.get_all_users()
|
||||
|
||||
async def get_user_authority(self, user_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.get_user_authority(user_id)
|
||||
|
||||
async def change_user_authority(self, user_id: str, new_authority):
|
||||
await self.ready_event.wait()
|
||||
return await self._auth_database.change_user_authority(user_id, new_authority)
|
||||
|
||||
# Provider Database Methods
|
||||
async def get_provider(self):
|
||||
await self.ready_event.wait()
|
||||
return await self._provider_database.get_provider()
|
||||
|
||||
async def add_provider_db(self, **kwargs):
|
||||
await self.ready_event.wait()
|
||||
return await self._provider_database.add_provider(**kwargs)
|
||||
|
||||
async def delete_provider_db(self, provider_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._provider_database.delete_provider(provider_id)
|
||||
|
||||
async def update_provider_db(self, provider_id: str, **kwargs):
|
||||
await self.ready_event.wait()
|
||||
return await self._provider_database.update_provider(provider_id, **kwargs)
|
||||
|
||||
# System Node Database Methods
|
||||
async def upsert_system_node_config(self, node_name: str, provider_title: str, model_id: str, tools: list[str] = None):
|
||||
await self.ready_event.wait()
|
||||
return await self._system_node_database.upsert_system_node_config(node_name, provider_title, model_id, tools)
|
||||
|
||||
async def get_all_system_node_configs(self):
|
||||
await self.ready_event.wait()
|
||||
return await self._system_node_database.get_all_system_node_configs()
|
||||
|
||||
# Individual Database Methods
|
||||
async def add_worker_individual(self, **kwargs):
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.add_worker_individual(**kwargs)
|
||||
|
||||
async def get_worker_individual(self, agent_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.get_worker_individual(agent_id)
|
||||
|
||||
async def get_worker_individual_list(self, owner_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.get_worker_individual_list(owner_id)
|
||||
|
||||
async def update_worker_individual(self, agent_id: str, **kwargs):
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.update_worker_individual(agent_id, **kwargs)
|
||||
|
||||
async def delete_worker_individual(self, agent_id: str):
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.delete_worker_individual(agent_id)
|
||||
|
||||
async def get_all_worker_individual(self):
|
||||
await self.ready_event.wait()
|
||||
return await self._individual_database.get_all_worker_individual()
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.database.table.user import User
|
||||
from pretor.core.database.table.provider import Provider
|
||||
from pretor.core.database.table.individual import WorkerIndividual
|
||||
__all__ = ["User", "Provider", "WorkerIndividual"]
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import Column, JSON
|
||||
from enum import Enum
|
||||
|
||||
class AgentType(str, Enum):
|
||||
SKILL_INDIVIDUAL = "skill_individual"
|
||||
ORDINARY_INDIVIDUAL = "ordinary_individual"
|
||||
SPECIAL_INDIVIDUAL = "special_individual"
|
||||
|
||||
class WorkerIndividual(SQLModel, table=True):
|
||||
__tablename__ = "worker_individual"
|
||||
agent_id: str = Field(primary_key=True)
|
||||
agent_name: str = Field(index=True)
|
||||
agent_type: AgentType
|
||||
description: str = Field(nullable=False)
|
||||
provider_title: str
|
||||
model_id: str
|
||||
system_prompt: Optional[str]
|
||||
output_template: Optional[dict] = Field(sa_column=Column(JSON),description="输出模板标识")
|
||||
bound_skill: Optional[str] = Field(sa_column=Column(JSON))
|
||||
workspace: Optional[List[str]] = Field(sa_column=Column(JSON))
|
||||
tools: Optional[List[str]] = Field(sa_column=Column(JSON), default=None)
|
||||
owner_id: str
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
from typing import List
|
||||
from sqlalchemy import Column, JSON
|
||||
from typing import Optional
|
||||
|
||||
class Provider(SQLModel, table=True):
|
||||
__tablename__ = "provider"
|
||||
provider_id: str = Field(primary_key=True)
|
||||
provider_title: str = Field(index=True)
|
||||
provider_type: str
|
||||
|
||||
provider_url: Optional[str]
|
||||
provider_apikey: Optional[str]
|
||||
|
||||
provider_models: List[str] = Field(sa_column=Column(JSON))
|
||||
|
||||
provider_owner: str
|
||||
is_active: bool = Field(default=True, description="该服务商节点是否在线/启用")
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import Column, JSON
|
||||
|
||||
class SystemNodeConfig(SQLModel, table=True):
|
||||
__tablename__ = "system_node_config"
|
||||
node_name: str = Field(primary_key=True)
|
||||
provider_title: str
|
||||
model_id: str
|
||||
tools: Optional[List[str]] = Field(sa_column=Column(JSON), default=None)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
from enum import IntEnum
|
||||
|
||||
class UserAuthority(IntEnum):
|
||||
SUPER_ADMINISTRATOR = 100
|
||||
ADMINISTRATOR = 50
|
||||
USER = 20
|
||||
UNAUTHORIZED_USER = 10
|
||||
GUEST = 0
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
__tablename__ = 'user'
|
||||
user_id: str = Field(primary_key=True)
|
||||
user_name: str = Field(index=True)
|
||||
hashed_password: str
|
||||
user_authority: UserAuthority = Field(default=UserAuthority.USER)
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import ray
|
||||
from pretor.core.global_state_machine.provider_manager import ProviderManager
|
||||
from pretor.core.global_state_machine.tool_manager import GlobalToolManager
|
||||
from typing import Dict
|
||||
from pretor.core.database.postgres import PostgresDatabase
|
||||
from pretor.api.platform.event import PretorEvent
|
||||
import asyncio
|
||||
from pretor.core.workflow.workflow import PretorWorkflow
|
||||
from pretor.core.workflow.workflow_template_manager import WorkflowManager
|
||||
from pretor.core.global_state_machine.skill_manager import GlobalSkillManager
|
||||
from pretor.core.global_state_machine.individual_manager import GlobalIndividualManager
|
||||
|
||||
|
||||
@ray.remote
|
||||
class GlobalStateMachine:
|
||||
def __init__(self, postgres_database: PostgresDatabase):
|
||||
import sys
|
||||
print("GSM __init__ START", file=sys.stderr, flush=True)
|
||||
self.event_dict: Dict[str, PretorEvent] = {}
|
||||
print(" event_dict done", file=sys.stderr, flush=True)
|
||||
self._global_provider_manager = ProviderManager(postgres_database)
|
||||
print(" provider_manager done", file=sys.stderr, flush=True)
|
||||
self._global_tool_manager = GlobalToolManager()
|
||||
print(" tool_manager done", file=sys.stderr, flush=True)
|
||||
self._global_workflow_template_manager = WorkflowManager()
|
||||
print(" workflow_template_manager done", file=sys.stderr, flush=True)
|
||||
self._global_skill_manager = GlobalSkillManager()
|
||||
print(" skill_manager done", file=sys.stderr, flush=True)
|
||||
self._global_individual_manager = GlobalIndividualManager()
|
||||
print(" individual_manager done", file=sys.stderr, flush=True)
|
||||
self.postgres_database = postgres_database
|
||||
print("GSM __init__ DONE", file=sys.stderr, flush=True)
|
||||
|
||||
async def init_state_machine(self):
|
||||
await self._global_provider_manager.init_provider_register(self.postgres_database)
|
||||
await self._global_individual_manager.init_individual_register(self.postgres_database)
|
||||
|
||||
async def add_provider_wrap(self, provider_type, provider_title, provider_url, provider_apikey, provider_owner):
|
||||
return await self._global_provider_manager.add_provider(
|
||||
provider_type=provider_type,
|
||||
provider_title=provider_title,
|
||||
provider_url=provider_url,
|
||||
provider_apikey=provider_apikey,
|
||||
provider_owner=provider_owner,
|
||||
postgres_database=self.postgres_database
|
||||
)
|
||||
|
||||
# Provider Manager Methods
|
||||
def get_provider_list(self):
|
||||
return self._global_provider_manager.get_provider_list()
|
||||
|
||||
def get_provider(self, provider_title):
|
||||
return self._global_provider_manager.get_provider(provider_title)
|
||||
|
||||
async def delete_provider(self, provider_title: str):
|
||||
return await self._global_provider_manager.delete_provider(provider_title, self.postgres_database)
|
||||
|
||||
# Tool Manager Methods
|
||||
def get_tool_mapper(self):
|
||||
return self._global_tool_manager.tool_mapper
|
||||
|
||||
def get_tool_list(self, agent_name: str):
|
||||
# get_tool_list didn't actually exist on tool_manager, let's implement it to return the tools
|
||||
# for a specific agent name (or scope)
|
||||
tools = self._global_tool_manager.tool_mapper.get(agent_name, {})
|
||||
# also include default tools
|
||||
default_tools = self._global_tool_manager.tool_mapper.get("default", {})
|
||||
merged_tools = {**default_tools, **tools}
|
||||
return merged_tools
|
||||
|
||||
# Workflow Template Manager Methods
|
||||
def get_all_workflow_templates(self):
|
||||
return self._global_workflow_template_manager.get_all_workflow_templates()
|
||||
|
||||
def add_workflow_template(self, template_name: str, workflow_template):
|
||||
return self._global_workflow_template_manager.add_workflow_template(template_name, workflow_template)
|
||||
|
||||
def delete_workflow_template(self, template_name: str):
|
||||
return self._global_workflow_template_manager.delete_workflow_template(template_name)
|
||||
|
||||
def generate_workflow_template(self, workflow_template):
|
||||
return self._global_workflow_template_manager.generate_workflow_template(workflow_template)
|
||||
|
||||
# Skill Manager Methods
|
||||
def add_skill(self, skill_name: str):
|
||||
return self._global_skill_manager.add_skill(skill_name)
|
||||
|
||||
def get_skill_list(self):
|
||||
return self._global_skill_manager.get_skill_list()
|
||||
|
||||
def remove_skill(self, skill_name: str):
|
||||
return self._global_skill_manager.remove_skill(skill_name)
|
||||
|
||||
# Individual Manager Methods
|
||||
def add_individual(self, agent_id: str, config):
|
||||
return self._global_individual_manager.add_individual(agent_id, config)
|
||||
|
||||
def get_individual(self, agent_id: str):
|
||||
return self._global_individual_manager.get_individual(agent_id)
|
||||
|
||||
def remove_individual(self, agent_id: str):
|
||||
return self._global_individual_manager.remove_individual(agent_id)
|
||||
|
||||
def list_individuals(self):
|
||||
return self._global_individual_manager.list_individuals()
|
||||
|
||||
###以下方法为event_dict方法
|
||||
def add_event(self, event: PretorEvent) -> None:
|
||||
event.pending_queue = asyncio.Queue()
|
||||
event.receive_queue = asyncio.Queue()
|
||||
self.event_dict[event.trace_id] = event
|
||||
|
||||
def delete_event(self, trace_id: str) -> None:
|
||||
del self.event_dict[trace_id]
|
||||
|
||||
def get_event(self, trace_id: str) -> PretorEvent:
|
||||
return self.event_dict.get(trace_id, None)
|
||||
|
||||
def update_attachment(self, trace_id: str, attachment: Dict[str, str]) -> None:
|
||||
self.event_dict[trace_id].attachment = attachment
|
||||
|
||||
def update_workflow(self, trace_id: str, workflow: PretorWorkflow) -> None:
|
||||
self.event_dict[trace_id].workflow = workflow
|
||||
|
||||
def get_workflow(self, trace_id: str) -> PretorWorkflow:
|
||||
return self.event_dict[trace_id].workflow
|
||||
|
||||
def list_events(self) -> list[dict]:
|
||||
result = []
|
||||
for trace_id, event in self.event_dict.items():
|
||||
workflow_title = event.workflow.title if event.workflow else None
|
||||
workflow_status = event.workflow.status.status if event.workflow and event.workflow.status else None
|
||||
result.append({
|
||||
"event_id": trace_id,
|
||||
"workflow_title": workflow_title,
|
||||
"status": workflow_status,
|
||||
"user_name": event.user_name,
|
||||
"message": event.message,
|
||||
})
|
||||
return result
|
||||
|
||||
async def put_pending(self, trace_id, item) -> None:
|
||||
await self.event_dict[trace_id].pending_queue.put(item)
|
||||
|
||||
async def get_pending(self, trace_id) -> str:
|
||||
return await self.event_dict[trace_id].pending_queue.get()
|
||||
|
||||
async def put_received(self, trace_id, item) -> None:
|
||||
await self.event_dict[trace_id].receive_queue.put(item)
|
||||
|
||||
async def get_received(self, trace_id) -> str:
|
||||
return await self.event_dict[trace_id].receive_queue.get()
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from typing import Dict, Any
|
||||
from pretor.utils.logger import get_logger
|
||||
logger = get_logger('individual_manager')
|
||||
|
||||
class GlobalIndividualManager:
|
||||
def __init__(self):
|
||||
self._individuals: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def init_individual_register(self, postgres) -> None:
|
||||
try:
|
||||
try:
|
||||
individuals = await postgres.get_all_worker_individual.remote()
|
||||
for ind in individuals:
|
||||
agent_id = getattr(ind, 'agent_id', None)
|
||||
if agent_id:
|
||||
self._individuals[agent_id] = ind.model_dump() if hasattr(ind, 'model_dump') else dict(ind)
|
||||
logger.info(f"成功从数据库拉取了 {len(self._individuals)} 个 Worker Individual 配置。")
|
||||
except AttributeError:
|
||||
logger.warning("数据库中 get_all_worker_individual 方法未实现,跳过全量加载。可以在将来完善该接口。")
|
||||
except Exception as e:
|
||||
# 捕获因 Ray 调用目标方法不存在引发的异常
|
||||
if "has no attribute 'get_all_worker_individual'" in str(e):
|
||||
logger.warning("数据库 individual_database 中缺少 get_all_worker_individual 方法,无法全量拉取。")
|
||||
else:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"从数据库拉取 Worker Individual 配置失败: {e}")
|
||||
|
||||
def add_individual(self, agent_id: str, config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
注册一个 worker individual
|
||||
config 可以包含 type, prompt, provider_title, model_id 等
|
||||
"""
|
||||
config["agent_id"] = agent_id
|
||||
self._individuals[agent_id] = config
|
||||
|
||||
def get_individual(self, agent_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取一个 worker individual 的配置
|
||||
"""
|
||||
return self._individuals.get(agent_id, None)
|
||||
|
||||
def remove_individual(self, agent_id: str) -> None:
|
||||
if agent_id in self._individuals:
|
||||
del self._individuals[agent_id]
|
||||
|
||||
def list_individuals(self) -> Dict[str, Dict[str, Any]]:
|
||||
return self._individuals
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.global_state_machine.model_provider.base_provider import Provider, ProviderArgs
|
||||
from pretor.core.global_state_machine.model_provider.openai_provider import OpenAIProvider
|
||||
from pretor.core.global_state_machine.model_provider.claude_provider import ClaudeProvider
|
||||
from pretor.core.global_state_machine.model_provider.deepseek_provider import DeepseekProvider
|
||||
__all__ = ["Provider", "ProviderArgs", "OpenAIProvider", "ClaudeProvider", "DeepseekProvider"]
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from enum import Enum
|
||||
|
||||
class ProviderStatus(str, Enum):
|
||||
UP = "up"
|
||||
DOWN = "down"
|
||||
|
||||
class Provider(BaseModel):
|
||||
provider_title: str
|
||||
provider_url: str
|
||||
provider_apikey: str
|
||||
provider_models: List[str]
|
||||
provider_type: str
|
||||
provider_owner: str | None = None
|
||||
provider_status: ProviderStatus = ProviderStatus.UP
|
||||
|
||||
class ProviderArgs(BaseModel):
|
||||
provider_title: str
|
||||
provider_url: str
|
||||
provider_apikey: str
|
||||
provider_owner: str
|
||||
|
||||
class BaseProvider(ABC):
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
async def create_provider(provider_args: ProviderArgs) -> Provider:
|
||||
"""
|
||||
创建一个供应商,传入provider_args参数,打包为一个Provider对象
|
||||
|
||||
Args:
|
||||
provider_args: 参数包,包含以下几个参数
|
||||
provider_title: 供应商的别名
|
||||
provider_url: 供应商的url
|
||||
provider_apikey:供应商的apikey
|
||||
|
||||
Returns:
|
||||
返回一个Provider对象,由provider_manager管理
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
async def _load_models(provider_args: ProviderArgs) -> List[str]:
|
||||
"""
|
||||
加载模型列表
|
||||
base_provider的字类应当按照供应商的api标准,向供应商的接口发送http请求从而或者供应商所提供的模型列表
|
||||
|
||||
Args:
|
||||
provider_args: 参数包,包含以下几个参数
|
||||
provider_title: 供应商的别名
|
||||
provider_url: 供应商的url
|
||||
provider_apikey:供应商的apikey
|
||||
|
||||
Returns:
|
||||
返回一个列表,为http请求获取的模型列表
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def _return_provider(provider_args: ProviderArgs, provider_models: List[str]) -> Provider:
|
||||
"""
|
||||
包装Provider对象并返回
|
||||
将provider_args和_load_models获取的方法包装为provider对象
|
||||
|
||||
Args:
|
||||
provider_args: 参数包,包含以下几个参数
|
||||
provider_title: 供应商的别名
|
||||
provider_url: 供应商的url
|
||||
provider_apikey:供应商的apikey
|
||||
|
||||
provider_models: 模型列表,为该供应商包含的模型列表
|
||||
|
||||
Returns:
|
||||
返回一个Provider对象
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
from pretor.utils.retry import retry_on_retryable_error
|
||||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.global_state_machine.model_provider.base_provider import BaseProvider, Provider, ProviderArgs
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
class ClaudeProvider(BaseProvider):
|
||||
@staticmethod
|
||||
async def create_provider(provider_args: ProviderArgs) -> Provider:
|
||||
provider_models: List[str] = await ClaudeProvider._load_models(provider_args)
|
||||
provider: Provider = ClaudeProvider._return_provider(provider_args, provider_models)
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
@retry_on_retryable_error()
|
||||
async def _load_models(provider_args: ProviderArgs) -> List[str]:
|
||||
# Anthropic 官方需要 version 头
|
||||
headers = {
|
||||
"x-api-key": provider_args.provider_apikey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 如果是官方 API,通常使用 /v1/models (如果支持)
|
||||
# 注意:很多时候 Anthropic 并不返回完整列表,如果请求失败,建议返回硬编码的常用模型
|
||||
url = f"{provider_args.provider_url.rstrip('/')}/v1/models"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
model_ids = [m["id"] for m in data.get("data", [])]
|
||||
return sorted(model_ids)
|
||||
else:
|
||||
# 如果官方列表接口不可用,fallback 到已知常用模型
|
||||
return ["claude-3-5-sonnet-20240620", "claude-3-opus-20240229", "claude-3-haiku-20240307"]
|
||||
except Exception as e:
|
||||
print(f"[{provider_args.provider_title}] 获取 Claude 模型列表错误: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _return_provider(provider_args: ProviderArgs, provider_models: List[str]) -> Provider:
|
||||
return Provider(provider_title=provider_args.provider_title,
|
||||
provider_apikey=provider_args.provider_apikey,
|
||||
provider_url=provider_args.provider_url,
|
||||
provider_models=provider_models,
|
||||
provider_type="claude")
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.utils.retry import retry_on_retryable_error
|
||||
from pretor.core.global_state_machine.model_provider.base_provider import BaseProvider, Provider, ProviderArgs
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
class DeepseekProvider(BaseProvider):
|
||||
@staticmethod
|
||||
async def create_provider(provider_args: ProviderArgs) -> Provider:
|
||||
provider_models: List[str] = await DeepseekProvider._load_models(provider_args)
|
||||
provider: Provider = DeepseekProvider._return_provider(provider_args, provider_models)
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
@retry_on_retryable_error()
|
||||
async def _load_models(provider_args: ProviderArgs) -> List[str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {provider_args.provider_apikey}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
url = f"{provider_args.provider_url}/models" if "/v1" in provider_args.provider_url else f"{provider_args.provider_url}/v1/models"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
print(f"[{provider_args.provider_title}] 获取模型失败: {response.status_code}")
|
||||
return []
|
||||
data = response.json()
|
||||
raw_models = data.get("data", [])
|
||||
model_ids = [m["id"] for m in raw_models]
|
||||
return sorted(model_ids)
|
||||
except httpx.RequestError as e:
|
||||
from pretor.utils.error import RetryableError
|
||||
print(f"[{provider_args.provider_title}] 网络请求异常: {e}")
|
||||
raise RetryableError(f"[{provider_args.provider_title}] 网络请求异常: {e}") from e
|
||||
except Exception as e:
|
||||
print(f"[{provider_args.provider_title}] 解析模型列表时发生错误: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _return_provider(provider_args: ProviderArgs, provider_models: List[str]) -> Provider:
|
||||
return Provider(provider_title=provider_args.provider_title,
|
||||
provider_apikey=provider_args.provider_apikey,
|
||||
provider_url=provider_args.provider_url,
|
||||
provider_models=provider_models,
|
||||
provider_type="deepseek")
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.utils.retry import retry_on_retryable_error
|
||||
from pretor.core.global_state_machine.model_provider.base_provider import BaseProvider, Provider, ProviderArgs
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
class OpenAIProvider(BaseProvider):
|
||||
@staticmethod
|
||||
async def create_provider(provider_args: ProviderArgs) -> Provider:
|
||||
provider_models: List[str] = await OpenAIProvider._load_models(provider_args)
|
||||
provider: Provider = OpenAIProvider._return_provider(provider_args, provider_models)
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
@retry_on_retryable_error()
|
||||
async def _load_models(provider_args: ProviderArgs) -> List[str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {provider_args.provider_apikey}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
url = f"{provider_args.provider_url}/models" if "/v1" in provider_args.provider_url else f"{provider_args.provider_url}/v1/models"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
print(f"[{provider_args.provider_title}] 获取模型失败: {response.status_code}")
|
||||
return []
|
||||
data = response.json()
|
||||
raw_models = data.get("data", [])
|
||||
model_ids = [m["id"] for m in raw_models]
|
||||
return sorted(model_ids)
|
||||
except httpx.RequestError as e:
|
||||
from pretor.utils.error import RetryableError
|
||||
print(f"[{provider_args.provider_title}] 网络请求异常: {e}")
|
||||
raise RetryableError(f"[{provider_args.provider_title}] 网络请求异常: {e}") from e
|
||||
except Exception as e:
|
||||
print(f"[{provider_args.provider_title}] 解析模型列表时发生错误: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _return_provider(provider_args: ProviderArgs, provider_models: List[str]) -> Provider:
|
||||
return Provider(provider_title=provider_args.provider_title,
|
||||
provider_apikey=provider_args.provider_apikey,
|
||||
provider_url=provider_args.provider_url,
|
||||
provider_models=provider_models,
|
||||
provider_type="openai")
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from pretor.core.global_state_machine.model_provider import Provider, OpenAIProvider, ClaudeProvider, DeepseekProvider
|
||||
from typing import Dict, Type
|
||||
|
||||
class ProviderManager:
|
||||
"""
|
||||
模型供应商管理器 (ProviderManager)。
|
||||
负责维护不同的 LLM 协议适配器,提供从配置注册到 Agent 实例化的全生命周期管理。
|
||||
"""
|
||||
# --- 类属性显式标注 (IDE 友好) ---
|
||||
provider_mapper: Dict[str, Type[Provider]]
|
||||
"""协议映射表:键为协议名(如 'openai'),值为对应的 Provider 类。"""
|
||||
|
||||
provider_register: Dict[str, Provider]
|
||||
"""供应商注册表:键为用户自定义别名,值为已实例化的 Provider 对象。"""
|
||||
def __init__(self, postgres):
|
||||
self.provider_mapper = {"openai": OpenAIProvider,
|
||||
"claude": ClaudeProvider,
|
||||
"deepseek": DeepseekProvider}
|
||||
self.provider_register = {}
|
||||
|
||||
async def init_provider_register(self, postgres) -> None:
|
||||
providers = await postgres.get_provider.remote()
|
||||
for provider in providers:
|
||||
self.provider_register[provider.provider_title] = provider
|
||||
|
||||
async def add_provider(self, provider_type, provider_title, provider_url, provider_apikey, provider_owner, postgres_database) -> None:
|
||||
from pretor.core.global_state_machine.model_provider import ProviderArgs
|
||||
from pretor.utils.logger import get_logger
|
||||
logger = get_logger('provider_manager')
|
||||
import httpx
|
||||
|
||||
provider_args: ProviderArgs = ProviderArgs(provider_title=provider_title,
|
||||
provider_url=provider_url,
|
||||
provider_apikey=provider_apikey,
|
||||
provider_owner=provider_owner)
|
||||
try:
|
||||
import ulid
|
||||
provider_class = self.provider_mapper.get(provider_type, None)
|
||||
if provider_class is None:
|
||||
logger.warning(f"Provider type {provider_type} is not supported.")
|
||||
return None
|
||||
provider: Provider = await provider_class.create_provider(provider_args)
|
||||
provider.provider_owner = provider_owner
|
||||
self.provider_register[provider_title] = provider
|
||||
await postgres_database.add_provider_db.remote(
|
||||
provider_id=str(ulid.ULID()),
|
||||
provider_title=provider.provider_title,
|
||||
provider_url=provider.provider_url,
|
||||
provider_apikey=provider.provider_apikey,
|
||||
provider_models=provider.provider_models,
|
||||
provider_type=provider.provider_type,
|
||||
provider_owner=provider.provider_owner)
|
||||
|
||||
logger.info(f"已添加适配器{provider_title}")
|
||||
except httpx.RequestError as e:
|
||||
from pretor.utils.error import RetryableError
|
||||
logger.warning(f"[{provider_args.provider_title}] 网络请求异常: {e}")
|
||||
raise RetryableError(f"[{provider_args.provider_title}] 网络请求异常: {e}") from e
|
||||
except Exception as e:
|
||||
logger.warning(f"[{provider_args.provider_title}] 解析模型列表时发生错误: {e}")
|
||||
|
||||
def get_provider_list(self):
|
||||
return self.provider_register
|
||||
|
||||
def get_provider(self, provider_title):
|
||||
return self.provider_register.get(provider_title)
|
||||
|
||||
async def delete_provider(self, provider_title: str, postgres_database) -> None:
|
||||
if provider_title in self.provider_register:
|
||||
provider = self.provider_register[provider_title]
|
||||
await postgres_database.delete_provider_db.remote( provider_id=provider.provider_id)
|
||||
del self.provider_register[provider_title]
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from typing import Tuple, Dict
|
||||
from collections import defaultdict
|
||||
import pathlib
|
||||
import json
|
||||
|
||||
class GlobalSkillManager:
|
||||
skill_mapper = Dict[str,Tuple[str]]
|
||||
"""skill的存储表"""
|
||||
|
||||
def __init__(self):
|
||||
self.skill_mapper = defaultdict(tuple)
|
||||
|
||||
import os
|
||||
skill_plugin_dir = pathlib.Path(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill")))
|
||||
if not skill_plugin_dir.exists() or not skill_plugin_dir.is_dir():
|
||||
return
|
||||
for item in skill_plugin_dir.iterdir():
|
||||
if item.is_dir() and not item.name.startswith((".", "__")):
|
||||
json_path = item / "skill.json" # 拼接文件路径
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
skill = json.load(f)
|
||||
# 提取并映射
|
||||
name = skill.get("name")
|
||||
if name:
|
||||
self.skill_mapper[name] = (
|
||||
skill.get("description", ""),
|
||||
skill.get("instructions", "")
|
||||
)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"警告: 加载插件 {item.name} 失败: {e}")
|
||||
|
||||
def add_skill(self, skill_name: str) -> None:
|
||||
"""Add a skill to the manager by reading its skill.json from the path"""
|
||||
import os
|
||||
skill_plugin_dir = pathlib.Path(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "plugin", "skill")))
|
||||
item = skill_plugin_dir / skill_name
|
||||
if item.is_dir() and not item.name.startswith((".", "__")):
|
||||
json_path = item / "skill.json"
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
skill = json.load(f)
|
||||
name = skill.get("name")
|
||||
if name:
|
||||
self.skill_mapper[name] = (
|
||||
skill.get("description", ""),
|
||||
skill.get("instructions", "")
|
||||
)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"警告: 加载插件 {item.name} 失败: {e}")
|
||||
|
||||
def get_skill_list(self) -> dict:
|
||||
"""Return all skills currently loaded."""
|
||||
return self.skill_mapper
|
||||
|
||||
def remove_skill(self, skill_name: str) -> None:
|
||||
"""Remove a skill from the manager mapping."""
|
||||
if skill_name in self.skill_mapper:
|
||||
del self.skill_mapper[skill_name]
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import pathlib
|
||||
import importlib
|
||||
import inspect
|
||||
from collections import defaultdict
|
||||
from pretor.plugin.tool_plugin.base_tool import BaseToolData
|
||||
from typing import Dict, Type
|
||||
from pretor.utils.logger import get_logger
|
||||
logger = get_logger('tool_manager')
|
||||
|
||||
class GlobalToolManager:
|
||||
tool_mapper: Dict[str, Dict[str, Type[BaseToolData]]]
|
||||
|
||||
def __init__(self):
|
||||
self.tool_mapper = defaultdict(dict)
|
||||
|
||||
tool_plugin_dir = pathlib.Path(__file__).parent.parent.parent / "plugin" / "tool_plugin"
|
||||
if not tool_plugin_dir.exists() or not tool_plugin_dir.is_dir():
|
||||
return
|
||||
|
||||
for item in tool_plugin_dir.iterdir():
|
||||
if item.is_dir() and not item.name.startswith("__"):
|
||||
plugin_name = item.name
|
||||
module_name = f"pretor.plugin.tool_plugin.{plugin_name}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if issubclass(obj, BaseToolData) and obj is not BaseToolData:
|
||||
# It's a valid tool class
|
||||
action_scopes = obj.model_fields.get("action_scope").default
|
||||
|
||||
if not action_scopes:
|
||||
self.tool_mapper["default"][plugin_name] = obj
|
||||
else:
|
||||
for scope in action_scopes:
|
||||
self.tool_mapper[scope][plugin_name] = obj
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tool plugin {plugin_name}: {e}")
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from .consciousness_node import ConsciousnessNode
|
||||
__all__ = ["ConsciousnessNode"]
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
import ray
|
||||
from typing import Union, overload
|
||||
from pretor.core.individual.consciousness_node.template import (ConsciousnessNodeDeps, ForSupervisoryNode, ForWorkflow,\
|
||||
ForWorkflowEngine, ForWorkflowInput, ForSupervisoryInput, ForWorkflowEngineInput)
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine
|
||||
from pretor.core.global_state_machine.model_provider.base_provider import Provider
|
||||
from pretor.adapter.model_adapter.agent_factory import AgentFactory
|
||||
|
||||
|
||||
@ray.remote
|
||||
class ConsciousnessNode:
|
||||
def __init__(self) -> None:
|
||||
from pretor.utils.logger import get_logger
|
||||
self.logger = get_logger('consciousness_node')
|
||||
self.agent: None | Agent = None
|
||||
|
||||
|
||||
async def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str, tools_list: list[str] = None) -> None:
|
||||
"""
|
||||
create_agent方法,将agent对象装配到ConsciousnessNode的属性内
|
||||
该方法通过provider_title从global_state_machine中获取provider对象,然后从provider对象中取出供应商形象,装配为pydantic_ai的
|
||||
Agent实例,
|
||||
并挂载到self.agent属性
|
||||
Args:
|
||||
global_state_machine: 全局状态机
|
||||
provider_title: 供应商名
|
||||
model_id: 模型id
|
||||
|
||||
Returns:
|
||||
无返回
|
||||
"""
|
||||
system_prompt: str = (
|
||||
"你叫Pretor,是一个多智能体AI助手系统中的【意识节点 (Consciousness Node)】。\n"
|
||||
"你是系统的'高级规划师'和'架构师',负责处理监控节点分配过来的复杂任务。\n"
|
||||
"你的主要工作场景包括:\n"
|
||||
"1. 拆解任务 (Workflow Generation):结合用户的原始命令和提供的模板,生成严谨、可执行的工作流 (PretorWorkflow),并将其输出为 ForWorkflowEngine 格式。拆解时步骤应清晰连贯。\n"
|
||||
"2. 中途指导 (Workflow Execution):在工作流执行中,如果某一步骤指派给你,你需要对控制节点的结果进行分析或提供下一步的指导,输出 ForWorkflow 格式。\n"
|
||||
"3. 总结报告 (Supervisory Report):在整个工作流执行完毕后,你需要对整体流程、各个控制节点的执行情况进行审查,并生成一份技术性的总结报告,输出 ForSupervisoryNode 格式。\n"
|
||||
"请确保所有的思考和生成过程符合逻辑,严密且高质量。"
|
||||
)
|
||||
output_type = Union[ForSupervisoryNode, ForWorkflow, ForWorkflowEngine]
|
||||
from pretor.utils.get_tool import load_tools_from_list
|
||||
provider: Provider = await global_state_machine.get_provider.remote( provider_title)
|
||||
agent_factory = AgentFactory()
|
||||
|
||||
callables = load_tools_from_list(tools_list)
|
||||
self.agent = agent_factory.create_agent(provider=provider,
|
||||
model_id=model_id,
|
||||
output_type=output_type,
|
||||
system_prompt=system_prompt,
|
||||
deps_type=ConsciousnessNodeDeps,
|
||||
agent_name="consciousness_node",
|
||||
tools=callables)
|
||||
|
||||
@self.agent.system_prompt
|
||||
async def dynamic_prompt(ctx: RunContext[ConsciousnessNodeDeps]):
|
||||
prompt = system_prompt + "\n\n"
|
||||
prompt += (
|
||||
f"=== 当前任务上下文 ===\n"
|
||||
f"- 当前指令 (Command): {ctx.deps.command}\n"
|
||||
f"- 原始用户命令 (Original Command): {ctx.deps.original_command}\n"
|
||||
)
|
||||
if ctx.deps.workflow_template:
|
||||
prompt += f"- 选定工作流模板 (Workflow Template): {ctx.deps.workflow_template}\n"
|
||||
if ctx.deps.available_skills:
|
||||
prompt += "\n=== 当前可用 Skill Individual ===\n"
|
||||
prompt += "你可以直接将以下 Skill Individual 安排进工作流的步骤中(设置 node 为 skill_individual,并将 agent_id 设置为对应 Skill Individual 的真实 agent_id,不要用名称!),作为可调用的工具。\n"
|
||||
for skill in ctx.deps.available_skills:
|
||||
prompt += f"- 真实 agent_id: {skill.get('agent_id')}\n 名称: {skill['name']}\n 描述: {skill['description']}\n"
|
||||
|
||||
return prompt
|
||||
|
||||
async def working(self, payload: Union[ForWorkflowEngineInput, ForWorkflowInput, ForSupervisoryInput]) -> Union[ForWorkflowEngine, ForWorkflow, ForSupervisoryNode, None]:
|
||||
try:
|
||||
result = await self._run(payload)
|
||||
if isinstance(result, (ForWorkflowEngine, ForWorkflow, ForSupervisoryNode)):
|
||||
return result
|
||||
else:
|
||||
self.logger.error(f"ConsciousnessNode: 未知或不匹配的返回类型: {type(result)}")
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception("ConsciousnessNode在执行working时发生严重错误")
|
||||
return None
|
||||
|
||||
|
||||
@overload
|
||||
async def _run(self, payload: ForWorkflowEngineInput) -> ForWorkflowEngine:
|
||||
"""
|
||||
_run方法
|
||||
该分支应当在supervisory_node简单处理用户命令后,工作流创建前调用!
|
||||
Args:
|
||||
payload: 应当包含workflow_template和event对象
|
||||
|
||||
Returns:
|
||||
ForWorkflowEngine对象,将被放到全局状态机后丢入WorkflowEngine的异步队列
|
||||
"""
|
||||
pass
|
||||
|
||||
@overload
|
||||
async def _run(self, payload: ForWorkflow) -> ForWorkflow:
|
||||
"""
|
||||
_run方法
|
||||
该分支应当在workflow运行时,由WorkflowEngine进行调用!
|
||||
Args:
|
||||
payload: 应当包含workflow中的WorkStep对象
|
||||
|
||||
Returns:
|
||||
ForWorkflow对象,作为ConsciousnessNode执行Workflow中的WorkStep的结果
|
||||
"""
|
||||
pass
|
||||
|
||||
@overload
|
||||
async def _run(self, payload: ForSupervisoryInput) -> ForSupervisoryNode:
|
||||
"""
|
||||
_run方法
|
||||
该分支应当在workflow运行完全结束后,由WorkflowEngine进行调用!
|
||||
Args:
|
||||
payload: 应当包含整个Workflow的情况
|
||||
|
||||
Returns:
|
||||
ForSupervisory对象,作为ConsciousnessNode对于全工作流的技术性总结,返回给SupervisoryNode
|
||||
"""
|
||||
pass
|
||||
|
||||
async def _run(self, payload: Union[ForSupervisoryInput, ForWorkflowInput, ForWorkflowEngineInput]) -> Union[ForSupervisoryNode, ForWorkflow, ForWorkflowEngine]:
|
||||
try:
|
||||
self.agent.retries = 3
|
||||
if isinstance(payload, ForWorkflowEngineInput):
|
||||
deps = ConsciousnessNodeDeps(
|
||||
original_command=payload.original_command,
|
||||
workflow_template=payload.workflow_template,
|
||||
command="拆解原始命令变成一个工作流",
|
||||
available_skills=payload.available_skills
|
||||
)
|
||||
self.logger.debug("ConsciousnessNode: 开始生成工作流 (原生重试开启)")
|
||||
prompt = "根据original_command制定严密的可执行workflow"
|
||||
if payload.workflow_template:
|
||||
prompt += ",可以学习并参考workflow_template的设计理念"
|
||||
result = await self.agent.run(prompt, deps=deps)
|
||||
return result.output
|
||||
|
||||
elif isinstance(payload, ForWorkflowInput):
|
||||
deps = ConsciousnessNodeDeps(
|
||||
original_command=payload.original_command,
|
||||
command="完成workflow step中分配给意识节点的特定任务或指导"
|
||||
)
|
||||
self.logger.debug("ConsciousnessNode: 开始处理工作流节点任务 (原生重试开启)")
|
||||
result = await self.agent.run(f"处理此工作流步骤信息:\n{payload.workflow_step.model_dump_json()}",
|
||||
deps=deps)
|
||||
return result.output
|
||||
|
||||
elif isinstance(payload, ForSupervisoryInput):
|
||||
deps = ConsciousnessNodeDeps(
|
||||
original_command=payload.original_command,
|
||||
command="对于工作流整体执行结果进行检查,并且生成一份专业的技术性总结报告"
|
||||
)
|
||||
self.logger.debug("ConsciousnessNode: 开始生成技术总结报告 (原生重试开启)")
|
||||
result = await self.agent.run(f"基于以下工作流的执行记录,生成技术报告:\n{payload.workflow.model_dump_json()}",
|
||||
deps=deps)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
self.logger.exception(f"ConsciousnessNode 模型生成最终失败: {str(e)}")
|
||||
raise RuntimeError(f"ConsciousnessNode 无法完成任务: {str(e)}") from e
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from pretor.core.workflow.workflow import PretorWorkflow, WorkStep
|
||||
from pretor.utils.agent_model import ResponseModel, DepsModel, InputModel
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
#意识节点回复类
|
||||
class ConsciousnessNodeResponse(ResponseModel):
|
||||
"""Consciousness response model,是意识节点所有回复类型的父类"""
|
||||
pass
|
||||
|
||||
|
||||
class ForWorkflowEngine(ConsciousnessNodeResponse):
|
||||
"""生成workflow并放入WorkflowEngine"""
|
||||
workflow: PretorWorkflow = Field(..., description="生成好的符合规范的完整工作流对象。")
|
||||
reasoning: str = Field(..., description="生成此工作流的原因和思路简述。")
|
||||
|
||||
|
||||
class ForWorkflow(ConsciousnessNodeResponse):
|
||||
"""处理workflow中需要ConsciousnessNode的工作"""
|
||||
output: str = Field(..., description="对当前工作流步骤的具体处理结果或指导意见。")
|
||||
|
||||
|
||||
class ForSupervisoryNode(ConsciousnessNodeResponse):
|
||||
"""工作流完成后进行校验并返回给SupervisoryNode"""
|
||||
output: str = Field(..., description="为监控节点提供的全工作流执行情况的技术性总结报告。")
|
||||
|
||||
|
||||
class ConsciousnessNodeDeps(DepsModel):
|
||||
original_command: str
|
||||
workflow_template: str | None = None
|
||||
command: str
|
||||
available_skills: list[dict] | None = None
|
||||
|
||||
|
||||
class ConsciousnessNodeInput(InputModel):
|
||||
pass
|
||||
|
||||
|
||||
class ForWorkflowEngineInput(ConsciousnessNodeInput):
|
||||
workflow_template: str | None = None
|
||||
original_command: str
|
||||
available_skills: list[dict] | None = None
|
||||
|
||||
|
||||
class ForWorkflowInput(ConsciousnessNodeInput):
|
||||
workflow_step: WorkStep
|
||||
original_command: str
|
||||
|
||||
|
||||
class ForSupervisoryInput(ConsciousnessNodeInput):
|
||||
workflow: PretorWorkflow
|
||||
original_command: str
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Copyright 2026 zhaoxi826
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from .control_node import ControlNode
|
||||
__all__ = ["ControlNode"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue