Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa47a19e98 | |||
| 4aa1dab283 | |||
| 005ce566a8 | |||
| 6d658b4f4d | |||
| 9b73ae4db4 | |||
| c0fcbe2849 | |||
| b15eeb9e74 | |||
| 78d03388c0 | |||
| 6792ad5485 | |||
| ac363bc6bc | |||
| 0e57c5cf16 | |||
| d39c80743d | |||
| ad5da2a118 | |||
| b61524e5d9 | |||
| 6f1bc27101 | |||
| e3b8686d45 | |||
| 0bbd08638c | |||
| 32bdbe77ff | |||
| 6932294ddd | |||
| 8f1398c591 | |||
| f3a92a793e | |||
| 9a7a5edd6e | |||
| 457d12834f | |||
| 76a67e8237 | |||
| 80174acaae | |||
| a53ffebe0e | |||
| f04fef916f | |||
| 99520c69d7 | |||
| affe460180 | |||
| a83c5fa5bd | |||
| 6f6879dfab | |||
| 78bd6adc48 | |||
| c0e4fd34ae | |||
| ff1ede47a0 | |||
| ee9bbbf676 | |||
| 2d8571dee3 | |||
| 209ba45477 | |||
| b3ea4cd8d9 |
@@ -9,3 +9,5 @@ docker-compose.yml
|
||||
.env.template
|
||||
.env.example
|
||||
.idea
|
||||
docs/
|
||||
tmp/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgrespassword
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=kilostar
|
||||
SECRET_KEY=mysecretkey123456789
|
||||
+18
-2
@@ -2,5 +2,21 @@ POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgrespassword
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=pretor
|
||||
SECRET_KEY=114514
|
||||
POSTGRES_DB=kilostar
|
||||
# JWT 签名密钥,必须填写一个高熵随机字符串,建议生成命令:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# 留空或填 "secret" / "114514" / "changethiskey12345" 等弱值会被拒绝。
|
||||
SECRET_KEY=
|
||||
|
||||
# 数据加密密钥(Fernet),用于加密 provider apikey / tool config 中的敏感字段。
|
||||
# 与 SECRET_KEY 独立,生成命令:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
KILOSTAR_SECRET_KEY=
|
||||
|
||||
# CORS 允许的源(逗号分隔,默认 "*" 允许所有源但禁用 credentials)。
|
||||
# 生产建议显式列出:KILOSTAR_CORS_ORIGINS=https://app.example.com,https://admin.example.com
|
||||
KILOSTAR_CORS_ORIGINS=*
|
||||
|
||||
# 日志格式:开发默认彩色 console;生产可设为 json 以便 ELK/Loki 采集。
|
||||
# KILOSTAR_LOG_FORMAT=json
|
||||
# KILOSTAR_LOG_LEVEL=INFO
|
||||
|
||||
+24
-10
@@ -1,11 +1,25 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
# 项目运行时数据:默认全部忽略,仅显式开放需要纳入版本控制的子目录
|
||||
data/*
|
||||
!data/toolset/
|
||||
!data/plugin/
|
||||
data/plugin/skill/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.idea
|
||||
# 插件运行时 SQLite / 状态
|
||||
data/plugin/*/_data/
|
||||
|
||||
tmp/
|
||||
.env
|
||||
|
||||
.idea/
|
||||
.venv/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
data/plugin/*/frontend/dist/
|
||||
data/plugin/*/frontend/node_modules/
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
+18
-1
@@ -2,21 +2,36 @@
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Install dependencies and build the static assets
|
||||
# 主前端
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# 重型插件前端:每个插件独立 vite lib build,输出 dist/plugin-element.js + wc-manifest.json
|
||||
# 这一段通过 sh 循环:复制源码 → npm install → build。任何一个插件失败都会让镜像构建失败,
|
||||
# 用 || true 兜底过于宽松——这里选择硬失败,便于第一时间发现 build 问题。
|
||||
COPY data/plugin /app/data/plugin
|
||||
RUN set -e; \
|
||||
for d in /app/data/plugin/*/frontend; do \
|
||||
if [ -f "$d/package.json" ]; then \
|
||||
echo "==> Building plugin frontend: $d"; \
|
||||
cd "$d" && npm install && npm run build; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Stage 2: Build the Python backend and serve
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (for building PostgreSQL drivers and other native extensions)
|
||||
# nodejs/npm are needed for stdio-mode MCP servers (e.g. npx -y @modelcontextprotocol/...)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv package manager
|
||||
@@ -33,6 +48,8 @@ COPY . .
|
||||
|
||||
# Copy the built frontend static assets from Stage 1
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
||||
# 重型插件前端 build 产物(让 /plugin-ui/<name>/ 静态挂载有内容可挂)
|
||||
COPY --from=frontend-builder /app/data/plugin /app/data/plugin
|
||||
|
||||
# Expose FastAPI and Ray Dashboard ports
|
||||
EXPOSE 8000 8265
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
run:
|
||||
uv run main.py
|
||||
|
||||
clean-cache:
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# Alembic 数据库迁移:m="message" 控制 revision 描述
|
||||
db-revision:
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
db-upgrade:
|
||||
uv run alembic upgrade head
|
||||
|
||||
db-downgrade:
|
||||
uv run alembic downgrade -1
|
||||
|
||||
db-history:
|
||||
uv run alembic history --verbose
|
||||
|
||||
db-stamp-head:
|
||||
uv run alembic stamp head
|
||||
|
||||
test:
|
||||
docker compose down
|
||||
docker rmi kilostar-kilostar:latest
|
||||
docker compose up -d --build
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
<div align="center">
|
||||
|
||||
# KiloStar
|
||||
|
||||
An open-source general-purpose multi-agent collaboration platform
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://docs.ray.io/)
|
||||
[](https://ai.pydantic.dev/)
|
||||
[](LICENSE)
|
||||
|
||||
[中文](./README.md) | [**Changelog**](./changelogs/CHANGELOG.md) | [**Roadmap**](./changelogs/ROADMAP.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**KiloStar** is an open-source general-purpose multi-agent collaboration platform that provides a complete capability stack covering model integration, agent orchestration, workflow execution, and plugin extension. The system uses **Ray** for distributed execution, **Pydantic-AI** for type-safe agent development, and exposes a unified API surface through **FastAPI**.
|
||||
|
||||
The platform supports both cloud API models and locally fine-tuned models, ships with built-in core nodes for multi-agent collaboration (Regulatory, Consciousness, Control, Growth), and provides a **heavy plugin** mechanism that lets users reshape the platform into purpose-built agent applications.
|
||||
|
||||
> **Current version**: `v0.1.1-alpha`
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local fine-tuned models as first-class citizens**: Built-in vLLM adapter; locally fine-tuned models are interchangeable with cloud API models at the call site, allowing different agent nodes to bind different local models.
|
||||
- **Heavy plugin mechanism**: Plugins can ship their own frontend pages, tool sets, and API endpoints — turning KiloStar into specialized agent applications such as coding assistants, learning helpers, or data analysis tools.
|
||||
- **Multi-agent collaboration core**: Four system node types (Regulatory / Consciousness / Control / Growth) plus dynamically spawned Worker individuals, with task decomposition, scheduling, and supervision built in.
|
||||
- **standalone / distributed dual mode**: Zero-dependency single-machine startup; horizontal scaling on demand. Business code is identical across both modes.
|
||||
- **Private deployment friendly**: Every component runs inside the user's own environment without mandatory third-party dependencies.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Core Capabilities
|
||||
|
||||
### 🧠 Multi-Agent Collaboration
|
||||
- **System node specialization**: Regulatory, Consciousness, Control, and Growth nodes each cover a distinct responsibility
|
||||
- **Worker dynamic spawning**: On-demand creation of Ordinary / Skill / Special Worker individuals
|
||||
- **Strongly-typed communication**: Pydantic-AI constrains LLM output to structured data, eliminating the unstructured-text black box in multi-agent flows
|
||||
|
||||
### 🚀 Distributed Execution
|
||||
- **Ray Actor model**: Cross-process and cross-machine collaboration for high-concurrency workloads
|
||||
- **Heterogeneous resource labels**: `kilostar_node_cpu` / `core` / `gpu` route Workers to the right physical nodes
|
||||
- **Standalone mode**: Zero external dependencies for single-machine startup; shares the same business code as distributed mode
|
||||
|
||||
### 🔄 Workflow Engine
|
||||
- **pydantic-graph driven**: Directed-graph workflow orchestration with conditional branching and loops
|
||||
- **Cross-process persistence**: PostgreSQL state snapshots enable workflow resume after interruption
|
||||
- **Human-in-the-Loop (HITL)**: Built-in HumanApproval node with idempotent resume semantics
|
||||
|
||||
### 🧩 Plugin System
|
||||
- **Tool plugins**: Standard tool calls; MCP protocol support for third-party services
|
||||
- **Skill (compatible with Anthropic Agent Skills spec)**: Installed and parsed via [viceroy](https://github.com/zhaoxi826/viceroy), loaded on demand at runtime
|
||||
- **Heavy plugins (planned)**: Vertical application packages with dedicated UI that reshape KiloStar into specialized platforms
|
||||
|
||||
### 🛡️ Security
|
||||
- **JWT authentication**: All API endpoints (including SSE streams) require Bearer Token auth
|
||||
- **Ownership enforcement**: Workflow / chat resources are user-bound; cross-user access returns 403
|
||||
- **fetch-based SSE**: Token is transmitted via the `Authorization` header, never exposed in URLs
|
||||
|
||||
### 📦 Companion Subprojects
|
||||
|
||||
| Project | Codename | Purpose | Status |
|
||||
|:--|:--|:--|:--|
|
||||
| [kilostar-viceroy](https://github.com/zhaoxi826/viceroy) | Viceroy | Skill installation and cluster-wide distribution | ✅ Released |
|
||||
| [kilostar-stardomain](./subprojects/stardomain) | Stardomain | Sandbox execution for Skill / plugin scripts | In progress |
|
||||
| [kilostar-thought](https://github.com/zhaoxi826/thought) | Thought | Augmented memory system for agents | In progress |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
POSTGRES_DB: kilostar
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d kilostar"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
kilostar:
|
||||
image: zhaoxi5699/kilostar:v0.1.1alpha
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8265:8265"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgrespassword
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_DB=kilostar
|
||||
- SECRET_KEY=changethiskey12345
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Once running:
|
||||
- Web Console: http://localhost:8000
|
||||
- Ray Dashboard: http://localhost:8265
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
uv sync
|
||||
cp config/.env.example .env # Configure database and secret key
|
||||
uv run python main.py
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
KiloStar/
|
||||
├── main.py # App entrypoint (FastAPI + Ray init)
|
||||
├── pyproject.toml # Python dependencies & metadata
|
||||
├── Dockerfile / docker-compose.yml # Container deployment
|
||||
├── alembic/ # Database migrations
|
||||
├── config/ # Environment config templates
|
||||
├── kilostar/ # Backend core package
|
||||
│ ├── api/ # FastAPI route layer
|
||||
│ │ ├── system.py # /health system health checks
|
||||
│ │ ├── workflow.py # /workflow CRUD + SSE + resume
|
||||
│ │ ├── chat.py # /chat session management
|
||||
│ │ ├── agent.py # /agent Worker management
|
||||
│ │ └── resource.py # /resource Skill/Toolset mgmt
|
||||
│ ├── core/ # Core business logic
|
||||
│ │ ├── individual/ # Agent node implementations
|
||||
│ │ │ ├── consciousness_node/ # Task planning
|
||||
│ │ │ ├── regulatory_node/ # Quality oversight
|
||||
│ │ │ ├── control_node/ # Deprecated (name reserved for future remote-probe node)
|
||||
│ │ │ └── growth_node/ # Capability expansion
|
||||
│ │ ├── work/ # Work execution layer
|
||||
│ │ │ ├── workflow/ # Workflow engine (pydantic-graph)
|
||||
│ │ │ ├── chat/ # Chat processing
|
||||
│ │ │ └── task/ # Single-task execution
|
||||
│ │ ├── global_state_machine/ # Global state (Provider/Config)
|
||||
│ │ ├── global_workflow_manager/ # Workflow message queue Actor
|
||||
│ │ └── postgres_database/ # PostgreSQL DAO layer
|
||||
│ ├── adapter/ # Model adapters (OpenAI/vLLM/...)
|
||||
│ ├── plugin/ # Tool plugins
|
||||
│ │ └── tool_plugin/ # Tavily / FileReader / Approval
|
||||
│ ├── utils/ # Utilities
|
||||
│ │ ├── access.py # JWT authentication
|
||||
│ │ ├── ray_hook.py # Ray Actor handle retrieval
|
||||
│ │ └── check_user/ # Role-based authorization
|
||||
│ ├── worker_cluster/ # Worker cluster management
|
||||
│ └── worker_individual/ # Worker individual lifecycle
|
||||
├── frontend/ # React frontend (Vite + Tailwind)
|
||||
│ └── src/
|
||||
│ ├── api/ # Axios client + SSE wrapper
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── Chat/ # Workflow panel + live graph
|
||||
│ │ ├── Agent/ # Worker/Provider management
|
||||
│ │ ├── Plugin/ # Skill/Tool configuration
|
||||
│ │ └── Settings/ # System settings
|
||||
│ ├── i18n/ # Internationalization (zh/en)
|
||||
│ ├── store/ # Zustand state management
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── tests/ # Test suite (249+ cases)
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration smoke tests
|
||||
└── docs/ # Design documents
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run pytest tests -q
|
||||
|
||||
# Unit tests only
|
||||
uv run pytest tests/unit -q
|
||||
|
||||
# Integration tests
|
||||
uv run pytest tests/integration -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [Apache License 2.0](LICENSE).
|
||||
@@ -1,74 +1,95 @@
|
||||
<div align="center">
|
||||
|
||||
# Pretor (执政官)
|
||||
# KiloStar (千星)
|
||||
|
||||
一款基于 Python 的分布式多 Agent 协作系统
|
||||
开源通用多 Agent 协作平台
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://docs.ray.io/)
|
||||
[](https://ai.pydantic.dev/)
|
||||
[](LICENSE)
|
||||
|
||||
[**项目架构**](./docs/ARCHITECTURE.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**未来展望**](./changelogs/ROADMAP.md)
|
||||
[English](./README-EN.md) | [**更新日志**](./changelogs/CHANGELOG.md) | [**项目结构**](docs/STRUCTURE.md) | [**未来展望**](./changelogs/ROADMAP.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
**Pretor** 是一款基于 **Ray** 构建的下一代分布式多 Agent 协作系统。项目采用“中心监管 + 边缘执行”的异构集群模式,通过大参数 MoE 模型进行高层逻辑推理,并协同微调后的轻量化模型高效完成具体任务。借助 **Pydantic-AI** 提供的强类型约束与 FastAPI 异步网关,Pretor 实现了任务从需求拆解、资源调度到自动化执行的全链路闭环,为个人提供可靠的人工智能助手服务。
|
||||
|
||||
## 简介
|
||||
|
||||
**KiloStar** 是一个开源的通用多 Agent 协作平台,提供从模型接入、Agent 编排、工作流执行到插件扩展的完整能力栈。系统基于 **Ray** 实现分布式执行,基于 **Pydantic-AI** 提供类型安全的 Agent 开发框架,并通过 **FastAPI** 网关对外暴露统一接口。
|
||||
|
||||
平台同时支持云端 API 模型与本地微调模型,内置多 Agent 协作的核心节点(监管、意识、控制、生长),并通过**重型插件**机制允许使用者把平台改造成面向具体场景的专用 Agent 应用。
|
||||
|
||||
> **当前版本**:`v0.1.1-alpha`
|
||||
|
||||
## 项目特色
|
||||
|
||||
- **本地微调小模型一等公民**:内置 vLLM 适配,支持将本地微调模型部署为系统中的 Agent 节点,与云端 API 模型在调用层面对等
|
||||
- **重型插件机制**:插件可附带独立前端页面、工具组与 API 接口,将 KiloStar 改造为编程辅助、学习助手、数据分析等专用 Agent 应用
|
||||
- **多 Agent 协作内核**:监管 / 意识 / 控制 / 生长四类系统节点 + 动态派生的 Worker 个体,原生支持任务拆解、调度、监督的分工模式
|
||||
- **分布式与单机统一**:standalone 与 distributed 双模式共享同一套代码,单机零依赖起步,集群按需横向扩展
|
||||
- **私有化部署友好**:所有组件可在用户自有环境内运行,不强制依赖任何第三方服务
|
||||
|
||||
---
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🧠 异构协作体系
|
||||
- **多智能体集群**:内置主管 (Supervisory)、意识 (Consciousness)、控制 (Control) 三大核心节点,实现比单 Agent 系统更严谨的决策链。
|
||||
- **Worker 动态派生**:根据任务需求动态拉起 Ordinary 或 Skill 类型的 Worker Individual,实现资源的按需分配。
|
||||
## ✨ 核心能力
|
||||
|
||||
### 🚀 分布式性能保障
|
||||
- **Ray 驱动**:底层基于 Ray 构建,支持跨进程、跨机器的 Actor 通讯,轻松应对高并发任务流。
|
||||
- **本地化优先**:深度适配 **vLLM**,支持本地私有化模型部署,在保障隐私的同时大幅降低 API 调用成本。
|
||||
### 🧠 多 Agent 协作
|
||||
- **核心节点分工**:监管 (Regulatory)、意识 (Consciousness)、控制 (Control)、生长 (Growth) 四类系统节点
|
||||
- **Worker 动态派生**:根据任务需求拉起 Ordinary / Skill / Special 三种 Worker 个体
|
||||
- **强类型通信**:基于 Pydantic-AI 将 LLM 输出约束为结构化数据,避免多 Agent 协作中的非结构化文本黑盒
|
||||
|
||||
### 🛠️ 工业级工程设计
|
||||
- **强类型契约**:基于 Pydantic-AI 实现 Tool 与 Agent 的接口定义,确保 AI 输出的确定性与安全性。
|
||||
- **自动化流**:内置工作流引擎 (Workflow Engine),实现从需求发现到自动化执行的闭环。
|
||||
### 🚀 分布式执行
|
||||
- **Ray Actor 模型**:跨进程、跨机器协作,支持高并发任务流
|
||||
- **异构资源标签**:`kilostar_node_cpu` / `core` / `gpu` 调度不同 Worker 到合适节点
|
||||
- **standalone 模式**:单机零依赖起步,与分布式模式共享同一套业务代码
|
||||
|
||||
### 📦 Pretor 生态子项目 (Sub-projects)
|
||||
### 🔄 工作流引擎
|
||||
- **pydantic-graph 驱动**:基于有向图的工作流编排,支持条件分支与循环
|
||||
- **跨进程持久化**:PostgreSQL 状态快照,支持 workflow 中断后恢复(resume)
|
||||
- **人工介入 (HITL)**:内置 HumanApproval 节点,支持审批挂起与幂等恢复
|
||||
|
||||
| 项目名称 | 代号 | 功能定位 | 当前状态 |
|
||||
|:-----------------------------------------------------------|:--------| :--- | :--- |
|
||||
| **[pretor-viceroy](https://github.com/zhaoxi826/viceroy)** | **总督** | **资源管理**:负责系统 Skill 的动态安装、元数据解析与全集群分发。 | ✅ 已发布 |
|
||||
| **pretor-stardomain** | **星域** | **安全沙箱**:为 Agent 自动生成的代码提供轻量化的隔离运行环境,防止逃逸。 | 📅 规划中 |
|
||||
| **pretor-explorer** | **探索者** | **网页感知**:自动化爬虫引擎,赋予智能体实时互联网信息搜索与内容抓取能力。 | 📅 规划中 |
|
||||
| **pretor-pioneer** | **先驱者** | **知识增强**:RAG 检索增强引擎,管理私有知识库的向量化、索引与精准检索。 | 📅 规划中 |
|
||||
### 🧩 插件体系
|
||||
- **工具插件**:标准 Tool 调用,支持 MCP 协议接入第三方服务
|
||||
- **Skill(兼容 Anthropic Agent Skills 标准)**:通过 [viceroy](https://github.com/zhaoxi826/viceroy) 安装解析,运行时按需加载
|
||||
- **重型插件(Organization)**:带独立工具集、多 Agent 团队与前端面板的垂直应用包,以"部门"身份接入系统内阁
|
||||
|
||||
### 🛡️ 安全设计
|
||||
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
|
||||
- **归属校验**:workflow / chat 资源严格绑定 user_id,跨用户访问返回 403
|
||||
- **fetch-based SSE**:Token 走 Authorization header,不暴露在 URL 中
|
||||
|
||||
### 📦 生态子项目
|
||||
|
||||
| 项目 | 代号 | 功能 | 状态 |
|
||||
|:--|:--|:--|:--|
|
||||
| [kilostar-viceroy](https://github.com/zhaoxi826/viceroy) | 总督 | Skill 动态安装与全集群分发 | ✅ 已发布 |
|
||||
| [kilostar-stardomain](./subprojects/stardomain) | 星域 | Skill / 插件脚本沙箱执行 | 开发中 |
|
||||
| [kilostar-thought](https://github.com/zhaoxi826/thought) | 思绪 | Agent 增强记忆系统 | 开发中 |
|
||||
|
||||
---
|
||||
## 🚀 快速开始 (Quick Start)
|
||||
|
||||
> **当前版本**:`v0.1.0-alpha` (开发预览版)
|
||||
> 本项目目前处于快速迭代阶段,欢迎提交 Issue 或 Pull Request。
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式一:使用 Docker Compose (推荐)
|
||||
这是部署 **Pretor 应用** 及其配套 **PostgreSQL 数据库** 最简单、最完整的方式。
|
||||
### Docker Compose(推荐)
|
||||
|
||||
1. **准备配置文件**:在本地创建一个目录,并新建 `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: pretor_db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
POSTGRES_DB: pretor
|
||||
POSTGRES_DB: kilostar
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d kilostar"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pretor:
|
||||
image: zhaoxi5699/pretor:v0.1.0alpha
|
||||
container_name: pretor
|
||||
kilostar:
|
||||
image: zhaoxi5699/kilostar:v0.1.1alpha
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8265:8265"
|
||||
@@ -80,31 +101,53 @@
|
||||
- POSTGRES_PASSWORD=postgrespassword
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_DB=pretor
|
||||
- SECRET_KEY=changethiskey12345 # 请在生产环境中修改此密钥
|
||||
- POSTGRES_DB=kilostar
|
||||
- SECRET_KEY=changethiskey12345
|
||||
```
|
||||
|
||||
2. **启动服务**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 方式二:使用 Docker
|
||||
1. **启动服务**
|
||||
启动后访问:
|
||||
- Web 控制台:http://localhost:8000
|
||||
- Ray Dashboard:http://localhost:8265
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name pretor \
|
||||
-p 8000:8000 \
|
||||
-p 8265:8265 \
|
||||
-e POSTGRES_HOST=你的数据库IP \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgrespassword \
|
||||
-e POSTGRES_DB=pretor \
|
||||
-e SECRET_KEY=your_secret_key \
|
||||
zhaoxi5699/pretor:v0.1.0alpha
|
||||
# 后端
|
||||
uv sync
|
||||
cp config/.env.example .env # 编辑数据库和密钥配置
|
||||
uv run python main.py
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
## 🔍 访问与验证
|
||||
服务启动后,可以通过以下地址进行操作:
|
||||
- Web 控制台 / API 文档: http://localhost:8000
|
||||
- Ray 任务仪表盘: http://localhost:8265
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
详见 [docs/STRUCTURE.md](docs/STRUCTURE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 全量测试
|
||||
uv run pytest tests -q
|
||||
|
||||
# 仅单元测试
|
||||
uv run pytest tests/unit -q
|
||||
|
||||
# 集成测试(标记 integration)
|
||||
uv run pytest tests/integration -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源协议
|
||||
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源。
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本目录
|
||||
script_location = alembic
|
||||
|
||||
# 默认时间戳模板
|
||||
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# 时区
|
||||
timezone = Asia/Shanghai
|
||||
|
||||
# Path to alembic version_path(多环境可以分目录)
|
||||
version_locations = alembic/versions
|
||||
|
||||
# 数据库 URL:留空,由 env.py 从环境变量动态读取
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
# 默认不做格式化;如需可启用 black
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 88 REVISION_SCRIPT_FILENAME
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Alembic 迁移环境。
|
||||
|
||||
设计要点:
|
||||
1. 数据库 URL 从 ``POSTGRES_*`` 环境变量动态拼装,不污染 ``alembic.ini``;
|
||||
2. 复用项目本身的 ORM metadata:``BaseDataModel.metadata`` 让 autogenerate
|
||||
能识别全部表;
|
||||
3. 与运行期保持一致使用 ``asyncpg`` 异步驱动 —— 通过 ``async_engine_from_config``
|
||||
建立 AsyncEngine,再借 ``run_sync`` 把 alembic 的 sync migration 执行入口
|
||||
挂载进去。这样无需额外引入 psycopg 等同步驱动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from kilostar.core.postgres_database.model.base import BaseDataModel # noqa: E402
|
||||
from kilostar.core.postgres_database import model as _model_pkg # noqa: F401,E402
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
def _build_db_url() -> str:
|
||||
"""从 ``POSTGRES_*`` 环境变量拼装一个 asyncpg URL。"""
|
||||
user = os.environ.get("POSTGRES_USER", "postgres")
|
||||
password = os.environ.get("POSTGRES_PASSWORD", "postgrespassword")
|
||||
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
|
||||
port = os.environ.get("POSTGRES_PORT", "5432")
|
||||
db = os.environ.get("POSTGRES_DB", "kilostar")
|
||||
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}"
|
||||
|
||||
|
||||
config.set_main_option("sqlalchemy.url", _build_db_url())
|
||||
|
||||
target_metadata = BaseDataModel.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""离线模式:不连库,直接把 SQL 渲染到 stdout。"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def _do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def _run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(_do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(_run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""initial baseline
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-05-31 00:00:00
|
||||
|
||||
这是 Alembic 接入时的占位 baseline。
|
||||
|
||||
- 全新部署:``alembic upgrade head`` 会跑过这条 no-op,
|
||||
然后由后续 ``alembic revision --autogenerate`` 生成真正的建表脚本,
|
||||
或在首次部署时由应用 ``Base.metadata.create_all`` 直接建表,再 ``alembic stamp head``。
|
||||
- 已有数据库:直接 ``alembic stamp 0001_initial`` 标定基线,再做后续 autogenerate。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,49 @@
|
||||
"""add workflow_graph_state and system_event_log tables
|
||||
|
||||
Revision ID: 0002_graph_and_logs
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-06-02 00:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_graph_and_logs"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_graph_state",
|
||||
sa.Column("trace_id", sa.String(64), primary_key=True, comment="对应的工作流 Trace ID"),
|
||||
sa.Column("history", postgresql.JSONB(), nullable=False, server_default="[]", comment="pydantic_graph history JSON"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"system_event_log",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("trace_id", sa.String(64), nullable=False, comment="关联的工作流 trace_id"),
|
||||
sa.Column("event_type", sa.String(50), nullable=False, comment="事件类型"),
|
||||
sa.Column("level", sa.String(10), nullable=False, server_default="info", comment="日志级别"),
|
||||
sa.Column("node_name", sa.String(100), nullable=True, comment="相关节点名称"),
|
||||
sa.Column("message", sa.Text, nullable=False, comment="日志消息正文"),
|
||||
sa.Column("extra_data", postgresql.JSONB(), nullable=True, comment="附加元数据"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_index("ix_system_event_log_trace_id", "system_event_log", ["trace_id"])
|
||||
op.create_index("ix_system_event_log_event_type", "system_event_log", ["event_type"])
|
||||
op.create_index("ix_system_event_log_level", "system_event_log", ["level"])
|
||||
op.create_index("ix_system_event_log_created_at", "system_event_log", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("system_event_log")
|
||||
op.drop_table("workflow_graph_state")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""add persona_template table, node_affinity and template_origin_id to base_individual
|
||||
|
||||
Revision ID: 0003_persona_template
|
||||
Revises: 0002_graph_and_logs
|
||||
Create Date: 2026-06-04 00:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003_persona_template"
|
||||
down_revision: Union[str, None] = "0002_graph_and_logs"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"persona_template",
|
||||
sa.Column("template_id", sa.String(64), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("system_prompt", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("agent_type", sa.String(32), nullable=False, server_default="ordinary"),
|
||||
sa.Column("provider_title", sa.String(50), nullable=True),
|
||||
sa.Column("model_id", sa.String(100), nullable=True),
|
||||
sa.Column("tools", postgresql.JSONB(), nullable=True, server_default="'[]'::jsonb"),
|
||||
sa.Column("tags", postgresql.JSONB(), nullable=True, server_default="'[]'::jsonb"),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("owner_id", sa.String(64), nullable=True),
|
||||
)
|
||||
op.create_index("ix_persona_template_name", "persona_template", ["name"])
|
||||
op.create_index("ix_persona_template_owner_id", "persona_template", ["owner_id"])
|
||||
|
||||
op.add_column(
|
||||
"base_individual",
|
||||
sa.Column("node_affinity", sa.String(32), nullable=False, server_default="cpu"),
|
||||
)
|
||||
op.add_column(
|
||||
"base_individual",
|
||||
sa.Column("template_origin_id", sa.String(64), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_base_individual_template_origin",
|
||||
"base_individual",
|
||||
"persona_template",
|
||||
["template_origin_id"],
|
||||
["template_id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_base_individual_template_origin_id",
|
||||
"base_individual",
|
||||
["template_origin_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_base_individual_template_origin_id", "base_individual")
|
||||
op.drop_constraint("fk_base_individual_template_origin", "base_individual", type_="foreignkey")
|
||||
op.drop_column("base_individual", "template_origin_id")
|
||||
op.drop_column("base_individual", "node_affinity")
|
||||
|
||||
op.drop_index("ix_persona_template_owner_id", "persona_template")
|
||||
op.drop_index("ix_persona_template_name", "persona_template")
|
||||
op.drop_table("persona_template")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add custom_system_prompt to system_node_config
|
||||
|
||||
Revision ID: 0004_system_node_custom_prompt
|
||||
Revises: 0003_persona_template
|
||||
Create Date: 2026-06-04 00:01:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_system_node_custom_prompt"
|
||||
down_revision: Union[str, None] = "0003_persona_template"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"system_node_config",
|
||||
sa.Column("custom_system_prompt", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_node_config", "custom_system_prompt")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""system_node_display_name
|
||||
|
||||
Revision ID: 0005_system_node_display_name
|
||||
Revises: 0004_system_node_custom_prompt
|
||||
Create Date: 2026-06-04
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0005_system_node_display_name"
|
||||
down_revision: Union[str, None] = "0004_system_node_custom_prompt"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"system_node_config",
|
||||
sa.Column("display_name", sa.String(100), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_node_config", "display_name")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""simplify persona_template to name + system_prompt
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-06-04
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0006"
|
||||
down_revision = "0005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("persona_template", "description")
|
||||
op.drop_column("persona_template", "agent_type")
|
||||
op.drop_column("persona_template", "provider_title")
|
||||
op.drop_column("persona_template", "model_id")
|
||||
op.drop_column("persona_template", "tools")
|
||||
op.drop_column("persona_template", "tags")
|
||||
op.drop_column("persona_template", "is_builtin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("persona_template", sa.Column("description", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("persona_template", sa.Column("agent_type", sa.String(32), nullable=False, server_default="ordinary"))
|
||||
op.add_column("persona_template", sa.Column("provider_title", sa.String(50), nullable=True))
|
||||
op.add_column("persona_template", sa.Column("model_id", sa.String(100), nullable=True))
|
||||
op.add_column("persona_template", sa.Column("tools", sa.JSON(), server_default="[]"))
|
||||
op.add_column("persona_template", sa.Column("tags", sa.JSON(), server_default="[]"))
|
||||
op.add_column("persona_template", sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""persona_id FK refactor: agents and system nodes reference persona_template
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-06-05
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import table, column
|
||||
from ulid import ULID
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Data migration: create persona records from existing system_prompts
|
||||
conn = op.get_bind()
|
||||
|
||||
# Migrate base_individual.system_prompt → persona_template records
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT agent_id, system_prompt, owner_id FROM base_individual "
|
||||
"WHERE system_prompt IS NOT NULL AND system_prompt != ''"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
template_id = str(ULID())
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO persona_template (template_id, name, system_prompt, owner_id) "
|
||||
"VALUES (:tid, :name, :prompt, :owner)"
|
||||
),
|
||||
{"tid": template_id, "name": f"auto_{row[0][:8]}", "prompt": row[1], "owner": row[2]},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE base_individual SET template_origin_id = :tid WHERE agent_id = :aid"
|
||||
),
|
||||
{"tid": template_id, "aid": row[0]},
|
||||
)
|
||||
|
||||
# Migrate system_node_config.custom_system_prompt → persona_template
|
||||
node_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT node_name, custom_system_prompt FROM system_node_config "
|
||||
"WHERE custom_system_prompt IS NOT NULL AND custom_system_prompt != ''"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
# Add persona_id column to system_node_config before populating
|
||||
op.add_column(
|
||||
"system_node_config",
|
||||
sa.Column("persona_id", sa.String(64), sa.ForeignKey("persona_template.template_id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
|
||||
for row in node_rows:
|
||||
template_id = str(ULID())
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO persona_template (template_id, name, system_prompt, owner_id) "
|
||||
"VALUES (:tid, :name, :prompt, NULL)"
|
||||
),
|
||||
{"tid": template_id, "name": f"node_{row[0]}", "prompt": row[1]},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE system_node_config SET persona_id = :tid WHERE node_name = :nn"
|
||||
),
|
||||
{"tid": template_id, "nn": row[0]},
|
||||
)
|
||||
|
||||
# 2. Rename template_origin_id → persona_id on base_individual
|
||||
op.alter_column("base_individual", "template_origin_id", new_column_name="persona_id")
|
||||
|
||||
# 3. Drop old columns
|
||||
op.drop_column("base_individual", "system_prompt")
|
||||
op.drop_column("system_node_config", "custom_system_prompt")
|
||||
@@ -0,0 +1,149 @@
|
||||
"""toolset system refactor: add is_system/category to custom_toolset, seed system toolsets, migrate node tools to toolset_ids
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-06-05
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from ulid import ULID
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# 系统预置 toolset 的固定 ID(前端可能引用)
|
||||
SYSTEM_TOOLSETS = [
|
||||
{
|
||||
"toolset_id": "system_basic",
|
||||
"name": "系统基础工具集",
|
||||
"description": "文件读写、搜索、Python/Shell 执行等基础能力",
|
||||
"category": "system_basic",
|
||||
"tools": [
|
||||
"file_reader",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"search_file",
|
||||
"python_executor",
|
||||
"shell_executor",
|
||||
],
|
||||
},
|
||||
{
|
||||
"toolset_id": "system_chat",
|
||||
"name": "系统对话工具集",
|
||||
"description": "对话场景专用工具,例如向用户发送文件附件",
|
||||
"category": "system_chat",
|
||||
"tools": ["send_file"],
|
||||
},
|
||||
{
|
||||
"toolset_id": "system_workflow",
|
||||
"name": "系统工作流工具集",
|
||||
"description": "工作流场景专用工具,例如人工审批",
|
||||
"category": "system_workflow",
|
||||
"tools": ["approval"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 加 is_system / category 列
|
||||
op.add_column(
|
||||
"custom_toolset",
|
||||
sa.Column(
|
||||
"is_system",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"custom_toolset",
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.String(32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'user'"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_custom_toolset_is_system", "custom_toolset", ["is_system"]
|
||||
)
|
||||
op.create_index("ix_custom_toolset_category", "custom_toolset", ["category"])
|
||||
|
||||
# 2. 种子三个系统工具集
|
||||
conn = op.get_bind()
|
||||
for ts in SYSTEM_TOOLSETS:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO custom_toolset
|
||||
(toolset_id, name, description, owner_id, tools, is_system, category)
|
||||
VALUES (:tid, :name, :desc, NULL, CAST(:tools AS JSONB), true, :cat)
|
||||
ON CONFLICT (toolset_id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tid": ts["toolset_id"],
|
||||
"name": ts["name"],
|
||||
"desc": ts["description"],
|
||||
"tools": _json_dump(ts["tools"]),
|
||||
"cat": ts["category"],
|
||||
},
|
||||
)
|
||||
|
||||
# 3. 迁移 system_node_config.tools:旧值是 tool_name 列表,按工具名归属推断 toolset_id
|
||||
_migrate_tools_to_toolsets(conn, "system_node_config", "node_name")
|
||||
# 4. 同样迁移 specialist_individual / ordinary_individual
|
||||
_migrate_tools_to_toolsets(conn, "specialist_individual", "agent_id")
|
||||
_migrate_tools_to_toolsets(conn, "ordinary_individual", "agent_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_custom_toolset_category", table_name="custom_toolset")
|
||||
op.drop_index("ix_custom_toolset_is_system", table_name="custom_toolset")
|
||||
op.drop_column("custom_toolset", "category")
|
||||
op.drop_column("custom_toolset", "is_system")
|
||||
# 注意:tools 字段语义变更不可逆——保留 toolset_ids,不还原
|
||||
|
||||
|
||||
def _json_dump(value) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
_TOOL_TO_TOOLSET = {
|
||||
"file_reader": "system_basic",
|
||||
"write_file": "system_basic",
|
||||
"edit_file": "system_basic",
|
||||
"search_file": "system_basic",
|
||||
"python_executor": "system_basic",
|
||||
"shell_executor": "system_basic",
|
||||
"send_file": "system_chat",
|
||||
"approval": "system_workflow",
|
||||
}
|
||||
|
||||
|
||||
def _migrate_tools_to_toolsets(conn, table: str, pk_col: str) -> None:
|
||||
"""把表里 ``tools`` 字段(旧:tool_name 列表)转换为 toolset_id 列表。
|
||||
|
||||
第三方/未知工具名直接丢弃(这些应该由用户自定义的 toolset 承载,迁移期不识别)。
|
||||
"""
|
||||
rows = conn.execute(
|
||||
sa.text(f"SELECT {pk_col}, tools FROM {table} WHERE tools IS NOT NULL")
|
||||
).fetchall()
|
||||
for pk, old_tools in rows:
|
||||
if not old_tools:
|
||||
continue
|
||||
toolset_ids = sorted({
|
||||
_TOOL_TO_TOOLSET[t] for t in old_tools if t in _TOOL_TO_TOOLSET
|
||||
})
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"UPDATE {table} SET tools = CAST(:val AS JSONB) WHERE {pk_col} = :pk"
|
||||
),
|
||||
{"val": _json_dump(list(toolset_ids)), "pk": pk},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add org_task and org_task_event tables for heavy plugin system
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-06-16
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_task",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.String(64), unique=True, index=True),
|
||||
sa.Column("org_name", sa.String(128), index=True),
|
||||
sa.Column("status", sa.String(20), index=True, server_default="pending"),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("result", sa.Text(), nullable=True),
|
||||
sa.Column("context", JSONB, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"org_task_event",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.String(64), index=True),
|
||||
sa.Column("event_type", sa.String(30), index=True),
|
||||
sa.Column("payload", JSONB, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add model_settings JSONB column to provider table
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"provider",
|
||||
sa.Column("model_settings", JSONB, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("provider", "model_settings")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add task table for regulatory_node short tasks
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"task",
|
||||
sa.Column("task_id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), index=True, nullable=False),
|
||||
sa.Column("chat_id", sa.String(64), index=True, nullable=True),
|
||||
sa.Column("command", sa.Text(), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(20), index=True, server_default="completed"
|
||||
),
|
||||
sa.Column("result_summary", sa.Text(), nullable=True),
|
||||
sa.Column("artifact_refs", JSONB, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("task")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add plugin_owned column to base_individual
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"base_individual",
|
||||
sa.Column("plugin_owned", sa.String(64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_base_individual_plugin_owned",
|
||||
"base_individual",
|
||||
["plugin_owned"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_base_individual_plugin_owned", table_name="base_individual")
|
||||
op.drop_column("base_individual", "plugin_owned")
|
||||
@@ -0,0 +1,20 @@
|
||||
# Alembic versions
|
||||
|
||||
迁移脚本会被自动生成到这个目录。
|
||||
|
||||
常用命令(在项目根目录运行):
|
||||
|
||||
- 生成 baseline(首次接入,已有数据库):
|
||||
`alembic stamp head`
|
||||
|
||||
- 自动检测 ORM 与 DB 差异并生成迁移:
|
||||
`alembic revision --autogenerate -m "your message"`
|
||||
|
||||
- 应用所有未执行的迁移:
|
||||
`alembic upgrade head`
|
||||
|
||||
- 回滚一个版本:
|
||||
`alembic downgrade -1`
|
||||
|
||||
- 查看历史:
|
||||
`alembic history --verbose`
|
||||
@@ -17,7 +17,7 @@
|
||||
- **分布式 Actor 骨架**:基于 Ray 框架构建了多智能体协作底座,支持节点跨进程通讯与资源调度。
|
||||
- **全局状态机 (GSM)**:实现了 `GlobalStateMachine` 模块,作为系统的“唯一真相来源”,管理所有 Individual、Skill 和 Provider 的注册信息。
|
||||
- **核心认知节点**:
|
||||
- `SupervisoryNode`:负责任务拆解与分发。
|
||||
- `regulatoryNode`:负责任务拆解与分发。
|
||||
- `ConsciousnessNode`:负责意图识别与语义理解。
|
||||
- `ControlNode`:负责工作流状态监控与逻辑卡点。
|
||||
- **异步工作流引擎**:实现 `WorkflowRunningEngine`,支持从数据库自动轮询并异步执行待办任务流。
|
||||
|
||||
+67
-14
@@ -1,17 +1,70 @@
|
||||
# Roadmap
|
||||
# ROADMAP
|
||||
|
||||
KiloStar 各阶段的方向规划。已完成项归入 [CHANGELOG](./CHANGELOG.md)。
|
||||
|
||||
---
|
||||
## [v0.1.0Alpha] - 2026/4/28
|
||||
### 未来展望:
|
||||
#### 功能增加
|
||||
- [ ] **完善系统插件**: 如 **RAG(检索增强生成)**,**沙箱**, **联网搜索** ,使agent拥有更多的能力适应多样化任务需求
|
||||
- [ ] **增加MCP功能**: 增加MCP,使得agent可以调用通用工具
|
||||
- [ ] **完善special_individual**: 使得`supervisory_node`等可以调用实现语言生成图像生成等功能
|
||||
- [ ] **完善supervisory_node**: 实现`supervisory_node`对于工作流状态的访问,实现更方便的检测
|
||||
- [ ] **对消息平台的对接**: 完善platform,实现对于更多消息平台的对接(如:钉钉微信等),实现在社交软件对`supervisory_node`下达命令
|
||||
|
||||
#### 系统优化
|
||||
- [ ] **优化workflow逻辑**: 通过**graph**等设计实现更优秀的工作流调度
|
||||
- [ ] **优化GSM设计**: 对于 **GSM(global_state_machine全局状态机)** 进行重构,实现更高的并发
|
||||
- [ ] **工具及skill优化**: 完善前端获取工具或skill的逻辑,实现对于skill或者tool的配置改写以及详细信息获取
|
||||
- [ ] **前端优化**: 完善前端设置逻辑(如:调节语言等),以及使前端更加灵活智能
|
||||
## v0.1.x 系列(当前)
|
||||
|
||||
主线目标:补齐 v0.1.0 骨架之上的工程化与可用性短板,让平台进入"装上能用"的状态。
|
||||
|
||||
### 已完成(截至 v0.1.1-alpha)
|
||||
|
||||
- **多 Agent 编排能力线**:人设模板、节点调度标签、Worker 动态派生、调控节点对话模式重构
|
||||
- **工具系统重构**:以 toolset 为单位组织工具,系统预置工具集自动补种,Agent 工具集多选绑定
|
||||
- **MCP 完整接入**:前端 CRUD、Dockerfile Node.js、后端 API 全链路落地,可作为标准 MCP 客户端调用第三方 MCP 服务器
|
||||
- **Provider UX 重做**:5 种 Provider 类型、品牌图标、默认 URL、Test Connection、API key 脱敏
|
||||
- **沙箱执行子项目**:stardomain 落地(local + Docker 双模式)
|
||||
- **基础安全**:JWT 鉴权、资源归属校验、fetch-based SSE、生产密钥强制校验
|
||||
|
||||
### 计划中
|
||||
|
||||
#### 平台体验
|
||||
- **重型插件机制**:定义包格式(manifest + frontend + backend + tools + agent 配置)、挂载协议、生命周期管理;为后续生态铺路
|
||||
- **Skill 工程化**:兼容 Anthropic Agent Skills 标准的同时,补充 KiloStar 自己的 SkillManifest 抽象(依赖识别、文件分类、执行模式声明)
|
||||
- **Provider 模型调用参数体系**:贯通 temperature / top_p / 自定义 headers / 超时 等模型调用参数,统一前后端
|
||||
- **前端 Tauri 桌面端**:把当前 Web 前端打包为 Tauri 桌面客户端,承载需要本地能力的功能
|
||||
|
||||
#### 模型与 Agent 能力
|
||||
- **本地微调模型集成**:vLLM 适配深化,支持把本地微调小模型部署为 Agent 节点
|
||||
- **special_individual 完善**:embedding / TTS / 图像生成等特殊 Agent 标准化接入
|
||||
- **regulatory_node 工作流可见性**:监管节点对工作流执行状态的访问与干预能力
|
||||
|
||||
#### 系统性优化
|
||||
- **GSM 写入路径优化**:单 Actor 写串行化的瓶颈处理,方向上倾向"PG 为真相之源 + GSM 退化为热缓存"
|
||||
- **workflow 引擎深化**:基于 pydantic-graph 的更复杂调度模式(嵌套子流、并行分支汇聚等)
|
||||
- **可观测性**:跨节点 trace 串联、workflow 执行可视化、日志检索体验
|
||||
|
||||
---
|
||||
|
||||
## v0.2.x 系列(中期)
|
||||
|
||||
围绕"通用 Agent 平台"的关键缺口。
|
||||
|
||||
- **重型插件生态**:第一批官方重型插件示例 + 第三方插件开发文档
|
||||
- **Skill 分发与缓存层**:跨节点的 Skill 同步策略,按需拉取 + 本地缓存 + 哈希去重
|
||||
- **多镜像部署**:拆分 core / worker / gpu / standalone 等多镜像,按场景组合
|
||||
- **消息平台对接**:完善 platform 模块,支持钉钉 / 微信 / Slack 等平台的接入
|
||||
- **persona 外键化**:人设统一为外键引用,消除 system_prompt 的数据冗余
|
||||
|
||||
---
|
||||
|
||||
## v0.3.x 系列及之后(远期)
|
||||
|
||||
- **特殊 Agent 生态**:embedding / 多模态 / 语音 / 图像生成等专项 Agent 的标准化接入
|
||||
- **生长机制**:growth_node 真正实现集群与子个体的自适应扩张
|
||||
- **微调模型工具链**:与 unsloth / axolotl 等微调框架的集成路径,把"训练 → 部署 → 接入"做成顺滑流程
|
||||
- **多用户多租户**:从单实例多用户演进到真正的多租户隔离
|
||||
- **联邦化部署**:跨组织的 Agent 协作与资源借用机制
|
||||
|
||||
---
|
||||
|
||||
## 不在路线图中
|
||||
|
||||
KiloStar 不计划自己做:
|
||||
|
||||
- 任何具体垂直场景的 Agent 产品(编程助手、英语学习、数据分析等都应通过重型插件实现)
|
||||
- 闭源模型的深度定制
|
||||
- 自研推理引擎(继续依托 vLLM / llama.cpp 等成熟方案)
|
||||
|
||||
> 路线图按版本节奏组织,但实际推进顺序会根据使用反馈调整。重大方向变化会在此文档留痕。
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
version: v0.1
|
||||
name:
|
||||
version: v0.1.1-alpha
|
||||
name: Kilostar
|
||||
@@ -0,0 +1,46 @@
|
||||
# KiloStar 沙箱安全策略配置
|
||||
sandbox:
|
||||
enabled: true
|
||||
|
||||
# 文件系统沙箱
|
||||
filesystem:
|
||||
workspace_root: "/tmp/kilostar_workspace"
|
||||
allowed_read_paths:
|
||||
- "/tmp"
|
||||
denied_paths:
|
||||
- "/etc/shadow"
|
||||
- "/etc/passwd"
|
||||
- "/root"
|
||||
|
||||
# Shell 命令沙箱
|
||||
shell:
|
||||
enabled: true
|
||||
blocked_commands:
|
||||
- "rm -rf /"
|
||||
- "mkfs"
|
||||
- "dd "
|
||||
- "shutdown"
|
||||
- "reboot"
|
||||
blocked_operators:
|
||||
- "&&"
|
||||
- "||"
|
||||
- ";"
|
||||
- "`"
|
||||
- "$("
|
||||
max_timeout: 60
|
||||
|
||||
# Python 执行器沙箱
|
||||
python_executor:
|
||||
enabled: true
|
||||
max_timeout: 30
|
||||
blocked_imports:
|
||||
- "os"
|
||||
- "subprocess"
|
||||
- "shutil"
|
||||
- "socket"
|
||||
- "ctypes"
|
||||
blocked_builtins:
|
||||
- "exec"
|
||||
- "eval"
|
||||
- "compile"
|
||||
- "__import__"
|
||||
@@ -0,0 +1,2 @@
|
||||
retry:
|
||||
max_attempts: 5
|
||||
@@ -0,0 +1 @@
|
||||
"""data_analytics 重型插件包。"""
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "analyst",
|
||||
"role": "数据分析师",
|
||||
"system_prompt": "你是一位严谨、克制的数据分析师。任务进来后:1) 先用 s3_list_objects/s3_peek 看几行了解结构;2) 决定用 python_executor(小数据,单机 pandas)或 ray_submit(大数据,分布式);3) 执行分析、得出明确结论,必要时给出图表链接或样例数据。注意:你只能读取 S3,**不能写入**。如果用户让你上传/删除/修改对象,请明确告知做不到。",
|
||||
"tools": ["s3_list_objects", "s3_peek", "s3_get_object", "ray_submit", "python_executor"],
|
||||
"skills": [],
|
||||
"peers": []
|
||||
}
|
||||
],
|
||||
"orchestration": {
|
||||
"type": "react",
|
||||
"entry": "analyst"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""data_analytics 插件 API:凭证 CRUD + 分析任务提交/查询/事件流。
|
||||
|
||||
挂载后路径前缀为 /api/v1/plugin/data_analytics/...,跟核心 API 完全独立。
|
||||
所有数据库读写都走 organization actor 的代理方法(确保分布式模式下不跨 actor
|
||||
共享 SQLAlchemy session)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
router = APIRouter(tags=["data_analytics"])
|
||||
|
||||
|
||||
# ─── Schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CredentialCreate(BaseModel):
|
||||
display_name: str = Field(..., max_length=100)
|
||||
endpoint_url: Optional[str] = None
|
||||
region: str = "us-east-1"
|
||||
access_key: str = Field(..., min_length=1)
|
||||
secret_key: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
cred_id: str
|
||||
description: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_org():
|
||||
try:
|
||||
return ray_actor_hook("org_data_analytics").org_data_analytics
|
||||
except Exception as e:
|
||||
raise HTTPException(503, f"data_analytics 插件未就绪:{e}")
|
||||
|
||||
|
||||
# ─── Credentials ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/credentials")
|
||||
async def list_credentials(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
rows = await org.cred_list.remote(token_data.username)
|
||||
return {"credentials": rows}
|
||||
|
||||
|
||||
@router.post("/credentials")
|
||||
async def create_credential(
|
||||
payload: CredentialCreate,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
row = await org.cred_create.remote(
|
||||
user_id=token_data.username,
|
||||
display_name=payload.display_name,
|
||||
access_key=payload.access_key,
|
||||
secret_key=payload.secret_key,
|
||||
endpoint_url=payload.endpoint_url,
|
||||
region=payload.region,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/credentials/{cred_id}")
|
||||
async def delete_credential(
|
||||
cred_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
ok = await org.cred_delete.remote(cred_id, token_data.username)
|
||||
if not ok:
|
||||
raise HTTPException(404, "凭证不存在或不属于当前用户")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ─── Jobs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs")
|
||||
async def create_job(
|
||||
payload: JobCreate,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
try:
|
||||
return await org.job_create.remote(
|
||||
user_id=token_data.username,
|
||||
cred_id=payload.cred_id,
|
||||
description=payload.description,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def list_jobs(
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
rows = await org.job_list.remote(token_data.username)
|
||||
return {"jobs": rows}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def get_job(
|
||||
job_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
org = _get_org()
|
||||
row = await org.job_get.remote(job_id, token_data.username)
|
||||
if row is None:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/stream")
|
||||
async def stream_job(
|
||||
job_id: str,
|
||||
token_data: TokenData = Depends(Accessor.get_current_user),
|
||||
):
|
||||
"""转发 organization 事件流为 SSE。"""
|
||||
import json
|
||||
|
||||
org = _get_org()
|
||||
row = await org.job_get.remote(job_id, token_data.username)
|
||||
if row is None:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
org_task_id = row.get("org_task_id")
|
||||
if not org_task_id:
|
||||
raise HTTPException(409, "任务尚未投递到 organization")
|
||||
|
||||
async def _generate():
|
||||
async for event in await org.stream.remote(org_task_id):
|
||||
payload = event if isinstance(event, str) else json.dumps(event, ensure_ascii=False)
|
||||
yield f"data: {payload}\n\n"
|
||||
|
||||
return StreamingResponse(_generate(), media_type="text/event-stream")
|
||||
@@ -0,0 +1 @@
|
||||
"""data_analytics organization 实现。"""
|
||||
@@ -0,0 +1,235 @@
|
||||
"""data_analytics 插件本地 SQLite 表与 DAO。
|
||||
|
||||
注意:本插件用的 ``DeclarativeBase`` 跟核心 PG 完全独立,避免元数据空间串场。
|
||||
所有数据落到 ``data/plugin/data_analytics/_data/data_analytics.db``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from kilostar.utils.crypto import decrypt_dict_secrets, encrypt_dict_secrets
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""data_analytics 插件私有的元数据空间,跟核心 PG 隔离。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class S3Credential(Base):
|
||||
__tablename__ = "s3_credential"
|
||||
|
||||
cred_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
endpoint_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
region: Mapped[str] = mapped_column(String(50), default="us-east-1")
|
||||
access_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
secret_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class AnalysisJob(Base):
|
||||
__tablename__ = "analysis_job"
|
||||
|
||||
job_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
cred_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
org_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
result: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False, index=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class CredentialDAO:
|
||||
"""S3 凭证 DAO:写入时自动加密,读取时自动解密。"""
|
||||
|
||||
SENSITIVE_KEYS = ("access_key", "secret_key")
|
||||
|
||||
def __init__(self, sm: async_sessionmaker[AsyncSession]):
|
||||
self._sm = sm
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: S3Credential, *, include_secrets: bool) -> dict:
|
||||
d = {
|
||||
"cred_id": row.cred_id,
|
||||
"user_id": row.user_id,
|
||||
"display_name": row.display_name,
|
||||
"endpoint_url": row.endpoint_url,
|
||||
"region": row.region,
|
||||
"access_key": row.access_key,
|
||||
"secret_key": row.secret_key,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
if not include_secrets:
|
||||
ak = decrypt_dict_secrets({"access_key": d["access_key"]}).get("access_key", "")
|
||||
d["access_key"] = (ak[:4] + "***" + ak[-2:]) if len(ak) > 6 else "***"
|
||||
d.pop("secret_key", None)
|
||||
return d
|
||||
# include_secrets=True 用于工具内部,返回明文给 boto3
|
||||
return decrypt_dict_secrets(d)
|
||||
|
||||
async def list_by_user(self, user_id: str) -> List[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.user_id == user_id)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r, include_secrets=False) for r in rows]
|
||||
|
||||
async def get(self, cred_id: str, *, include_secrets: bool = False) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_dict(row, include_secrets=include_secrets)
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
cred_id: str,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
endpoint_url: Optional[str] = None,
|
||||
region: str = "us-east-1",
|
||||
) -> dict:
|
||||
encrypted = encrypt_dict_secrets(
|
||||
{"access_key": access_key, "secret_key": secret_key}
|
||||
)
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(S3Credential.cred_id == cred_id)
|
||||
existing = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.display_name = display_name
|
||||
existing.endpoint_url = endpoint_url
|
||||
existing.region = region
|
||||
existing.access_key = encrypted["access_key"]
|
||||
existing.secret_key = encrypted["secret_key"]
|
||||
s.add(existing)
|
||||
await s.commit()
|
||||
await s.refresh(existing)
|
||||
return self._row_to_dict(existing, include_secrets=False)
|
||||
row = S3Credential(
|
||||
cred_id=cred_id,
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
endpoint_url=endpoint_url,
|
||||
region=region,
|
||||
access_key=encrypted["access_key"],
|
||||
secret_key=encrypted["secret_key"],
|
||||
)
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row, include_secrets=False)
|
||||
|
||||
async def delete(self, cred_id: str, user_id: str) -> bool:
|
||||
async with self._sm() as s:
|
||||
stmt = select(S3Credential).where(
|
||||
S3Credential.cred_id == cred_id, S3Credential.user_id == user_id
|
||||
)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
await s.delete(row)
|
||||
await s.commit()
|
||||
return True
|
||||
|
||||
|
||||
class JobDAO:
|
||||
"""分析任务记录 DAO。"""
|
||||
|
||||
def __init__(self, sm: async_sessionmaker[AsyncSession]):
|
||||
self._sm = sm
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: AnalysisJob) -> dict:
|
||||
return {
|
||||
"job_id": row.job_id,
|
||||
"user_id": row.user_id,
|
||||
"cred_id": row.cred_id,
|
||||
"description": row.description,
|
||||
"status": row.status,
|
||||
"org_task_id": row.org_task_id,
|
||||
"result": row.result,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
description: str,
|
||||
cred_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
async with self._sm() as s:
|
||||
row = AnalysisJob(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
description=description,
|
||||
cred_id=cred_id,
|
||||
)
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
result: Optional[str] = None,
|
||||
org_task_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if status is not None:
|
||||
row.status = status
|
||||
if result is not None:
|
||||
row.result = result
|
||||
if org_task_id is not None:
|
||||
row.org_task_id = org_task_id
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def list_by_user(self, user_id: str, limit: int = 50) -> List[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = (
|
||||
select(AnalysisJob)
|
||||
.where(AnalysisJob.user_id == user_id)
|
||||
.order_by(AnalysisJob.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r) for r in rows]
|
||||
|
||||
async def get(self, job_id: str) -> Optional[dict]:
|
||||
async with self._sm() as s:
|
||||
stmt = select(AnalysisJob).where(AnalysisJob.job_id == job_id)
|
||||
row = (await s.execute(stmt)).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
@@ -0,0 +1,135 @@
|
||||
"""data_analytics organization:管理本插件的 SQLite 元数据 + 注入凭证 ctx。
|
||||
|
||||
凭证经由 ``S3_CREDS_VAR`` ContextVar 传给工具,避免污染 agent tool signature
|
||||
(agent 看到的工具不带 cred 参数,模型不会误传)。
|
||||
|
||||
API 层通过本类暴露的 ``cred_*`` / ``job_*`` 代理方法跨 actor 调 DAO,
|
||||
保证分布式模式下 actor 之间不直接共享 SQLAlchemy session。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from kilostar.plugin_runtime.base_organization import BaseOrganization
|
||||
from kilostar.plugin_runtime.event import OrgEvent
|
||||
|
||||
from .db import Base, CredentialDAO, JobDAO
|
||||
|
||||
# 当前任务的 S3 凭证(明文):工具内部读 .get() 拿
|
||||
S3_CREDS_VAR: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
|
||||
"data_analytics_s3_creds", default=None
|
||||
)
|
||||
|
||||
|
||||
class DataAnalyticsOrganization(BaseOrganization):
|
||||
"""对接 S3 的数据分析组织。"""
|
||||
|
||||
async def setup(self) -> None:
|
||||
await super().setup()
|
||||
await self.init_local_db([Base])
|
||||
# 跨工具/跨 API 共享的 DAO 实例
|
||||
self.cred_dao = CredentialDAO(self._session_maker)
|
||||
self.job_dao = JobDAO(self._session_maker)
|
||||
|
||||
async def on_first_install(self) -> None:
|
||||
self.logger.info(
|
||||
"data_analytics installed; configure S3 credentials in dashboard."
|
||||
)
|
||||
|
||||
async def react(
|
||||
self,
|
||||
task_description: str,
|
||||
ctx: Dict[str, Any],
|
||||
emit: Callable[[OrgEvent], Any],
|
||||
) -> Any:
|
||||
cred_id = ctx.get("cred_id")
|
||||
if cred_id and getattr(self, "cred_dao", None) is not None:
|
||||
cred = await self.cred_dao.get(cred_id, include_secrets=True)
|
||||
if cred is None:
|
||||
raise RuntimeError(f"S3 凭证 {cred_id} 不存在")
|
||||
S3_CREDS_VAR.set(cred)
|
||||
ctx["s3_cred_display"] = cred.get("display_name")
|
||||
else:
|
||||
S3_CREDS_VAR.set(None)
|
||||
return await super().react(task_description, ctx, emit)
|
||||
|
||||
# ─── 凭证代理(API 层调用) ─────────────────────────────────────
|
||||
|
||||
async def cred_list(self, user_id: str) -> List[dict]:
|
||||
return await self.cred_dao.list_by_user(user_id)
|
||||
|
||||
async def cred_create(
|
||||
self,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
endpoint_url: Optional[str] = None,
|
||||
region: str = "us-east-1",
|
||||
) -> dict:
|
||||
cred_id = uuid.uuid4().hex
|
||||
return await self.cred_dao.upsert(
|
||||
cred_id=cred_id,
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
endpoint_url=endpoint_url,
|
||||
region=region,
|
||||
)
|
||||
|
||||
async def cred_delete(self, cred_id: str, user_id: str) -> bool:
|
||||
return await self.cred_dao.delete(cred_id, user_id)
|
||||
|
||||
# ─── 任务代理 ──────────────────────────────────────────────────
|
||||
|
||||
async def job_create(
|
||||
self, user_id: str, cred_id: str, description: str
|
||||
) -> dict:
|
||||
# 校验凭证归属
|
||||
cred = await self.cred_dao.get(cred_id, include_secrets=False)
|
||||
if cred is None or cred.get("user_id") != user_id:
|
||||
raise ValueError("凭证不存在或不属于当前用户")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
await self.job_dao.create(
|
||||
job_id=job_id,
|
||||
user_id=user_id,
|
||||
description=description,
|
||||
cred_id=cred_id,
|
||||
)
|
||||
# 投递 organization 任务(拿 task_id 回填,便于前端拉事件流)
|
||||
task_id = await self.submit(
|
||||
description, {"user_id": user_id, "cred_id": cred_id, "job_id": job_id}
|
||||
)
|
||||
await self.job_dao.update(job_id, status="running", org_task_id=task_id)
|
||||
return {"job_id": job_id, "task_id": task_id, "status": "running"}
|
||||
|
||||
async def job_list(self, user_id: str) -> List[dict]:
|
||||
return await self.job_dao.list_by_user(user_id)
|
||||
|
||||
async def job_get(self, job_id: str, user_id: str) -> Optional[dict]:
|
||||
row = await self.job_dao.get(job_id)
|
||||
if row is None or row.get("user_id") != user_id:
|
||||
return None
|
||||
# 附带最新 organization 状态
|
||||
org_task_id = row.get("org_task_id")
|
||||
if org_task_id:
|
||||
ts = await self.status(org_task_id)
|
||||
if ts is not None:
|
||||
row["task_status"] = ts.get("status")
|
||||
row["task_result"] = ts.get("result")
|
||||
row["task_error"] = ts.get("error")
|
||||
# 任务终态时把结果回写 SQLite,方便重启后查询
|
||||
if ts.get("status") in ("completed", "failed") and row.get("status") != ts.get("status"):
|
||||
result_payload = ts.get("result") if ts.get("status") == "completed" else ts.get("error")
|
||||
await self.job_dao.update(
|
||||
job_id,
|
||||
status=ts.get("status"),
|
||||
result=str(result_payload) if result_payload is not None else None,
|
||||
)
|
||||
row["status"] = ts.get("status")
|
||||
return row
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Loader2, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
loading: boolean;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function CredentialPanel({ credentials, loading, onChanged }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
display_name: '',
|
||||
endpoint_url: '',
|
||||
region: 'us-east-1',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setForm({ display_name: '', endpoint_url: '', region: 'us-east-1', access_key: '', secret_key: '' });
|
||||
setError('');
|
||||
setShowSecret(false);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.display_name.trim() || !form.access_key.trim() || !form.secret_key.trim()) {
|
||||
setError('显示名 / Access Key / Secret Key 必填');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/credentials`, {
|
||||
display_name: form.display_name.trim(),
|
||||
endpoint_url: form.endpoint_url.trim() || null,
|
||||
region: form.region.trim() || 'us-east-1',
|
||||
access_key: form.access_key,
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
reset();
|
||||
setShowForm(false);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (cred_id: string) => {
|
||||
if (!confirm('确定删除该凭证?删除后该凭证下的任务将无法继续运行。')) return;
|
||||
try {
|
||||
await client.delete(`${API_BASE}/credentials/${cred_id}`);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Key size={16} className="text-accent" /> S3 凭证
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">访问密钥加密存储于本地 SQLite。</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5"
|
||||
onClick={() => { setShowForm((s) => !s); setError(''); }}
|
||||
>
|
||||
<Plus size={14} /> {showForm ? '取消' : '新增'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="space-y-3 mb-4 p-4 bg-bg-secondary rounded-xl border border-border-secondary">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="显示名(如 prod-aws)"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Endpoint URL(可选,自托管 S3 / MinIO 填写)"
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => setForm({ ...form, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Region(默认 us-east-1)"
|
||||
value={form.region}
|
||||
onChange={(e) => setForm({ ...form, region: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Access Key"
|
||||
value={form.access_key}
|
||||
onChange={(e) => setForm({ ...form, access_key: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Secret Key"
|
||||
value={form.secret_key}
|
||||
onChange={(e) => setForm({ ...form, secret_key: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
||||
onClick={() => setShowSecret((s) => !s)}
|
||||
>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||||
还没有凭证,点右上角「新增」开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{credentials.map((c) => (
|
||||
<div
|
||||
key={c.cred_id}
|
||||
className="flex items-center justify-between p-3 bg-bg-secondary rounded-xl border border-border-secondary"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text-primary truncate">{c.display_name}</div>
|
||||
<div className="text-[11px] text-text-muted font-mono mt-0.5">
|
||||
{c.endpoint_url || 'aws-s3'} · {c.region} · {c.access_key}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-1.5 text-text-muted hover:text-danger transition"
|
||||
onClick={() => remove(c.cred_id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BarChart3, Plus, Loader2, ListChecks } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import { CredentialPanel } from './CredentialPanel';
|
||||
import { NewJobDialog } from './NewJobDialog';
|
||||
import { JobDetail } from './JobDetail';
|
||||
import type { S3Credential, AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export function Dashboard({ pluginName }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credentials, setCredentials] = useState<S3Credential[]>([]);
|
||||
const [credLoading, setCredLoading] = useState(true);
|
||||
const [jobs, setJobs] = useState<AnalysisJob[]>([]);
|
||||
const [jobLoading, setJobLoading] = useState(true);
|
||||
const [showNewJob, setShowNewJob] = useState(false);
|
||||
const [openJobId, setOpenJobId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadCredentials = useCallback(async () => {
|
||||
setCredLoading(true);
|
||||
try {
|
||||
const resp = await client.get<{ credentials: S3Credential[] }>(`${API_BASE}/credentials`);
|
||||
setCredentials(resp.data.credentials || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '加载凭证失败');
|
||||
} finally {
|
||||
setCredLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setJobLoading(true);
|
||||
try {
|
||||
const resp = await client.get<{ jobs: AnalysisJob[] }>(`${API_BASE}/jobs`);
|
||||
setJobs(resp.data.jobs || []);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '加载任务失败');
|
||||
} finally {
|
||||
setJobLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCredentials();
|
||||
loadJobs();
|
||||
}, [loadCredentials, loadJobs]);
|
||||
|
||||
// 轮询任务列表,方便看状态变化
|
||||
useEffect(() => {
|
||||
const t = setInterval(loadJobs, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadJobs]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-bg-base">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 pb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-light text-accent flex items-center justify-center">
|
||||
<BarChart3 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-primary">数据分析</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
对接 S3,让 agent 自主决定分析路径(python_executor / ray_submit)。{' '}
|
||||
<span className="font-mono text-[10px] opacity-70">{pluginName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
|
||||
)}
|
||||
|
||||
<CredentialPanel credentials={credentials} loading={credLoading} onChanged={loadCredentials} />
|
||||
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<ListChecks size={16} className="text-accent" /> 分析任务
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">点行查看详情和事件流。每 5 秒自动刷新。</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5 disabled:opacity-50"
|
||||
onClick={() => setShowNewJob(true)}
|
||||
disabled={credentials.length === 0}
|
||||
title={credentials.length === 0 ? '请先添加凭证' : ''}
|
||||
>
|
||||
<Plus size={14} /> 新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobLoading && jobs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||||
还没有分析任务。点右上角「新建任务」开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((j) => (
|
||||
<button
|
||||
key={j.job_id}
|
||||
className="w-full text-left p-3 bg-bg-secondary rounded-xl border border-border-secondary hover:border-accent transition flex items-center justify-between gap-3"
|
||||
onClick={() => setOpenJobId(j.job_id)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-text-primary truncate">{j.description}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5 font-mono">
|
||||
{j.job_id.slice(0, 8)} · {j.created_at?.slice(0, 19).replace('T', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={j.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewJob && (
|
||||
<NewJobDialog
|
||||
credentials={credentials}
|
||||
onClose={() => setShowNewJob(false)}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
)}
|
||||
{openJobId && <JobDetail jobId={openJobId} onClose={() => setOpenJobId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-bg-base text-text-muted border-border-primary',
|
||||
running: 'bg-warning-bg text-warning border-warning/20',
|
||||
completed: 'bg-success-bg text-success border-success/20',
|
||||
failed: 'bg-danger-bg text-danger border-danger/20',
|
||||
};
|
||||
const cls = map[status] || map.pending;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium px-2 py-1 rounded-lg border ${cls} shrink-0`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, X, Activity } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
jobId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
type?: string;
|
||||
ts?: number;
|
||||
payload?: unknown;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export function JobDetail({ jobId, onClose }: Props) {
|
||||
const { client, token, apiBase } = usePluginContext();
|
||||
const [job, setJob] = useState<AnalysisJob | null>(null);
|
||||
const [events, setEvents] = useState<StreamEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const eventBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 初次加载 + 后台轮询
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchJob = async () => {
|
||||
try {
|
||||
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
|
||||
if (!cancelled) setJob(resp.data);
|
||||
} catch (e) {
|
||||
console.error('fetch job failed', e);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchJob();
|
||||
const t = setInterval(fetchJob, 4000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [client, jobId]);
|
||||
|
||||
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const run = async () => {
|
||||
try {
|
||||
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.body) return;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buf = '';
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split('\n\n');
|
||||
buf = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
try {
|
||||
setEvents((prev) => [...prev, JSON.parse(payload)]);
|
||||
} catch {
|
||||
setEvents((prev) => [...prev, { raw: payload }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => controller.abort();
|
||||
}, [apiBase, jobId, token]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (eventBoxRef.current) {
|
||||
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Activity size={16} className="text-accent" />
|
||||
任务详情
|
||||
</h3>
|
||||
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
|
||||
</div>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : job ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Field label="状态" value={job.task_status || job.status} />
|
||||
<Field label="描述" value={job.description} />
|
||||
{job.task_error && <Field label="错误" value={job.task_error} danger />}
|
||||
</div>
|
||||
|
||||
{job.task_result !== undefined && job.task_result !== null && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">执行结果</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
|
||||
{typeof job.task_result === 'string'
|
||||
? job.task_result
|
||||
: JSON.stringify(job.task_result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">事件流(SSE)</div>
|
||||
<div
|
||||
ref={eventBoxRef}
|
||||
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<span className="text-text-muted">(等待事件…)</span>
|
||||
) : (
|
||||
events.map((e, i) => (
|
||||
<div key={i} className="text-text-secondary">
|
||||
<span className="text-accent">{e.type || 'event'}</span>{' '}
|
||||
{e.payload !== undefined ? (
|
||||
<span>{JSON.stringify(e.payload)}</span>
|
||||
) : e.raw ? (
|
||||
<span>{e.raw}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted text-center py-8">任务不存在或已被删除</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
|
||||
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Send, X } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function NewJobDialog({ credentials, onClose, onCreated }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credId, setCredId] = useState(credentials[0]?.cred_id || '');
|
||||
const [description, setDescription] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = async () => {
|
||||
if (!credId) {
|
||||
setError('请选择 S3 凭证');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setError('请描述要做的分析');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/jobs`, {
|
||||
cred_id: credId,
|
||||
description: description.trim(),
|
||||
});
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '提交失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-lg bg-bg-card rounded-2xl border border-border-primary shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<h3 className="font-semibold text-text-primary">新建分析任务</h3>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">S3 凭证</label>
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-xs text-warning bg-warning-bg/50 border border-warning/20 rounded-lg p-2">
|
||||
请先在上方添加 S3 凭证。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
value={credId}
|
||||
onChange={(e) => setCredId(e.target.value)}
|
||||
>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.cred_id} value={c.cred_id}>
|
||||
{c.display_name} · {c.region}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">任务描述</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent min-h-[120px] resize-y"
|
||||
placeholder="例如:分析 s3://my-bucket/sales/2026-q1/ 的销售趋势,输出按月汇总"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
Agent 会先用 s3_peek/s3_list_objects 探查数据,然后选择 python_executor 或 ray_submit 执行分析。
|
||||
</p>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border-primary">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-text-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center gap-1.5"
|
||||
onClick={submit}
|
||||
disabled={busy || credentials.length === 0}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// vite lib 模式 build 完后写一份 wc-manifest.json,给后端 /ui-manifest 端点读
|
||||
const distDir = 'dist';
|
||||
if (!existsSync(distDir)) {
|
||||
console.error('dist/ not found; run vite build first');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(distDir);
|
||||
const js = files.find((f) => f.endsWith('.js')) || 'plugin-element.js';
|
||||
const css = files.filter((f) => f.endsWith('.css'));
|
||||
|
||||
const manifest = {
|
||||
tag: 'plugin-data-analytics',
|
||||
js,
|
||||
css,
|
||||
};
|
||||
|
||||
writeFileSync(join(distDir, 'wc-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
console.log(`wrote dist/wc-manifest.json: ${JSON.stringify(manifest)}`);
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface PluginContextValue {
|
||||
client: AxiosInstance;
|
||||
token: string;
|
||||
apiBase: string;
|
||||
}
|
||||
|
||||
export const PluginContext = createContext<PluginContextValue | null>(null);
|
||||
|
||||
export function usePluginContext(): PluginContextValue {
|
||||
const ctx = useContext(PluginContext);
|
||||
if (!ctx) throw new Error('PluginContext missing — Web Component not initialized');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function makeClient(token: string, apiBase: string): AxiosInstance {
|
||||
const c = axios.create({
|
||||
baseURL: apiBase || undefined,
|
||||
});
|
||||
c.interceptors.request.use((cfg) => {
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
(cfg.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
return c;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { PluginContext, makeClient } from './client';
|
||||
|
||||
// 把 build 出来的 CSS 当字符串收入,作为 ConstructableStyleSheet 注入到 shadow root,
|
||||
// 既能享受 shadow DOM 的样式隔离,也不需要额外的 fetch 步骤。
|
||||
import css from './styles.css?inline';
|
||||
|
||||
const TAG = 'plugin-data-analytics';
|
||||
|
||||
class DataAnalyticsElement extends HTMLElement {
|
||||
private root?: Root;
|
||||
private mount?: HTMLDivElement;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['token', 'api-base'];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
|
||||
// 用 <style> 注入 CSS(ConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
shadow.appendChild(style);
|
||||
|
||||
this.mount = document.createElement('div');
|
||||
this.mount.style.cssText = 'height:100%;width:100%';
|
||||
shadow.appendChild(this.mount);
|
||||
|
||||
this.root = createRoot(this.mount);
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.root) this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.root?.unmount();
|
||||
this.root = undefined;
|
||||
}
|
||||
|
||||
private render() {
|
||||
const token = this.getAttribute('token') ?? '';
|
||||
const apiBase = this.getAttribute('api-base') ?? '';
|
||||
const client = makeClient(token, apiBase);
|
||||
this.root!.render(
|
||||
<React.StrictMode>
|
||||
<PluginContext.Provider value={{ client, token, apiBase }}>
|
||||
<Dashboard pluginName="data_analytics" />
|
||||
</PluginContext.Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get(TAG)) {
|
||||
customElements.define(TAG, DataAnalyticsElement);
|
||||
}
|
||||
+1744
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "plugin-data-analytics",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build && node build-manifest.mjs",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 在 shadow DOM 内 :root 不匹配,用 :host 给 Web Component 自身定义主题 token。
|
||||
颜色名称跟主前端 frontend/src/index.css 保持一致——这样组件里的 bg-bg-card / text-accent
|
||||
等类名在插件 build 时也能解析到对应的 var()。 */
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-card: var(--bg-card);
|
||||
--color-bg-sidebar: var(--bg-sidebar);
|
||||
--color-bg-input: var(--bg-input);
|
||||
--color-bg-hover: var(--bg-hover);
|
||||
--color-bg-active: var(--bg-active);
|
||||
--color-bg-base: var(--bg-base);
|
||||
--color-border-primary: var(--border-primary);
|
||||
--color-border-secondary: var(--border-secondary);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-light: var(--accent-light);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-bg: var(--danger-bg);
|
||||
--color-success: var(--success);
|
||||
--color-success-bg: var(--success-bg);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-bg: var(--warning-bg);
|
||||
}
|
||||
|
||||
:host {
|
||||
/* light theme defaults — 跟主前端保持一致 */
|
||||
--bg-primary: #f2f0ed;
|
||||
--bg-secondary: #eae8e4;
|
||||
--bg-tertiary: #e0ddd8;
|
||||
--bg-card: #faf9f7;
|
||||
--bg-sidebar: #eae8e4;
|
||||
--bg-input: #f2f0ed;
|
||||
--bg-hover: rgba(255, 255, 255, 0.4);
|
||||
--bg-active: rgba(156, 175, 136, 0.08);
|
||||
--bg-base: #f2f0ed;
|
||||
--border-primary: #e0ddd8;
|
||||
--border-secondary: #eae8e4;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #5a5a5a;
|
||||
--text-tertiary: #8c8680;
|
||||
--text-muted: #b5afa8;
|
||||
--accent: #9caf88;
|
||||
--accent-hover: #8a9e78;
|
||||
--accent-light: rgba(156, 175, 136, 0.12);
|
||||
--danger: #c4917a;
|
||||
--danger-bg: rgba(196, 145, 122, 0.08);
|
||||
--success: #7a8e6a;
|
||||
--success-bg: rgba(122, 142, 106, 0.08);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.08);
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 跟随系统/主前端的暗色主题:宿主元素加 [data-theme="dark"] 时切换 */
|
||||
:host([data-theme="dark"]) {
|
||||
--bg-primary: #1c1b19;
|
||||
--bg-secondary: #232220;
|
||||
--bg-tertiary: #2d2b28;
|
||||
--bg-card: #252421;
|
||||
--bg-sidebar: #1e1d1b;
|
||||
--bg-input: #2d2b28;
|
||||
--bg-hover: rgba(255, 255, 255, 0.04);
|
||||
--bg-active: rgba(156, 175, 136, 0.1);
|
||||
--bg-base: #1c1b19;
|
||||
--border-primary: rgba(255, 255, 255, 0.06);
|
||||
--border-secondary: rgba(255, 255, 255, 0.03);
|
||||
--text-primary: #e8e6e3;
|
||||
--text-secondary: #c8c5c0;
|
||||
--text-tertiary: #a09c96;
|
||||
--text-muted: #7a7772;
|
||||
--accent: #a8bc94;
|
||||
--accent-hover: #b8caa6;
|
||||
--accent-light: rgba(156, 175, 136, 0.15);
|
||||
--danger: #d4a894;
|
||||
--danger-bg: rgba(196, 145, 122, 0.1);
|
||||
--success: #9caf88;
|
||||
--success-bg: rgba(156, 175, 136, 0.1);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["*.ts", "*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface S3Credential {
|
||||
cred_id: string;
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
endpoint_url: string | null;
|
||||
region: string;
|
||||
access_key: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AnalysisJob {
|
||||
job_id: string;
|
||||
user_id: string;
|
||||
cred_id: string | null;
|
||||
description: string;
|
||||
status: string;
|
||||
org_task_id: string | null;
|
||||
result: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
task_status?: string;
|
||||
task_result?: unknown;
|
||||
task_error?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'index.tsx',
|
||||
formats: ['es'],
|
||||
fileName: () => 'plugin-element.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (info) => {
|
||||
if (info.name && info.name.endsWith('.css')) return 'plugin-element.css';
|
||||
return 'assets/[name]-[hash][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "data_analytics",
|
||||
"version": "0.1.0",
|
||||
"display_name": "数据分析",
|
||||
"description": "对接 S3 对象存储,由 agent 自主决定使用 python_executor 或 ray_submit 跑分析。仅读不写。",
|
||||
"entry": "core.organization:DataAnalyticsOrganization",
|
||||
"concurrency": "queue",
|
||||
"node_affinity": "cpu",
|
||||
"api_prefix": "/api/v1/plugin/data_analytics",
|
||||
"capabilities": ["data_analysis", "s3_readonly"],
|
||||
"dependencies": {
|
||||
"python": [],
|
||||
"plugins": []
|
||||
},
|
||||
"ui": {
|
||||
"entry": "frontend/index.tsx",
|
||||
"icon": "BarChart3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""data_analytics 插件本地工具集。
|
||||
|
||||
agent 看到这些工具时不带凭证参数,凭证由 organization 通过 ContextVar 注入。
|
||||
"""
|
||||
|
||||
from .s3_list_objects import s3_list_objects
|
||||
from .s3_peek import s3_peek
|
||||
from .s3_get_object import s3_get_object
|
||||
from .ray_submit import ray_submit
|
||||
|
||||
__all__ = [
|
||||
"s3_list_objects",
|
||||
"s3_peek",
|
||||
"s3_get_object",
|
||||
"ray_submit",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""S3 工具共用辅助:从 ContextVar 拿凭证 + 解析 URI。
|
||||
|
||||
所有 s3_* 工具都依赖这个模块,把"明文凭证"的取用集中在一处。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
def get_s3_creds_or_raise() -> Dict[str, Any]:
|
||||
"""从 organization 注入的 ContextVar 中取出明文凭证;未注入则抛错。"""
|
||||
# 延迟 import 避免循环;这里走 organization 子类被加载时注入的虚拟包路径
|
||||
from ..core.organization import S3_CREDS_VAR
|
||||
|
||||
creds = S3_CREDS_VAR.get()
|
||||
if not creds:
|
||||
raise RuntimeError(
|
||||
"未提供 S3 凭证:本任务上下文中没有 cred_id,请在创建 job 时选择凭证。"
|
||||
)
|
||||
return creds
|
||||
|
||||
|
||||
def parse_s3_uri(uri: str) -> Tuple[str, str]:
|
||||
"""解析 ``s3://bucket/key`` → ``(bucket, key)``;非法格式抛 ValueError。"""
|
||||
m = re.match(r"^s3://([^/]+)/(.+)$", uri.strip())
|
||||
if not m:
|
||||
raise ValueError(f"非法 S3 URI:{uri!r}(期待 s3://bucket/key 形式)")
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def make_session_kwargs(creds: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""转 boto3/aiobotocore client 调用所需的 kwargs。"""
|
||||
kw: Dict[str, Any] = {
|
||||
"aws_access_key_id": creds["access_key"],
|
||||
"aws_secret_access_key": creds["secret_key"],
|
||||
"region_name": creds.get("region") or "us-east-1",
|
||||
}
|
||||
endpoint = creds.get("endpoint_url")
|
||||
if endpoint:
|
||||
kw["endpoint_url"] = endpoint
|
||||
return kw
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "data_analytics_internal",
|
||||
"version": "0.1.0",
|
||||
"description": "data_analytics 插件内部工具:S3 只读 + Ray 提交。仅限本插件内部 agent 调用。",
|
||||
"tools": [
|
||||
{
|
||||
"name": "s3_list_objects",
|
||||
"file": "s3_list_objects.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "s3_peek",
|
||||
"file": "s3_peek.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "s3_get_object",
|
||||
"file": "s3_get_object.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "ray_submit",
|
||||
"file": "ray_submit.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["data_analytics_internal"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""ray_submit:把分析脚本提交到 Ray(distributed)或 subprocess(standalone)执行。
|
||||
|
||||
凭证以 ``AWS_*`` 环境变量注入子进程,让 boto3/pandas-s3 自然读到。
|
||||
脚本走 ``kilostar.utils.sandbox.validate_python_code`` 的静态屏蔽兜底。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from kilostar.utils.ray_compat import _STANDALONE
|
||||
from kilostar.utils.sandbox import (
|
||||
CodeViolation,
|
||||
get_python_timeout,
|
||||
validate_python_code,
|
||||
)
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise
|
||||
|
||||
|
||||
def _build_env(creds) -> dict:
|
||||
env = os.environ.copy()
|
||||
env["AWS_ACCESS_KEY_ID"] = creds["access_key"]
|
||||
env["AWS_SECRET_ACCESS_KEY"] = creds["secret_key"]
|
||||
env["AWS_DEFAULT_REGION"] = creds.get("region") or "us-east-1"
|
||||
if creds.get("endpoint_url"):
|
||||
env["AWS_ENDPOINT_URL_S3"] = creds["endpoint_url"]
|
||||
env["AWS_ENDPOINT_URL"] = creds["endpoint_url"]
|
||||
return env
|
||||
|
||||
|
||||
async def ray_submit(script: str, timeout: int = 300) -> str:
|
||||
"""提交 Python 脚本到 Ray(分布式)或子进程(单机)执行。
|
||||
|
||||
脚本中可直接 ``import boto3`` 读 S3(凭证已通过环境变量注入);可用
|
||||
pandas / polars / numpy 等已安装的依赖。**只读**——不要尝试 put/delete。
|
||||
|
||||
Args:
|
||||
script: Python 源码
|
||||
timeout: 超时秒数(默认 300)
|
||||
|
||||
Returns:
|
||||
stdout(必要时尾部追加 stderr 与 exit code)
|
||||
"""
|
||||
try:
|
||||
script = validate_python_code(script)
|
||||
except CodeViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
env = _build_env(creds)
|
||||
timeout = get_python_timeout(timeout)
|
||||
|
||||
# standalone 与 distributed 第一版都走 subprocess,保证环境变量传递可控
|
||||
# (ray.remote 跑函数时 env vars 需另装 runtime_env,复杂度跟 subprocess 持平
|
||||
# 但前者透明可控,先这样落地)
|
||||
tmp_file = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(script)
|
||||
tmp_file = f.name
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
tmp_file,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
out = stdout.decode("utf-8", errors="replace")
|
||||
err = stderr.decode("utf-8", errors="replace")
|
||||
result = ""
|
||||
if out:
|
||||
result += out
|
||||
if err:
|
||||
result += f"\n[stderr]\n{err}"
|
||||
if proc.returncode != 0:
|
||||
result += f"\n[exit code: {proc.returncode}]"
|
||||
result = result.strip() or "(no output)"
|
||||
if not _STANDALONE:
|
||||
result = f"[mode: ray-cluster (subprocess)]\n{result}"
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return f"[Error] ray_submit 执行超时({timeout}s)"
|
||||
except Exception as e:
|
||||
return f"[Error] ray_submit 失败:{e}"
|
||||
finally:
|
||||
if tmp_file and os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""s3_get_object:下载到 artifact 目录(路径强校验防穿越)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from kilostar.utils.settings import get_artifact_dir
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
|
||||
|
||||
|
||||
async def s3_get_object(uri: str, save_as: str) -> str:
|
||||
"""把 S3 对象下载到本进程的 artifact 工作区,返回本地绝对路径。
|
||||
|
||||
``save_as`` 必须是相对路径,落到 ``data/artifact/data_analytics_downloads/``
|
||||
下面(防越权写入任意目录)。下载后供 python_executor / ray_submit 中以
|
||||
pandas/polars 读取。
|
||||
|
||||
Args:
|
||||
uri: 形如 ``s3://bucket/key`` 的对象路径
|
||||
save_as: 保存的相对文件名(不能含 ``..`` 或绝对路径)
|
||||
|
||||
Returns:
|
||||
本地保存的绝对路径
|
||||
"""
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
bucket, key = parse_s3_uri(uri)
|
||||
|
||||
save_path = Path(save_as).as_posix()
|
||||
if save_path.startswith("/") or ".." in save_path.split("/"):
|
||||
raise ValueError(f"save_as 必须是相对、不含 .. 的路径,收到 {save_as!r}")
|
||||
|
||||
base = get_artifact_dir() / "data_analytics_downloads"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
target = base / save_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session = get_session()
|
||||
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
|
||||
resp = await client.get_object(Bucket=bucket, Key=key)
|
||||
body = await resp["Body"].read()
|
||||
target.write_bytes(body)
|
||||
return str(target.resolve())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""s3_list_objects:列出 bucket+prefix 下的对象列表(key/size/last_modified)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs
|
||||
|
||||
|
||||
async def s3_list_objects(
|
||||
bucket: str,
|
||||
prefix: str = "",
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""列出 S3 bucket 下指定 prefix 的对象(最多 limit 条)。
|
||||
|
||||
Args:
|
||||
bucket: S3 bucket 名
|
||||
prefix: 对象 key 前缀,留空表示根路径
|
||||
limit: 最多返回条数(1-1000),默认 50
|
||||
|
||||
Returns:
|
||||
对象信息列表,每项含 key / size / last_modified(ISO 字符串)
|
||||
"""
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
limit = max(1, min(int(limit), 1000))
|
||||
|
||||
session = get_session()
|
||||
out: List[Dict[str, Any]] = []
|
||||
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
async for page in paginator.paginate(
|
||||
Bucket=bucket, Prefix=prefix, PaginationConfig={"MaxItems": limit}
|
||||
):
|
||||
for item in page.get("Contents", []) or []:
|
||||
out.append({
|
||||
"key": item.get("Key"),
|
||||
"size": item.get("Size"),
|
||||
"last_modified": (
|
||||
item["LastModified"].isoformat() if item.get("LastModified") else None
|
||||
),
|
||||
})
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
@@ -0,0 +1,35 @@
|
||||
"""s3_peek:读取对象的头若干字节并尝试 UTF-8 解码(看几行用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._s3_common import get_s3_creds_or_raise, make_session_kwargs, parse_s3_uri
|
||||
|
||||
|
||||
async def s3_peek(uri: str, n_bytes: int = 4096) -> str:
|
||||
"""读取 S3 对象的头 ``n_bytes`` 字节,UTF-8 解码后返回。
|
||||
|
||||
适合快速预览 csv/json/log 等文本类对象的开头几行。二进制内容会以
|
||||
``[binary, ...]`` 占位说明返回。
|
||||
|
||||
Args:
|
||||
uri: 形如 ``s3://bucket/key`` 的对象路径
|
||||
n_bytes: 读取字节数,默认 4096,上限 1MB
|
||||
|
||||
Returns:
|
||||
对象内容片段(解码后的字符串或占位说明)
|
||||
"""
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
creds = get_s3_creds_or_raise()
|
||||
bucket, key = parse_s3_uri(uri)
|
||||
n = max(1, min(int(n_bytes), 1024 * 1024))
|
||||
|
||||
session = get_session()
|
||||
async with session.create_client("s3", **make_session_kwargs(creds)) as client:
|
||||
resp = await client.get_object(Bucket=bucket, Key=key, Range=f"bytes=0-{n-1}")
|
||||
body = await resp["Body"].read()
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
return text
|
||||
except UnicodeDecodeError:
|
||||
return f"[binary, {len(body)} bytes; first 64 hex] {body[:64].hex()}"
|
||||
@@ -0,0 +1,16 @@
|
||||
# 示例部门 (Example Dept)
|
||||
|
||||
演示用的重型插件骨架。包含两个平级 agent(analyst + executor),
|
||||
可作为开发新组织插件的模板。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
example_dept/
|
||||
├── manifest.json # 插件元数据
|
||||
├── agents.json # agent 定义
|
||||
├── core/ # 业务逻辑
|
||||
├── toolset/ # 本地工具
|
||||
├── skills/ # 本地技能
|
||||
└── dashboard/ # 前端面板(占位)
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "analyst",
|
||||
"role": "数据分析专家",
|
||||
"system_prompt": "你是一位数据分析专家,负责理解用户需求并给出分析方案。",
|
||||
"model": {
|
||||
"provider_title": "",
|
||||
"model_id": ""
|
||||
},
|
||||
"tools": [],
|
||||
"skills": [],
|
||||
"peers": ["executor"]
|
||||
},
|
||||
{
|
||||
"name": "executor",
|
||||
"role": "执行专家",
|
||||
"system_prompt": "你是一位执行专家,负责将分析方案转化为具体操作。",
|
||||
"model": {
|
||||
"provider_title": "",
|
||||
"model_id": ""
|
||||
},
|
||||
"tools": ["shell_executor", "python_executor"],
|
||||
"skills": [],
|
||||
"peers": ["analyst"]
|
||||
}
|
||||
],
|
||||
"orchestration": {
|
||||
"type": "react",
|
||||
"entry": "analyst"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
from kilostar.plugin_runtime.base_organization import BaseOrganization
|
||||
|
||||
|
||||
class ExampleOrganization(BaseOrganization):
|
||||
"""示例组织 — 直接使用基类的 react 逻辑。"""
|
||||
pass
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "example_dept",
|
||||
"version": "0.1.0",
|
||||
"display_name": "示例部门",
|
||||
"description": "演示用的重型插件骨架,可作为开发模板。",
|
||||
"entry": "core.organization:ExampleOrganization",
|
||||
"concurrency": "queue",
|
||||
"node_affinity": "cpu",
|
||||
"api_prefix": "/plugin/example_dept",
|
||||
"capabilities": ["text_processing"],
|
||||
"dependencies": {
|
||||
"python": [],
|
||||
"plugins": []
|
||||
},
|
||||
"ui": {
|
||||
"entry": "dashboard/index.html",
|
||||
"icon": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# base_toolset
|
||||
|
||||
KiloStar 内置基础工具集。提供文件操作、命令执行、搜索等通用能力,所有 Agent 默认可用。
|
||||
|
||||
## 工具列表
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `shell_executor` | 执行 shell 命令,返回 stdout/stderr |
|
||||
| `file_reader` | 读取文件内容(支持按行偏移和行数限制) |
|
||||
| `edit_file` | 按 old_string → new_string 的方式精确替换文件内容 |
|
||||
| `write_file` | 整体写入或覆盖文件 |
|
||||
| `search_file` | 在目录树内按 glob/正则搜索文件名或内容 |
|
||||
| `python_executor` | 在沙箱中运行 Python 代码片段 |
|
||||
| `tavily_search` | 调用 Tavily API 进行联网搜索(需配置 `api_key`) |
|
||||
|
||||
## 配置说明
|
||||
|
||||
`tavily_search` 需要在工具配置中填入 `api_key`,可选参数:
|
||||
|
||||
- `max_results`:返回结果条数,默认 `5`
|
||||
- `search_depth`:`basic` 或 `advanced`
|
||||
- `include_answer`:是否带 LLM 摘要,默认 `true`
|
||||
|
||||
其他工具开箱即用,无需配置。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- `shell_executor` / `python_executor` 会在受限沙箱内执行,但仍建议在受信环境下使用
|
||||
- `edit_file` / `write_file` 会修改本地文件系统,注意权限范围
|
||||
@@ -0,0 +1,17 @@
|
||||
from .shell_executor import shell_executor
|
||||
from .file_reader import file_reader
|
||||
from .edit_file import edit_file
|
||||
from .write_file import write_file
|
||||
from .search_file import search_file
|
||||
from .python_executor import python_executor
|
||||
from .tavily_search import tavily_search
|
||||
|
||||
__all__ = [
|
||||
"shell_executor",
|
||||
"file_reader",
|
||||
"edit_file",
|
||||
"write_file",
|
||||
"search_file",
|
||||
"python_executor",
|
||||
"tavily_search",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
|
||||
|
||||
async def edit_file(
|
||||
file_path: str,
|
||||
old_content: str,
|
||||
new_content: str,
|
||||
) -> str:
|
||||
"""通过查找替换的方式编辑文件内容。
|
||||
|
||||
Args:
|
||||
file_path: 文件的路径
|
||||
old_content: 要被替换的原始内容片段
|
||||
new_content: 替换后的新内容
|
||||
|
||||
Returns:
|
||||
操作结果描述
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
file_path = validate_path(file_path, write=True)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return f"[Error] 文件不存在: {file_path}"
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
if old_content not in content:
|
||||
return f"[Error] 未在文件中找到要替换的内容片段"
|
||||
|
||||
new_file_content = content.replace(old_content, new_content, 1)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_file_content)
|
||||
|
||||
return f"已成功编辑文件: {file_path}"
|
||||
except Exception as e:
|
||||
return f"[Error] 编辑文件失败: {e}"
|
||||
@@ -0,0 +1,23 @@
|
||||
async def file_reader(file_path: str) -> str:
|
||||
"""读取本地文件的内容。
|
||||
|
||||
Args:
|
||||
file_path: 文件的绝对路径或相对路径
|
||||
|
||||
Returns:
|
||||
文件内容文本,若文件不存在则返回错误信息
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
file_path = validate_path(file_path, write=False)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return f"[Error] File not found: {file_path}"
|
||||
except Exception as e:
|
||||
return f"[Error] Failed to read file: {str(e)}"
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "基础工具集",
|
||||
"version": "0.1.0",
|
||||
"description": "文件读写、命令执行、Python/搜索等通用能力",
|
||||
"tools": [
|
||||
{
|
||||
"name": "shell_executor",
|
||||
"file": "shell_executor.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "file_reader",
|
||||
"file": "file_reader.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "edit_file",
|
||||
"file": "edit_file.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"file": "write_file.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "search_file",
|
||||
"file": "search_file.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "python_executor",
|
||||
"file": "python_executor.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "tavily_search",
|
||||
"file": "tavily_search.py",
|
||||
"is_system": false,
|
||||
"action_scope": ["consciousness_node", "regulatory_node"],
|
||||
"config_args": {
|
||||
"api_key": "",
|
||||
"max_results": "5",
|
||||
"search_depth": "basic",
|
||||
"include_answer": "true"
|
||||
},
|
||||
"category": "search"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
async def python_executor(code: str, timeout: int = 30) -> str:
|
||||
"""执行 Python 代码片段并返回输出。
|
||||
|
||||
Args:
|
||||
code: 要执行的 Python 代码
|
||||
timeout: 超时秒数,默认 30 秒
|
||||
|
||||
Returns:
|
||||
代码的标准输出 + 标准错误
|
||||
"""
|
||||
from kilostar.utils.sandbox import (
|
||||
validate_python_code, CodeViolation, get_python_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
code = validate_python_code(code)
|
||||
except CodeViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
timeout = get_python_timeout(timeout)
|
||||
|
||||
tmp_file = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(code)
|
||||
tmp_file = f.name
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, tmp_file,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
err_output = stderr.decode("utf-8", errors="replace")
|
||||
result = ""
|
||||
if output:
|
||||
result += output
|
||||
if err_output:
|
||||
result += f"\n[stderr]\n{err_output}"
|
||||
if proc.returncode != 0:
|
||||
result += f"\n[exit code: {proc.returncode}]"
|
||||
return result.strip() or "(no output)"
|
||||
except asyncio.TimeoutError:
|
||||
return f"[Error] Python 代码执行超时({timeout}s)"
|
||||
except Exception as e:
|
||||
return f"[Error] 执行失败: {e}"
|
||||
finally:
|
||||
if tmp_file and os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
@@ -0,0 +1,55 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
async def search_file(
|
||||
keyword: str,
|
||||
directory: str = ".",
|
||||
file_pattern: str = "*",
|
||||
max_results: int = 20,
|
||||
) -> str:
|
||||
"""在指定目录下递归搜索包含关键字的文件内容。
|
||||
|
||||
Args:
|
||||
keyword: 要搜索的关键字或正则表达式
|
||||
directory: 搜索的根目录,默认当前目录
|
||||
file_pattern: 文件名匹配模式,如 "*.py"
|
||||
max_results: 最大返回结果数
|
||||
|
||||
Returns:
|
||||
匹配的文件名和行内容
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
directory = validate_path(directory, write=False)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
max_results = min(max_results, 100)
|
||||
|
||||
try:
|
||||
grep_args = [
|
||||
"grep", "-rn",
|
||||
f"--include={file_pattern}",
|
||||
"-m", str(max_results),
|
||||
"--", keyword, directory,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*grep_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=30
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace").strip()
|
||||
if not output:
|
||||
return f"未找到包含 '{keyword}' 的匹配项"
|
||||
lines = output.split("\n")
|
||||
if len(lines) > max_results:
|
||||
output = "\n".join(lines[:max_results])
|
||||
return output
|
||||
except asyncio.TimeoutError:
|
||||
return "[Error] 搜索超时"
|
||||
except Exception as e:
|
||||
return f"[Error] 搜索失败: {e}"
|
||||
@@ -0,0 +1,46 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
async def shell_executor(command: str, timeout: int = 30) -> str:
|
||||
"""在服务器上执行 shell 命令并返回输出。
|
||||
|
||||
Args:
|
||||
command: 要执行的 shell 命令
|
||||
timeout: 超时秒数,默认 30 秒
|
||||
|
||||
Returns:
|
||||
命令的 stdout + stderr 输出
|
||||
"""
|
||||
from kilostar.utils.sandbox import (
|
||||
validate_shell_command, CommandViolation, get_shell_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
command = validate_shell_command(command)
|
||||
except CommandViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
timeout = get_shell_timeout(timeout)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
err_output = stderr.decode("utf-8", errors="replace")
|
||||
result = ""
|
||||
if output:
|
||||
result += output
|
||||
if err_output:
|
||||
result += f"\n[stderr]\n{err_output}"
|
||||
if proc.returncode != 0:
|
||||
result += f"\n[exit code: {proc.returncode}]"
|
||||
return result.strip() or "(no output)"
|
||||
except asyncio.TimeoutError:
|
||||
return f"[Error] 命令执行超时({timeout}s)"
|
||||
except Exception as e:
|
||||
return f"[Error] 执行失败: {e}"
|
||||
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from tavily import AsyncTavilyClient
|
||||
|
||||
|
||||
async def _resolve_api_key(explicit: Optional[str]) -> Optional[str]:
|
||||
"""按优先级解析 Tavily API key:显式参数 > GSM 配置 > 环境变量。"""
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
from kilostar.core.global_state_machine.gsm_snapshot import fetch_snapshot
|
||||
|
||||
snapshot = await fetch_snapshot()
|
||||
cfg = snapshot.tool_configs.get("tavily_search") or {}
|
||||
if isinstance(cfg, dict) and cfg.get("api_key"):
|
||||
return cfg["api_key"]
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get("TAVILY_API_KEY")
|
||||
|
||||
|
||||
async def tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
search_depth: str = "basic",
|
||||
include_answer: bool = True,
|
||||
api_key: Optional[str] = None,
|
||||
) -> str:
|
||||
"""使用 Tavily 进行网络搜索,获取高质量的网络搜索结果。
|
||||
|
||||
Args:
|
||||
query: 搜索查询内容
|
||||
max_results: 返回的最大结果数量(1-10)
|
||||
search_depth: 搜索深度,"basic" 或 "advanced"
|
||||
include_answer: 是否包含 AI 生成的答案摘要
|
||||
api_key: 可选;不传则按 GSM 配置 → 环境变量顺序解析
|
||||
|
||||
Returns:
|
||||
格式化的搜索结果文本,包含标题、URL、摘要和可选的 AI 答案
|
||||
"""
|
||||
resolved_key = await _resolve_api_key(api_key)
|
||||
if not resolved_key:
|
||||
return (
|
||||
"[Error] Tavily API key 未配置。"
|
||||
"请在 ``/api/v1/resource/tool/config`` 写入或设置环境变量 ``TAVILY_API_KEY``。"
|
||||
)
|
||||
|
||||
try:
|
||||
client = AsyncTavilyClient(api_key=resolved_key)
|
||||
result = await client.search(
|
||||
query=query,
|
||||
max_results=min(max_results, 10),
|
||||
search_depth=search_depth,
|
||||
include_answer=include_answer,
|
||||
)
|
||||
|
||||
lines = []
|
||||
if include_answer and result.get("answer"):
|
||||
lines.append(f"【AI 摘要】{result['answer']}\n")
|
||||
|
||||
results = result.get("results", [])
|
||||
if not results:
|
||||
return "No results found for the query."
|
||||
|
||||
lines.append("【搜索结果】")
|
||||
for i, item in enumerate(results, 1):
|
||||
title = item.get("title", "Untitled")
|
||||
url = item.get("url", "")
|
||||
content = item.get("content", "").strip()
|
||||
lines.append(f"\n{i}. {title}")
|
||||
lines.append(f" URL: {url}")
|
||||
if content:
|
||||
lines.append(f" {content[:300]}{'...' if len(content) > 300 else ''}")
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"[Error] Tavily search failed: {str(e)}"
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
|
||||
async def write_file(file_path: str, content: str) -> str:
|
||||
"""将内容写入指定文件(会覆盖已有内容,自动创建目录)。
|
||||
|
||||
Args:
|
||||
file_path: 文件的路径
|
||||
content: 要写入的内容
|
||||
|
||||
Returns:
|
||||
操作结果描述
|
||||
"""
|
||||
from kilostar.utils.sandbox import validate_path, PathViolation
|
||||
|
||||
try:
|
||||
file_path = validate_path(file_path, write=True)
|
||||
except PathViolation as e:
|
||||
return f"[Sandbox] {e}"
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return f"已成功写入文件: {file_path}({len(content)} 字符)"
|
||||
except Exception as e:
|
||||
return f"[Error] 写入文件失败: {e}"
|
||||
@@ -0,0 +1,26 @@
|
||||
# interactive__toolset
|
||||
|
||||
KiloStar 工作流交互工具集。这些工具用于 Agent 与用户/前端之间的实时交互,依赖 `global_workflow_manager` 的消息通道。
|
||||
|
||||
## 工具列表
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `approval` | 在执行高风险操作前向用户发送审批请求,阻塞等待用户回复 |
|
||||
| `send_file` | 把 Agent 生成的文件作为附件推送到当前对话窗口,前端渲染为可下载卡片 |
|
||||
|
||||
## 使用前提
|
||||
|
||||
这两个工具需要工作流上下文:调用方必须在 deps 中传入 `trace_id`,工具会通过 `global_workflow_manager` 的 pending 队列与前端通信。
|
||||
|
||||
- 在普通聊天场景下,`send_file` 在 `trace_id` 为空时会退化为直接返回文件内容字符串
|
||||
- `approval` 在没有合法 `trace_id` 时会一直阻塞,建议仅在工作流节点中绑定
|
||||
|
||||
## 配置说明
|
||||
|
||||
无需任何配置,开箱即用。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- Agent 计划执行删除、转账等高风险操作前的人工确认
|
||||
- 让 Agent 把生成的报告、代码片段、图表数据以文件形式投递给用户
|
||||
@@ -0,0 +1,5 @@
|
||||
from .approval import approval
|
||||
|
||||
__all__ = [
|
||||
"approval",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
|
||||
async def approval(message: str, trace_id: str) -> str:
|
||||
"""当任务存在某些高风险操作或者计划需要让用户审批,发送请求给用户等待用户审批。
|
||||
|
||||
Args:
|
||||
message: 发送给用户的请求
|
||||
trace_id: 当前工作流的 trace_id
|
||||
|
||||
Returns:
|
||||
用户的审批结果
|
||||
"""
|
||||
actor_list = ray_actor_hook("global_workflow_manager")
|
||||
await actor_list.global_workflow_manager.put_pending.remote(trace_id, message)
|
||||
reply = await actor_list.global_workflow_manager.get_received.remote(trace_id)
|
||||
return reply
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "交互工具集",
|
||||
"version": "0.1.0",
|
||||
"description": "工作流场景下与用户/前端交互的工具(HITL 审批)",
|
||||
"tools": [
|
||||
{
|
||||
"name": "approval",
|
||||
"file": "approval.py",
|
||||
"is_system": true,
|
||||
"action_scope": [],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
from .query_workflow_status import query_workflow_status
|
||||
from .query_task_list import query_task_list
|
||||
from .send_file import send_file
|
||||
|
||||
__all__ = [
|
||||
"query_workflow_status",
|
||||
"query_task_list",
|
||||
"send_file",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "监管节点工具集",
|
||||
"version": "0.1.0",
|
||||
"description": "监管节点(regulatory_node)专属能力:查询工作流、查询任务列表、发送文件",
|
||||
"tools": [
|
||||
{
|
||||
"name": "query_workflow_status",
|
||||
"file": "query_workflow_status.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["regulatory_node"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "query_task_list",
|
||||
"file": "query_task_list.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["regulatory_node"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
},
|
||||
{
|
||||
"name": "send_file",
|
||||
"file": "send_file.py",
|
||||
"is_system": true,
|
||||
"action_scope": ["regulatory_node"],
|
||||
"config_args": {},
|
||||
"category": "system"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""query_task_list:列出当前用户的短任务记录。
|
||||
|
||||
regulatory_node 用以回答"我之前那份报告呢""昨天那个查询结果是什么"。
|
||||
返回 task 表中的精简元数据列表(不含工作流的 graph state、context 等)。
|
||||
|
||||
注:此处的 "task" 是 regulatory_node 完成的轻量短任务(出报告/写文件/查询整理等),
|
||||
与 workflow(多步骤复杂任务)是两套独立体系。如需查工作流进度,使用 query_workflow_status。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
|
||||
async def query_task_list(
|
||||
user_id: str,
|
||||
status_filter: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""列出当前用户的短任务记录,按时间倒序。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID(通常由调用方从对话上下文中带入)
|
||||
status_filter: 可选,按状态过滤(running/completed/failed)
|
||||
limit: 最多返回条数,默认 20
|
||||
|
||||
Returns:
|
||||
{
|
||||
"user_id": str,
|
||||
"tasks": [
|
||||
{"task_id": ..., "title": ..., "status": ...,
|
||||
"result_summary": ..., "created_at": ...}
|
||||
],
|
||||
"total": int
|
||||
}
|
||||
"""
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
rows: List[Dict[str, Any]] = await pg.list_tasks_by_user.remote(
|
||||
user_id=user_id,
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
) or []
|
||||
|
||||
tasks = [
|
||||
{
|
||||
"task_id": r.get("task_id"),
|
||||
"title": r.get("title"),
|
||||
"status": r.get("status"),
|
||||
"result_summary": r.get("result_summary"),
|
||||
"created_at": r.get("created_at"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""query_workflow_status:查询某个 trace_id 对应工作流的最近事件。
|
||||
|
||||
regulatory_node 在与用户对话时,可以借此工具回答"我那个任务跑到哪一步了"
|
||||
之类的问题。返回最近 N 条事件 + 当前工作流 status。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
|
||||
async def query_workflow_status(trace_id: str, limit: int = 10) -> Dict[str, Any]:
|
||||
"""查询指定工作流 trace_id 的状态与最近事件。
|
||||
|
||||
Args:
|
||||
trace_id: 工作流追踪 ID
|
||||
limit: 返回最近多少条事件,默认 10
|
||||
|
||||
Returns:
|
||||
{
|
||||
"trace_id": str,
|
||||
"status": str | None, # 工作流当前状态(pending/running/completed/failed)
|
||||
"title": str | None,
|
||||
"recent_events": [ # 最近事件,按时间倒序
|
||||
{"event_type": ..., "level": ..., "message": ..., "node_name": ..., "created_at": ...}
|
||||
]
|
||||
}
|
||||
"""
|
||||
pg = ray_actor_hook("postgres_database").postgres_database
|
||||
|
||||
workflow = await pg.get_workflow.remote(trace_id)
|
||||
events = await pg.query_event_logs.remote(trace_id=trace_id, limit=limit)
|
||||
|
||||
recent: List[Dict[str, Any]] = []
|
||||
for e in events or []:
|
||||
recent.append(
|
||||
{
|
||||
"event_type": getattr(e, "event_type", None),
|
||||
"level": getattr(e, "level", None),
|
||||
"message": getattr(e, "message", None),
|
||||
"node_name": getattr(e, "node_name", None),
|
||||
"created_at": str(getattr(e, "created_at", "")),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": getattr(workflow, "status", None) if workflow else None,
|
||||
"title": getattr(workflow, "title", None) if workflow else None,
|
||||
"recent_events": recent,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""send_file:在对话/工作流场景下投递一份文件给用户。
|
||||
|
||||
regulatory_node 直接对话场景下用此工具把生成的文件发给用户:
|
||||
- 工作流场景(带 trace_id):写入 data/artifact/<trace_id>/,前端通过 SSE
|
||||
收到带下载链接的卡片。
|
||||
- 直接对话场景(无 trace_id):退化为把文件内容拼回字符串返回给 agent,
|
||||
让 agent 再以代码块形式吐给用户。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
from kilostar.utils.settings import get_artifact_dir
|
||||
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
name = name.strip().replace("\\", "/").split("/")[-1]
|
||||
name = _SAFE_NAME_RE.sub("_", name)
|
||||
return name or "file"
|
||||
|
||||
|
||||
async def send_file(filename: str, content: str, trace_id: str = "") -> str:
|
||||
"""把 agent 生成的文件作为附件投递给用户。
|
||||
|
||||
Args:
|
||||
filename: 文件名(含扩展名),如 "report.md" / "main.py"
|
||||
content: 文件内容(UTF-8 文本)
|
||||
trace_id: 当前会话/工作流的 trace_id;为空时退化为直接返回内容
|
||||
|
||||
Returns:
|
||||
发送结果说明或文件内容
|
||||
"""
|
||||
if not trace_id:
|
||||
return f"文件 {filename} 内容如下:\n\n```\n{content}\n```"
|
||||
|
||||
safe_name = _sanitize_filename(filename)
|
||||
artifact_id = uuid.uuid4().hex[:12]
|
||||
trace_dir: Path = get_artifact_dir() / trace_id
|
||||
trace_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = trace_dir / f"{artifact_id}_{safe_name}"
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"type": "file",
|
||||
"filename": safe_name,
|
||||
"artifact_id": artifact_id,
|
||||
"url": f"/api/v1/resource/artifact/{trace_id}/{artifact_id}",
|
||||
"size": len(content.encode("utf-8")),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
actor_list = ray_actor_hook("global_workflow_manager")
|
||||
await actor_list.global_workflow_manager.put_pending.remote(
|
||||
trace_id, f"__FILE__{payload}"
|
||||
)
|
||||
return f"已发送文件: {safe_name}"
|
||||
+15
-15
@@ -1,24 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: pretor_db
|
||||
container_name: kilostar_db_test
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgrespassword
|
||||
POSTGRES_DB: pretor
|
||||
POSTGRES_PASSWORD: testpass123
|
||||
POSTGRES_DB: kilostar
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d kilostar"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pretor:
|
||||
kilostar:
|
||||
build: .
|
||||
container_name: pretor
|
||||
container_name: kilostar_test
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8265:8265"
|
||||
@@ -26,10 +24,12 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgrespassword
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_DB=pretor
|
||||
- SECRET_KEY=changethiskey12345
|
||||
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: testpass123
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: kilostar
|
||||
SECRET_KEY: test-secret-key-not-for-production
|
||||
KILOSTAR_SECRET_KEY: test-secret-key-not-for-production
|
||||
KILOSTAR_MODE: standalone
|
||||
KILOSTAR_ENV: dev
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# 项目结构
|
||||
|
||||
> 最后更新:2026-06-17
|
||||
|
||||
```
|
||||
KiloStar/
|
||||
├── main.py # 应用入口(standalone / distributed 双模式)
|
||||
├── pyproject.toml # Python 依赖与项目元数据(uv 管理)
|
||||
├── Dockerfile / docker-compose.yml # 容器化部署
|
||||
├── alembic/ # 数据库迁移脚本(顺序编号 0001~0009)
|
||||
├── config/ # 环境配置模板
|
||||
│ ├── .env.example # 环境变量模板
|
||||
│ ├── config.yml # 应用配置(provider/node 默认值)
|
||||
│ ├── workflow.yaml # 工作流重试策略
|
||||
│ └── sandbox.yaml # 沙箱策略(路径白名单/命令过滤)
|
||||
│
|
||||
├── kilostar/ # ===== 后端核心包 =====
|
||||
│ │
|
||||
│ ├── api/ # FastAPI 路由层(每个文件一个 APIRouter)
|
||||
│ │ ├── __init__.py # app 实例、中间件、异常处理、路由挂载
|
||||
│ │ ├── system.py # GET /health + /api/v1/system 系统信息
|
||||
│ │ ├── workflow.py # /api/v1/workflow CRUD / SSE / resume
|
||||
│ │ ├── chat.py # /api/v1/chat 对话(含历史上下文注入)
|
||||
│ │ ├── agent.py # /api/v1/agent Worker CRUD / 模板
|
||||
│ │ ├── resource.py # /api/v1/resource Toolset / Skill / Artifact
|
||||
│ │ ├── plugin.py # /api/v1/plugin 重型插件 submit/status/stream
|
||||
│ │ ├── provider.py # /api/v1/provider 模型供应商 CRUD
|
||||
│ │ ├── auth.py # /api/v1/auth 登录/注册/改密
|
||||
│ │ └── platform/ # 平台接入
|
||||
│ │ ├── frontend.py # SPA 静态资源 fallback
|
||||
│ │ └── onebot.py # OneBot v11 协议适配
|
||||
│ │
|
||||
│ ├── core/ # 核心业务逻辑
|
||||
│ │ ├── individual/ # 系统 Agent 节点
|
||||
│ │ │ ├── consciousness_node/ # 意识节点:接收用户命令 → 设计工作流 DAG
|
||||
│ │ │ │ ├── consciousness_node.py
|
||||
│ │ │ │ └── template.py # structured output 模板
|
||||
│ │ │ ├── regulatory_node/ # 监管节点:直面用户对话、质量把关
|
||||
│ │ │ │ ├── regulatory_node.py
|
||||
│ │ │ │ └── template.py
|
||||
│ │ │ ├── control_node/ # 控制节点(已废弃,名字保留给未来远程探针节点)
|
||||
│ │ │ │ ├── control_node.py
|
||||
│ │ │ │ └── template.py
|
||||
│ │ │ └── growth_node/ # 生长节点:能力自扩展(占位)
|
||||
│ │ │
|
||||
│ │ ├── work/ # 工作执行层
|
||||
│ │ │ ├── workflow/ # 工作流引擎
|
||||
│ │ │ │ ├── workflow_engine.py # 轮询 + 调度主循环
|
||||
│ │ │ │ ├── workflow.py # pydantic-graph 节点定义
|
||||
│ │ │ │ ├── model.py # WorkflowState / StepResult 等
|
||||
│ │ │ │ └── graph_persistence.py # 执行状态持久化到 PG
|
||||
│ │ │ ├── chat/ # 对话处理(占位)
|
||||
│ │ │ └── task/ # 短任务执行(占位)
|
||||
│ │ │
|
||||
│ │ ├── global_state_machine/ # 全局状态机 Actor(系统唯一真相源)
|
||||
│ │ │ ├── global_state_machine.py # GSM 主体:初始化、注册表读写、toolset 补种
|
||||
│ │ │ ├── gsm_snapshot.py # 不可变快照(放入 Ray Object Store 供快读)
|
||||
│ │ │ ├── individual_manager.py # Individual 注册/查询/删除
|
||||
│ │ │ ├── provider_manager.py # Provider 注册/CRUD/test_connection
|
||||
│ │ │ ├── tool_manager.py # Toolset 加载(读 manifest.json)+ 工具分发
|
||||
│ │ │ ├── skill_manager.py # Skill 元数据注册/查询
|
||||
│ │ │ └── model_provider/ # Provider 适配(per-vendor 子类)
|
||||
│ │ │ ├── base_provider.py # 抽象基类
|
||||
│ │ │ ├── openai_provider.py # OpenAI / 兼容接口
|
||||
│ │ │ ├── claude_provider.py # Anthropic Claude
|
||||
│ │ │ ├── gemini_provider.py # Google Gemini
|
||||
│ │ │ └── deepseek_provider.py # DeepSeek
|
||||
│ │ │
|
||||
│ │ ├── global_workflow_manager/ # 工作流调度 Actor
|
||||
│ │ │ └── global_workflow_manager.py # 消息队列、pending workflow 轮询
|
||||
│ │ │
|
||||
│ │ └── postgres_database/ # PostgreSQL DAO 层(Actor 门面模式)
|
||||
│ │ ├── postgres.py # 统一门面:组合所有子 DAO,ready_event 守卫
|
||||
│ │ ├── database_exception.py# @database_exception 装饰器(统一异常包装)
|
||||
│ │ ├── model/ # SQLAlchemy ORM 模型
|
||||
│ │ │ ├── base.py # DeclarativeBase
|
||||
│ │ │ ├── user.py # User + UserAuthority
|
||||
│ │ │ ├── provider.py # ProviderModel
|
||||
│ │ │ ├── individual.py # Base/Specialist/Ordinary/Special Individual
|
||||
│ │ │ ├── workflow.py # Workflow + Context + GraphState
|
||||
│ │ │ ├── chat_history.py # ChatHistoryRegister + Message
|
||||
│ │ │ ├── system_node.py # SystemNodeConfigModel
|
||||
│ │ │ ├── mcp_server.py # MCPServerModel
|
||||
│ │ │ ├── tool_config.py # ToolConfigModel
|
||||
│ │ │ ├── custom_toolset.py# CustomToolsetModel
|
||||
│ │ │ ├── persona_template.py# PersonaTemplate
|
||||
│ │ │ ├── system_event_log.py# SystemEventLog
|
||||
│ │ │ ├── org_task.py # OrgTask(重型插件任务)
|
||||
│ │ │ └── org_task_event.py# OrgTaskEvent(任务事件流)
|
||||
│ │ └── module/ # 各表 DAO 实现(async session + CRUD)
|
||||
│ │ ├── user.py / provider.py / individual.py / ...
|
||||
│ │ └── org_task.py # 重型插件任务 + 事件 DAO
|
||||
│ │
|
||||
│ ├── plugin_runtime/ # 重型插件(Organization)运行时
|
||||
│ │ ├── base_organization.py # 基类:asyncio.Queue 消费、dispatch/submit 双通道
|
||||
│ │ │ # react 循环、consult 工具、PG 持久化
|
||||
│ │ ├── plugin_manager.py # GlobalPluginManager Actor:bootstrap/install/reload
|
||||
│ │ ├── loader.py # discover_plugins + load_plugin + uv 依赖安装
|
||||
│ │ ├── tool_bridge.py # make_dispatch_tool() → 生成 dispatch_to_<org> 函数
|
||||
│ │ ├── manifest.py # OrgManifest pydantic 模型
|
||||
│ │ ├── agents_config.py # AgentsConfig / AgentDef / orchestration
|
||||
│ │ └── event.py # OrgEvent / OrgEventType / TaskState
|
||||
│ │
|
||||
│ ├── adapter/ # 模型适配器
|
||||
│ │ └── model_adapter/
|
||||
│ │ ├── agent_factory.py # AgentFactory:根据 provider+model 构建 pydantic-ai Agent
|
||||
│ │ └── deepseek_reasoner.py # DeepSeek R1 reasoning 特殊适配
|
||||
│ │
|
||||
│ ├── utils/ # ===== 工具函数层 =====
|
||||
│ │ ├── settings.py # AppSettings(pydantic-settings)+ 路径工具
|
||||
│ │ │ # get_settings / get_toolset_dir / get_plugin_dir / get_artifact_dir
|
||||
│ │ ├── config_loader.py # 多 YAML 统一加载 → AppConfig(workflow/sandbox/应用配置)
|
||||
│ │ ├── ray_compat.py # standalone/distributed 兼容层
|
||||
│ │ │ # @actor_class 装饰器、StandaloneProxy、_STANDALONE 标志
|
||||
│ │ ├── ray_hook.py # ray_actor_hook():按名字获取 Actor 句柄(两种模式统一)
|
||||
│ │ ├── access.py # JWT 认证 + RBAC 鉴权(Accessor / TokenData / RoleChecker)
|
||||
│ │ ├── crypto.py # Fernet 对称加密(API key 等敏感字段落盘加密)
|
||||
│ │ ├── error.py # 统一异常体系:KiloStarError / BusinessError / InfraError
|
||||
│ │ ├── logger.py # loguru + rich 日志(get_logger 按模块名取 logger)
|
||||
│ │ ├── request_context.py # contextvars 双层 ID:request_id + trace_id 传播
|
||||
│ │ ├── get_tool.py # 按工具名动态加载函数(扫描 manifest → importlib)
|
||||
│ │ ├── mcp_helper.py # MCP Server 实例创建(stdio/sse/http 三种传输)
|
||||
│ │ ├── sandbox.py # 工具沙箱:路径校验、命令黑名单、Python AST 检查
|
||||
│ │ ├── agent_model.py # Agent 通用 pydantic 响应模型(ResponseModel 等)
|
||||
│ │ ├── prompts.py # 系统节点 system prompt 模板(按角色×locale)
|
||||
│ │ ├── rate_limit.py # 滑动窗口内存限流器(按 IP,单实例用)
|
||||
│ │ ├── retry.py # @retry_on_retryable_error 装饰器(指数退避)
|
||||
│ │ ├── banner.py # 启动 banner ASCII art
|
||||
│ │ └── i18n.py # 国际化翻译(t() 函数,accept-language 解析)
|
||||
│ │
|
||||
│ ├── worker_cluster/ # Worker 集群管理
|
||||
│ │ └── worker_cluster.py # WorkerCluster Actor:按资源标签管理 worker 池
|
||||
│ │ # CPU / Core / GPU 三类,分布式下各一个 Actor
|
||||
│ │
|
||||
│ └── worker_individual/ # Worker 个体
|
||||
│ ├── base_individual.py # 抽象基类(生命周期 + 工具绑定)
|
||||
│ ├── ordinary_individual.py # 通用 Worker(接受任意 system prompt)
|
||||
│ ├── skill_individual.py # Skill Worker(加载指定 skill 执行)
|
||||
│ └── special_individual.py # 特殊 Worker(embedding/TTS/图像,占位)
|
||||
│
|
||||
├── data/ # ===== 数据/插件目录(运行时读取)=====
|
||||
│ ├── toolset/ # 工具集(每个子目录 = 一个 toolset)
|
||||
│ │ ├── base_toolset/ # 系统基础工具
|
||||
│ │ │ ├── manifest.json # 声明 7 个工具的元数据
|
||||
│ │ │ ├── shell_executor.py # Shell 命令执行
|
||||
│ │ │ ├── file_reader.py # 文件读取
|
||||
│ │ │ ├── edit_file.py # 文件编辑(diff patch)
|
||||
│ │ │ ├── write_file.py # 文件写入
|
||||
│ │ │ ├── search_file.py # 文件搜索(glob + grep)
|
||||
│ │ │ ├── python_executor.py # Python 代码执行(沙箱内)
|
||||
│ │ │ └── tavily_search.py # Tavily 网络搜索
|
||||
│ │ └── interactive_toolset/ # 交互工具(需要人/系统介入)
|
||||
│ │ ├── manifest.json
|
||||
│ │ ├── approval.py # 人工审批节点
|
||||
│ │ └── send_file.py # 文件下发(存 artifact + 推 SSE)
|
||||
│ │
|
||||
│ └── plugin/ # 重型插件(每个子目录 = 一个 Organization)
|
||||
│ └── example_dept/ # 示例插件(开发模板)
|
||||
│ ├── manifest.json # 插件元数据(name/entry/concurrency/...)
|
||||
│ ├── agents.json # 内部 agent 定义(analyst + executor)
|
||||
│ ├── README.md
|
||||
│ ├── core/ # 业务逻辑入口
|
||||
│ │ └── organization.py # ExampleOrganization(BaseOrganization)
|
||||
│ ├── toolset/ # 插件本地工具(可选)
|
||||
│ ├── skills/ # 插件本地技能(可选)
|
||||
│ └── dashboard/ # 前端面板(占位,Tauri 化后接通)
|
||||
│
|
||||
├── frontend/ # ===== React 前端(Vite + TypeScript + Tailwind)=====
|
||||
│ └── src/
|
||||
│ ├── api/ # Axios client + SSE 封装 + 类型化请求
|
||||
│ ├── assets/ # 静态资源(图标/字体)
|
||||
│ ├── hooks/ # 自定义 React hooks
|
||||
│ ├── components/ # UI 组件
|
||||
│ │ ├── Chat/ # 工作流面板 + 实时日志 + 文件卡片
|
||||
│ │ ├── Agent/ # Worker / Provider 管理
|
||||
│ │ ├── Plugin/ # Skill / Toolset / MCP 配置
|
||||
│ │ ├── Auth/ # 登录 / 注册
|
||||
│ │ ├── Layout/ # 布局骨架 + 导航
|
||||
│ │ └── Settings/ # 系统设置
|
||||
│ ├── i18n/ # 国际化
|
||||
│ │ └── locales/ # zh.json / en.json
|
||||
│ ├── store/ # Zustand 状态管理
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
│
|
||||
├── tests/ # ===== 测试套件(331 用例)=====
|
||||
│ ├── unit/ # 单元测试(纯逻辑,mock 外部依赖)
|
||||
│ └── integration/ # 集成测试(启动真实服务)
|
||||
│
|
||||
└── subprojects/ # ===== 子项目 =====
|
||||
└── stardomain/ # Skill 脚本沙箱执行(local + Docker 双模式)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式
|
||||
|
||||
| 模式 | 说明 |
|
||||
|:--|:--|
|
||||
| `@actor_class` | 装饰器统一 standalone(普通对象)和 distributed(Ray Actor)两种运行形态 |
|
||||
| `ray_actor_hook(name)` | 按名字获取 actor 句柄,两种模式下接口一致 |
|
||||
| `StandaloneProxy` | 将普通方法调用包装为 `.remote()` 语法兼容 |
|
||||
| GSM Snapshot | 不可变快照放入 Object Store,各节点快速读取无需 RPC |
|
||||
| DAO 门面 | `PostgresDatabase` 组合所有子 DAO,`ready_event` 确保初始化后才放行 |
|
||||
| manifest.json 声明式 | 工具集/插件元数据与代码分离,支持热发现和前端展示 |
|
||||
| dispatch / submit 双通道 | 重型插件对内阁阻塞(dispatch),对用户射后不管(submit) |
|
||||
+94
-26
@@ -1,34 +1,102 @@
|
||||
## Pretor项目
|
||||
# KiloStar 项目介绍
|
||||
|
||||
#### 简介
|
||||
**Pretor**是一款python开发,实现将小模型进行微调后整理为一个大型集群,从而实现低算力情况下高复杂度任务的实现。
|
||||
系统模型分为以下部分:
|
||||
- **监管节点**:负责基本交流和任务分流;
|
||||
- **管控节点**:负责调度系统资源;
|
||||
- **意识节点**:负责复杂任务的处理;
|
||||
- **生长节点**:负责获取资源并且将基础模型训练为特化模型;
|
||||
- **特殊子个体**:与外界交互的模型,如embedding模型,tts模型等;
|
||||
- **专家子个体**:携带有专业skill的agent对象;
|
||||
- **基础子个体**:普通的agent对象;
|
||||
---
|
||||
#### 项目介绍
|
||||
**Pretor** 是一款基于分布式计算平台 **Ray** 和 agent开发框架**pydantic-AI** 开发的多智能体协作平台,通过多智能体的协作和任务拆解,实现复杂任务的高质量完成。
|
||||
## 简介
|
||||
|
||||
**Pretor**使用 **python**著名的高性能后端框架 **Fastapi** 来作为整个系统对用户暴露接口的网关。在**Pretor**运行过程中,用户通过发送请求至fastapi从而包装为 `PretorEvent`对象,并且发往`supervisory_node`,由**supervisory_node**进行简单的意图判断,如果判断用户只是简单交流比如聊天等,**supervisory_node**会直接对用户进行回复结束事件。
|
||||
,如果判断用户想要完成复杂的任务,**supervisory_node**会选择将从`workflow_template(工作流模板)`中选择一个或者不选择,然后将event挂到`全局状态机`实现追溯方便并发往`Workflow_Running_Engine`的异步队列,被协程对象取走后,由**consciousness_node**创建为`PretorWorkflow`对象,挂载到实例化的`WorkflowEngine`进行执行。完成任务后返回给用户。
|
||||
**KiloStar(千星)** 是一个开源的通用多 Agent 协作平台,提供从模型接入、Agent 编排、工作流执行到插件扩展的完整能力栈。系统基于 [Ray](https://www.ray.io/) 实现分布式执行,基于 [Pydantic-AI](https://ai.pydantic.dev/) 提供类型安全的 Agent 开发框架,并通过 [FastAPI](https://fastapi.tiangolo.com/) 网关对外暴露统一接口。
|
||||
|
||||
平台同时支持云端 API 模型与本地微调模型,内置多 Agent 协作的核心系统节点,并通过**重型插件**机制允许使用者把平台改造成面向具体场景的专用 Agent 应用。
|
||||
|
||||
---
|
||||
#### 技术架构背景
|
||||
|
||||
- 分布式大脑:利用 Ray 框架实现 Actor 模型,将不同的智能体节点(Node)部署为独立运行的分布式 Actor,具备跨节点通信和动态调度的能力。
|
||||
- 强类型通信协议:引入 PydanticAI 作为智能体开发框架,核心目的在于将大语言模型(LLM)产生的非结构化文本,通过 Pydantic 模型转化为强类型的结构化数据(JSON),确保多智能体协作时数据传输的工业级稳定性。
|
||||
- 推理驱动路由:系统针对最新的**deepseek-v4**系列进行了适配,实现灵活调用
|
||||
## 项目特色
|
||||
|
||||
1. **本地微调小模型一等公民**:内置 vLLM 适配,本地微调模型在调用层与云端 API 模型对等,使用者可以为不同 Agent 节点绑定不同的本地模型。
|
||||
2. **重型插件机制**:插件可附带独立前端页面、工具组与 API 接口,将 KiloStar 改造为编程辅助、学习助手、数据分析等专用 Agent 应用。
|
||||
3. **多 Agent 协作内核**:监管 / 意识 / 控制 / 生长四类系统节点 + 动态派生的 Worker 个体,原生支持任务拆解、调度、监督的分工模式。
|
||||
4. **standalone / distributed 双模式**:单机零依赖起步,集群按需横向扩展,业务代码在两种模式下完全一致。
|
||||
5. **私有化部署友好**:所有组件可在用户自有环境内运行,不强制依赖任何第三方服务。
|
||||
|
||||
---
|
||||
#### 项目背景
|
||||
###### 1.多智能体架构的需求
|
||||
随着任务复杂度的提升,单一**Agent**一定程度上以及满足不了人们对于人工智能完成复杂任务的需求。模仿人类社会中的团队合作,Pretor以**Ray**作为底座,从而实现一种多智能体协作的设计。
|
||||
###### 2.对于大语言模型输出内容约束的需求
|
||||
LLM 输出的非结构化文本在多智能体交互中极易崩溃。所以,**Pretor**没有选择如**LangChain**这种老牌智能体开发框架,而是选择了新兴的**pydanticAI**这种强约束框架,使得多智能体协作避免黑盒化。
|
||||
**PydanticAI**是一款基于**Pydantic**的智能体开发框架,**Pydantic**是**python**中著名的数据类型约束库,**Pydantic**官方通过**Pydantic**的强约束,实现了对于LLM的生成约束。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 节点体系
|
||||
|
||||
| 节点类型 | 职责 |
|
||||
|:--|:--|
|
||||
| **Regulatory Node** | 监管节点。承担用户对话入口、意图判断、对话模式的工具调用,以及对工作流执行结果的监督 |
|
||||
| **Consciousness Node** | 意识节点。负责复杂任务的拆解、规划与工作流构建 |
|
||||
| **Control Node** | 控制节点。负责工作流执行过程中的路由调度与状态监控 |
|
||||
| **Growth Node** | 生长节点。负责集群与子个体的扩张 |
|
||||
| **Worker Individual** | Worker 个体。包括 Ordinary(基础 Agent)、Skill(携带专业技能的 Agent)、Special(与外部世界交互的特殊 Agent,例如 embedding / TTS) |
|
||||
|
||||
### 模块布局
|
||||
|
||||
```
|
||||
KiloStar/
|
||||
├── kilostar/
|
||||
│ ├── api/ # FastAPI 路由层
|
||||
│ ├── core/
|
||||
│ │ ├── individual/ # 各类系统节点实现
|
||||
│ │ ├── work/ # 工作流 / 对话 / 任务执行层
|
||||
│ │ ├── global_state_machine/ # 全局状态机(Provider / Config 等)
|
||||
│ │ ├── global_workflow_manager/ # 工作流消息队列 Actor
|
||||
│ │ └── postgres_database/ # PostgreSQL DAO 层
|
||||
│ ├── adapter/ # 模型适配器
|
||||
│ ├── plugin/ # 工具插件
|
||||
│ ├── worker_cluster/ # Worker 集群管理
|
||||
│ ├── worker_individual/ # Worker 个体生命周期
|
||||
│ └── utils/ # 鉴权 / Ray 句柄 / 配置等
|
||||
├── frontend/ # React + Vite 前端
|
||||
├── subprojects/ # Rust 子项目(viceroy / stardomain)
|
||||
├── alembic/ # 数据库迁移
|
||||
├── tests/ # 单元 + 集成测试
|
||||
└── docs/ # 设计文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 选型 | 说明 |
|
||||
|:--|:--|:--|
|
||||
| 分布式运行时 | **Ray** | Actor 模型、跨进程跨机器通信、自定义资源调度 |
|
||||
| Agent 框架 | **Pydantic-AI** | LLM 输出强类型约束,避免多 Agent 协作中的非结构化文本黑盒 |
|
||||
| Web 网关 | **FastAPI + Ray Serve** | 异步 HTTP 网关与 SSE 事件流 |
|
||||
| 工作流引擎 | **pydantic-graph** | 有向图工作流编排,支持条件分支、循环、人工介入 |
|
||||
| 状态持久化 | **PostgreSQL + Alembic** | workflow 中断恢复、Agent 配置、Provider 注册等 |
|
||||
| 前端 | **React 19 + Vite + Tailwind + Zustand** | TypeScript 单页应用 |
|
||||
| 子项目 | **Rust** | viceroy(Skill 安装)、stardomain(沙箱执行) |
|
||||
|
||||
---
|
||||
|
||||
## 运行模式
|
||||
|
||||
### standalone 单机模式
|
||||
|
||||
通过 `KILOSTAR_MODE=standalone` 启动,所有 Actor 退化为普通 Python 异步实例,无需安装 Ray,适合个人使用与开发调试。
|
||||
|
||||
### distributed 分布式模式
|
||||
|
||||
默认模式。Ray 启动后将系统节点部署为命名 Actor,WorkerCluster 按 `kilostar_node_cpu` / `core` / `gpu` 自定义资源调度到对应物理节点,支持跨机器横向扩展。
|
||||
|
||||
两种模式共享同一套业务代码,通过 `kilostar.utils.standalone_proxy` 在 Actor Handle 层做透明适配。
|
||||
|
||||
---
|
||||
|
||||
## 安全设计
|
||||
|
||||
- **JWT 鉴权**:所有 API 端点(含 SSE 事件流)均走 Bearer Token 认证
|
||||
- **资源归属校验**:workflow / chat 等用户资源严格绑定 user_id,跨用户访问返回 403
|
||||
- **fetch-based SSE**:Token 走 `Authorization` header,不暴露在 URL 中
|
||||
- **生产模式密钥校验**:未提供有效 SECRET_KEY 时拒绝以 production 模式启动
|
||||
|
||||
---
|
||||
|
||||
## 生态子项目
|
||||
|
||||
| 项目 | 代号 | 功能 | 状态 |
|
||||
|:--|:--|:--|:--|
|
||||
| [kilostar-viceroy](https://github.com/zhaoxi826/viceroy) | 总督 | Skill 动态安装与全集群分发 | ✅ 已发布 |
|
||||
| [kilostar-stardomain](../subprojects/stardomain) | 星域 | Skill / 插件脚本沙箱执行 | 开发中 |
|
||||
| [kilostar-thought](https://github.com/zhaoxi826/thought) | 思绪 | Agent 增强记忆系统 | 开发中 |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
KiloStar is a distributed multi-agent system. Python/FastAPI backend with Ray actor orchestration, React 19 frontend, PostgreSQL storage. Supports two deployment modes: standalone (pure asyncio) and distributed (full Ray cluster).
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
uv sync # Install Python deps
|
||||
uv run python main.py # Start distributed mode
|
||||
KILOSTAR_MODE=standalone uv run python main.py # Start standalone mode
|
||||
```
|
||||
|
||||
### Frontend (this directory)
|
||||
```bash
|
||||
npm install # Install deps
|
||||
npm run dev # Dev server (Vite)
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint
|
||||
npx tsc --noEmit # Type-check without emitting
|
||||
```
|
||||
|
||||
### Database Migrations (from project root)
|
||||
```bash
|
||||
make db-revision m="description" # Generate migration
|
||||
make db-upgrade # Apply to HEAD
|
||||
make db-downgrade # Rollback one
|
||||
```
|
||||
|
||||
### Testing (from project root)
|
||||
```bash
|
||||
uv run pytest tests -q # Full suite
|
||||
uv run pytest tests/unit -q # Unit only
|
||||
uv run pytest tests/integration -q # Integration (needs DB)
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up -d # Full stack (frontend build + backend)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dual-Mode Actor System
|
||||
|
||||
All core components are Ray actors in distributed mode, plain Python instances in standalone mode. Code uses a unified call pattern:
|
||||
|
||||
```python
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
# Returns a namespace object with actor handles
|
||||
actors = ray_actor_hook("postgres_database")
|
||||
await actors.postgres_database.some_method.remote(arg)
|
||||
```
|
||||
|
||||
`StandaloneProxy` (in `kilostar/utils/standalone_proxy.py`) wraps instances to expose the same `.method.remote()` interface via asyncio. The `@actor_class` decorator marks classes that can be Ray actors or standalone instances depending on mode.
|
||||
|
||||
Mode is set via `KILOSTAR_MODE` env var. Entry point `main.py` branches into `start_standalone()` or `start_distributed()`.
|
||||
|
||||
### Backend Layout (`kilostar/`)
|
||||
|
||||
- `api/` — FastAPI routers (auth, chat, agent, workflow, system, resource, platform)
|
||||
- `core/individual/` — 4 node types: RegulatoryNode (user-facing QA + short tasks), ConsciousnessNode (workflow planning), ControlNode (deprecated; name reserved for future remote-probe node), GrowthNode (capability expansion, not yet implemented)
|
||||
- `core/global_state_machine/` — Provider registry, model config state
|
||||
- `core/global_workflow_manager/` — Workflow queue & recovery
|
||||
- `core/postgres_database/` — DAO layer: `model/` (SQLAlchemy models), `module/` (CRUD methods), `postgres.py` (facade)
|
||||
- `worker_cluster/` — Task queue & worker dispatch
|
||||
- `adapter/` — LLM model adapters (pydantic-ai AgentFactory)
|
||||
- `plugin/tool_plugin/` — MCP-style tool plugins
|
||||
- `utils/` — ray_hook, standalone_proxy, config_loader, access (JWT), i18n
|
||||
|
||||
### Frontend Layout (`frontend/src/`)
|
||||
|
||||
- `store/` — Zustand stores (useAppStore, useChatStore, etc.)
|
||||
- `components/` — Chat, Agent, Plugin, Settings, Layout
|
||||
- `api/client.ts` — Axios instance with auth interceptor
|
||||
- `i18n/` — Chinese + English translations
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Database facade**: `postgres.py` delegates to per-entity modules (`module/chat_history.py`, `module/persona_template.py`, etc.). Always add new DB methods to both the module AND the facade.
|
||||
- **pydantic-ai agents**: Regulatory/Consciousness nodes use pydantic-ai with structured output (tool calls). Streaming chat uses direct httpx calls to OpenAI-compatible API instead.
|
||||
- **SSE streaming**: Chat stream endpoint uses `StreamingResponse(media_type="text/event-stream")` with `data: {json}\n\n` format.
|
||||
- **Config**: Multi-YAML in `config/` directory, loaded via `config_loader.py` at startup.
|
||||
- **Alembic migrations**: `alembic/versions/` — naming convention: `YYYY_MM_DD_HHMM-NNNN_description.py`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `KILOSTAR_MODE` — `standalone` or omit for distributed
|
||||
- `KILOSTAR_SECRET_KEY` — JWT signing key
|
||||
- `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` — DB connection
|
||||
- `KILOSTAR_ENV` — `dev` or `prod`
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>pretor-dashboard</title>
|
||||
<title>kilostar-dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1913
-19
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "pretor-dashboard",
|
||||
"name": "kilostar-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -10,11 +10,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"axios": "^1.15.1",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
+118
-98
@@ -1,143 +1,131 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import i18n from './i18n';
|
||||
import { TopBar } from './components/Layout/TopBar';
|
||||
import { CollapsibleSidebar } from './components/Layout/CollapsibleSidebar';
|
||||
import { SetupGuideModal } from './components/Layout/SetupGuideModal';
|
||||
import { SettingsLayout } from './components/Settings/SettingsLayout';
|
||||
import { AgentLayout } from './components/Agent/AgentLayout';
|
||||
import { PluginLayout } from './components/Plugin/PluginLayout'; // Will rename to PluginLayout soon
|
||||
import { PluginLayout } from './components/Plugin/PluginLayout';
|
||||
import { ToolSettings } from './components/Plugin/ToolSettings';
|
||||
import { WorkflowConfigSettings } from './components/Agent/WorkflowConfigSettings';
|
||||
import { SystemLogsView } from './components/Agent/SystemLogsView';
|
||||
import { LeftPanel } from './components/Chat/LeftPanel';
|
||||
import { ChatPanel } from './components/Chat/ChatPanel';
|
||||
import { RightPanel } from './components/Chat/RightPanel';
|
||||
import { WorkflowListView } from './components/Chat/WorkflowListView';
|
||||
import { NewWorkflowDialog } from './components/Chat/NewWorkflowDialog';
|
||||
import { AuthPage } from './components/Auth/AuthPage';
|
||||
|
||||
// For Chat Module State Persistence
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
updatedAt: number;
|
||||
}
|
||||
import { HeavyPluginShell } from './plugins/HeavyPluginShell';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
import { useChatStore } from './store/useChatStore';
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const {
|
||||
isAuthenticated,
|
||||
setIsAuthenticated,
|
||||
mode,
|
||||
setMode,
|
||||
showSettings,
|
||||
workTab,
|
||||
agentTab,
|
||||
applyTheme,
|
||||
locale,
|
||||
loadInstalledPlugins,
|
||||
} = useAppStore();
|
||||
|
||||
// Layout State
|
||||
const [mode, setMode] = useState<'work' | 'agent'>('work');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const { loadSessions } = useChatStore();
|
||||
const [showSetupGuide, setShowSetupGuide] = useState(false);
|
||||
const activeHeavyPlugin = useAppStore((s) => s.activeHeavyPlugin);
|
||||
|
||||
// Module Sub-navigation States
|
||||
// Work Mode
|
||||
const [workTab, setWorkTab] = useState<'chat' | 'workflow'>('chat');
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
applyTheme();
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => applyTheme();
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, [applyTheme]);
|
||||
|
||||
// Agent Mode
|
||||
const [agentTab, setAgentTab] = useState<'plugin' | 'agents'>('plugin');
|
||||
|
||||
// Settings Sub-tab
|
||||
const [settingsTab, setSettingsTab] = useState('users');
|
||||
|
||||
// Inner Agent Tab (temporary until full Agent layout rewrite)
|
||||
const [innerAgentTab, setInnerAgentTab] = useState('worker');
|
||||
const [resourceTab, setResourceTab] = useState('skill');
|
||||
|
||||
// Chat State Hoisted for Persistence
|
||||
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (locale && i18n.language !== locale) {
|
||||
i18n.changeLanguage(locale);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}, []);
|
||||
}, [setIsAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSessions();
|
||||
loadInstalledPlugins();
|
||||
setShowSetupGuide(true);
|
||||
}
|
||||
}, [isAuthenticated, loadSessions, loadInstalledPlugins]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage onLoginSuccess={() => setIsAuthenticated(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
|
||||
{/* 1. Top Bar */}
|
||||
<TopBar
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
showSettings={showSettings}
|
||||
setShowSettings={setShowSettings}
|
||||
/>
|
||||
<div className="flex flex-col h-screen w-screen bg-bg-primary text-text-primary font-sans overflow-hidden">
|
||||
<TopBar />
|
||||
|
||||
{showSetupGuide && (
|
||||
<SetupGuideModal
|
||||
onClose={() => setShowSetupGuide(false)}
|
||||
onNavigateToAgent={() => {
|
||||
setMode('agent');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 2. Main Content Area */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{showSettings ? (
|
||||
<SettingsLayout settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
||||
<SettingsLayout />
|
||||
) : (
|
||||
<>
|
||||
{/* Collapsible Main Sidebar */}
|
||||
<CollapsibleSidebar
|
||||
mode={mode}
|
||||
isOpen={isSidebarOpen}
|
||||
setIsOpen={setIsSidebarOpen}
|
||||
workTab={workTab}
|
||||
setWorkTab={setWorkTab}
|
||||
agentTab={agentTab}
|
||||
setAgentTab={setAgentTab}
|
||||
/>
|
||||
<CollapsibleSidebar />
|
||||
|
||||
{/* Dynamic View based on Mode and Tab */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{mode === 'work' && workTab === 'chat' && (
|
||||
<div className="flex-1 p-6 flex overflow-hidden">
|
||||
<div className="flex-1 flex bg-white rounded-3xl shadow-md border border-slate-200 overflow-hidden relative">
|
||||
<LeftPanel
|
||||
activeTab="chats"
|
||||
selectedWorkflow={null}
|
||||
setSelectedWorkflow={() => {}}
|
||||
// Pass hoisted state down
|
||||
chatSessions={chatSessions}
|
||||
setChatSessions={setChatSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
setActiveSessionId={setActiveSessionId}
|
||||
/>
|
||||
<ChatPanel
|
||||
chatSessions={chatSessions}
|
||||
setChatSessions={setChatSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
setActiveSessionId={setActiveSessionId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="chats" />
|
||||
{activeHeavyPlugin ? (
|
||||
<HeavyPluginShell name={activeHeavyPlugin} />
|
||||
) : (
|
||||
<ChatPanel />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'work' && workTab === 'workflow' && (
|
||||
<>
|
||||
{selectedWorkflow ? (
|
||||
<>
|
||||
<LeftPanel
|
||||
activeTab="workflows"
|
||||
selectedWorkflow={selectedWorkflow}
|
||||
setSelectedWorkflow={setSelectedWorkflow}
|
||||
/>
|
||||
<RightPanel selectedWorkflow={selectedWorkflow} />
|
||||
</>
|
||||
) : (
|
||||
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
|
||||
)}
|
||||
</>
|
||||
<WorkflowShell />
|
||||
)}
|
||||
|
||||
{mode === 'agent' && agentTab === 'agents' && (
|
||||
<AgentLayout agentTab={innerAgentTab} setAgentTab={setInnerAgentTab} />
|
||||
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
|
||||
|
||||
{mode === 'agent' && agentTab === 'toolsets' && (
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<ToolSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'agent' && agentTab === 'plugin' && (
|
||||
<PluginLayout resourceTab={resourceTab} setResourceTab={setResourceTab} />
|
||||
{mode === 'agent' && agentTab === 'plugin' && <PluginLayout />}
|
||||
|
||||
{mode === 'agent' && agentTab === 'config' && (
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<WorkflowConfigSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'agent' && agentTab === 'logs' && (
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<SystemLogsView />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -147,4 +135,36 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowShell() {
|
||||
const { selectedWorkflow, setSelectedWorkflow } = useChatStore();
|
||||
|
||||
if (selectedWorkflow === 'new') {
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="workflows" />
|
||||
<NewWorkflowDialog
|
||||
onClose={() => setSelectedWorkflow(null)}
|
||||
onSuccess={(traceId: string) => setSelectedWorkflow(traceId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedWorkflow) {
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="workflows" />
|
||||
<RightPanel selectedWorkflow={selectedWorkflow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="workflows" />
|
||||
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -4,18 +4,21 @@ import axios from 'axios';
|
||||
// If missing, defaulting to '' means requests will be relative to the current browser origin.
|
||||
export const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
timeout: 10000,
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor to attach token to requests if we have one
|
||||
// Interceptor to attach token and locale to requests
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// 把用户语言偏好透传给后端,让 Agent prompt 和错误消息都能本地化
|
||||
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh';
|
||||
config.headers['Accept-Language'] = lang;
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。
|
||||
//
|
||||
// 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query,
|
||||
// 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。
|
||||
// 这里用 fetch 手动读取 text/event-stream,token 走标准 Authorization header。
|
||||
|
||||
export interface SSEHandlers {
|
||||
onOpen?: () => void;
|
||||
onMessage?: (data: string) => void;
|
||||
onError?: (err: unknown) => void;
|
||||
// 连接断开、准备重连时回调,附带本次退避延迟(毫秒)
|
||||
onReconnect?: (delayMs: number) => void;
|
||||
}
|
||||
|
||||
export interface SSEOptions {
|
||||
// 初始重连延迟(毫秒),默认 1000
|
||||
baseDelayMs?: number;
|
||||
// 最大重连延迟(毫秒),默认 30000
|
||||
maxDelayMs?: number;
|
||||
// 鉴权失败(401/403)时是否停止重连,默认 true
|
||||
stopOnAuthError?: boolean;
|
||||
}
|
||||
|
||||
export interface SSEConnection {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const AUTH_ERROR_STATUSES = new Set([401, 403]);
|
||||
|
||||
export function connectSSE(
|
||||
url: string,
|
||||
token: string,
|
||||
handlers: SSEHandlers,
|
||||
options: SSEOptions = {},
|
||||
): SSEConnection {
|
||||
const baseDelay = options.baseDelayMs ?? 1000;
|
||||
const maxDelay = options.maxDelayMs ?? 30000;
|
||||
const stopOnAuthError = options.stopOnAuthError ?? true;
|
||||
|
||||
let controller = new AbortController();
|
||||
let closed = false;
|
||||
let attempt = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closed) return;
|
||||
// 指数退避 + 抖动,封顶 maxDelay
|
||||
const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
||||
const delay = backoff / 2 + Math.random() * (backoff / 2);
|
||||
attempt += 1;
|
||||
handlers.onReconnect?.(delay);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (closed) return;
|
||||
controller = new AbortController();
|
||||
void run();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
handlers.onError?.(new Error(`SSE connect failed: ${resp.status}`));
|
||||
if (stopOnAuthError && AUTH_ERROR_STATUSES.has(resp.status)) {
|
||||
closed = true;
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接成功,重置退避计数
|
||||
attempt = 0;
|
||||
handlers.onOpen?.();
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
||||
const rawEvent = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const data = parseEventData(rawEvent);
|
||||
if (data !== null) handlers.onMessage?.(data);
|
||||
}
|
||||
}
|
||||
|
||||
// 流正常结束(服务端关闭),非主动 close 则尝试重连
|
||||
if (!closed) scheduleReconnect();
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted || closed) return;
|
||||
handlers.onError?.(err);
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
closed = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseEventData(rawEvent: string): string | null {
|
||||
// 只关心 data: 行,多行 data 用 \n 拼接,忽略注释(:)与其他字段
|
||||
const dataLines = rawEvent
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^ /, ''));
|
||||
if (dataLines.length === 0) return null;
|
||||
return dataLines.join('\n');
|
||||
}
|
||||
@@ -1,38 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../../store/useAppStore';
|
||||
import { ProvidersSettings } from './ProvidersSettings';
|
||||
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
|
||||
import { PersonaTemplateSettings } from './PersonaTemplateSettings';
|
||||
|
||||
interface AgentLayoutProps {
|
||||
agentTab: string;
|
||||
setAgentTab: (tab: string) => void;
|
||||
}
|
||||
export function AgentLayout() {
|
||||
const { t } = useTranslation();
|
||||
const { innerAgentTab, setInnerAgentTab } = useAppStore();
|
||||
|
||||
const tabs = [
|
||||
{ key: 'worker', label: t('agent.individual') },
|
||||
{ key: 'persona', label: t('agent.personaManagement') },
|
||||
{ key: 'providers', label: t('agent.providerManagement') },
|
||||
];
|
||||
|
||||
export function AgentLayout({ agentTab, setAgentTab }: AgentLayoutProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
|
||||
{/* Top Tabs for Agent Module */}
|
||||
<div className="h-14 border-b border-slate-200 bg-white flex items-center px-6 shadow-sm z-10 shrink-0 space-x-6">
|
||||
<div className="flex-1 flex flex-col bg-bg-secondary overflow-hidden">
|
||||
<div className="h-12 border-b border-border-primary bg-bg-card/80 backdrop-blur flex items-center px-6 shrink-0 gap-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
onClick={() => setAgentTab('worker')}
|
||||
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
agentTab === 'worker' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
|
||||
key={tab.key}
|
||||
onClick={() => setInnerAgentTab(tab.key)}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-lg transition-all ${
|
||||
innerAgentTab === tab.key
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAgentTab('providers')}
|
||||
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
agentTab === 'providers' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Provider Management
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{agentTab === 'worker' && <WorkerIndividualSettings />}
|
||||
{agentTab === 'providers' && <ProvidersSettings />}
|
||||
{innerAgentTab === 'worker' && <WorkerIndividualSettings />}
|
||||
{innerAgentTab === 'persona' && <PersonaTemplateSettings />}
|
||||
{innerAgentTab === 'providers' && <ProvidersSettings />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '../../api/client';
|
||||
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText } from 'lucide-react';
|
||||
|
||||
interface PersonaTemplate {
|
||||
template_id: string;
|
||||
name: string;
|
||||
system_prompt: string;
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export function PersonaTemplateSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [templates, setTemplates] = useState<PersonaTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<PersonaTemplate>>({});
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [modalMessage, setModalMessage] = useState('');
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get('/api/v1/agent/template');
|
||||
setTemplates(res.data.templates || []);
|
||||
} catch { setError(t('agent.loadFailed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditData({ name: '', system_prompt: '' });
|
||||
setIsNew(true);
|
||||
setIsEditing(true);
|
||||
setModalMessage('');
|
||||
};
|
||||
|
||||
const handleEdit = (tpl: PersonaTemplate) => {
|
||||
setEditData({ ...tpl });
|
||||
setIsNew(false);
|
||||
setIsEditing(true);
|
||||
setModalMessage('');
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(t('agent.deleteTemplateConfirm'))) return;
|
||||
try { await apiClient.delete(`/api/v1/agent/template/${id}`); fetchData(); }
|
||||
catch { alert(t('common.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleModalSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setModalMessage('');
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
const payload = { name: editData.name, system_prompt: editData.system_prompt };
|
||||
if (isNew) await apiClient.post('/api/v1/agent/template', payload);
|
||||
else await apiClient.put(`/api/v1/agent/template/${editData.template_id}`, payload);
|
||||
setIsEditing(false);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
setModalMessage(err.response?.data?.detail || t('common.saveFailed'));
|
||||
} finally { setSubmitLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-primary">{t('agent.personaManagement')}</h1>
|
||||
<p className="text-sm text-text-muted mt-0.5">{t('agent.personaManagementDesc')}</p>
|
||||
</div>
|
||||
<button onClick={handleAddNew} className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
|
||||
<Plus size={14} /> {t('agent.addTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-danger bg-danger-bg border border-danger/20 rounded-xl p-3">{error}</div>}
|
||||
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={24} className="animate-spin mb-3" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<FileText size={32} className="mb-3 opacity-40" />
|
||||
<span className="text-sm">{t('agent.noTemplates')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-secondary">
|
||||
{templates.map((tpl) => (
|
||||
<div key={tpl.template_id} className="flex items-center justify-between px-5 py-3.5 hover:bg-bg-hover transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center shrink-0">
|
||||
<FileText size={14} className="text-text-muted" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-text-primary text-sm">{tpl.name}</div>
|
||||
<div className="text-xs text-text-muted truncate mt-0.5">{tpl.system_prompt || t('agent.noPrompt')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0 ml-3">
|
||||
<button onClick={() => handleEdit(tpl)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
|
||||
<button onClick={() => handleDelete(tpl.template_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto border border-border-primary">
|
||||
<div className="flex justify-between items-center p-5 border-b border-border-primary">
|
||||
<h2 className="text-base font-bold text-text-primary">{isNew ? t('agent.addTemplate') : t('agent.editTemplate')}</h2>
|
||||
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleModalSave} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.templateName')}</label>
|
||||
<input type="text" required value={editData.name || ''} onChange={(e) => setEditData({...editData, name: e.target.value})}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label>
|
||||
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={6}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
|
||||
</div>
|
||||
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
|
||||
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
|
||||
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={submitLoading} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
|
||||
<Save size={14} /> {submitLoading ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
interface ProviderIconProps {
|
||||
type: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderIcon({ type, size = 18, className = '' }: ProviderIconProps) {
|
||||
const props = {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor',
|
||||
className,
|
||||
};
|
||||
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'openai':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'claude':
|
||||
case 'anthropic':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M4.709 15.955l4.72-2.647.079-.23-.079-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.328h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'deepseek':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.846-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'gemini':
|
||||
case 'google':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24">
|
||||
<path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'local':
|
||||
case 'ollama':
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M7 20h10M9 16v4M15 16v4" />
|
||||
<circle cx="8" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<path d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_BRAND_COLORS: Record<string, string> = {
|
||||
openai: '#10a37f',
|
||||
claude: '#d97757',
|
||||
anthropic: '#d97757',
|
||||
deepseek: '#4d6bfe',
|
||||
gemini: '#4285f4',
|
||||
google: '#4285f4',
|
||||
local: '#8a8a8a',
|
||||
ollama: '#8a8a8a',
|
||||
};
|
||||
|
||||
export function getProviderBrandColor(type: string): string {
|
||||
return PROVIDER_BRAND_COLORS[type?.toLowerCase()] || '#8a8a8a';
|
||||
}
|
||||
@@ -1,255 +1,455 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Plus, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, X, Loader2, Boxes, Zap, ChevronDown, ChevronRight, Settings as SettingsIcon } from 'lucide-react';
|
||||
import type { Provider } from '../../types';
|
||||
import apiClient from '../../api/client';
|
||||
import { ProviderIcon, getProviderBrandColor } from './ProviderIcon';
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: string;
|
||||
iconKey: string;
|
||||
backendType: 'openai' | 'claude' | 'deepseek' | 'gemini';
|
||||
defaultUrl: string;
|
||||
descKey: string;
|
||||
nameKey: string;
|
||||
}
|
||||
|
||||
const PROVIDER_TYPES: ProviderTypeOption[] = [
|
||||
{ id: 'openai', iconKey: 'openai', backendType: 'openai', defaultUrl: 'https://api.openai.com/v1', nameKey: 'agent.providerTypeOpenai', descKey: 'agent.providerTypeOpenaiDesc' },
|
||||
{ id: 'openai_compat', iconKey: 'openai', backendType: 'openai', defaultUrl: '', nameKey: 'agent.providerTypeOpenaiCompat', descKey: 'agent.providerTypeOpenaiCompatDesc' },
|
||||
{ id: 'anthropic', iconKey: 'claude', backendType: 'claude', defaultUrl: 'https://api.anthropic.com', nameKey: 'agent.providerTypeAnthropic', descKey: 'agent.providerTypeAnthropicDesc' },
|
||||
{ id: 'gemini', iconKey: 'gemini', backendType: 'gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta', nameKey: 'agent.providerTypeGemini', descKey: 'agent.providerTypeGeminiDesc' },
|
||||
{ id: 'deepseek', iconKey: 'deepseek', backendType: 'deepseek', defaultUrl: 'https://api.deepseek.com/v1', nameKey: 'agent.providerTypeDeepseek', descKey: 'agent.providerTypeDeepseekDesc' },
|
||||
];
|
||||
|
||||
function detectTypeFromProvider(p: Provider): string {
|
||||
if (p.provider_type === 'openai') {
|
||||
return p.provider_url?.includes('api.openai.com') ? 'openai' : 'openai_compat';
|
||||
}
|
||||
if (p.provider_type === 'claude') return 'anthropic';
|
||||
return p.provider_type || 'openai';
|
||||
}
|
||||
|
||||
export function ProvidersSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
provider_type: 'openai',
|
||||
provider_title: '',
|
||||
provider_url: '',
|
||||
provider_apikey: ''
|
||||
});
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const [selectedTypeId, setSelectedTypeId] = useState<string>('openai');
|
||||
const [formData, setFormData] = useState({ provider_title: '', provider_url: '', provider_apikey: '', custom_models: '', model_settings: '' });
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; error?: string; model_count?: number } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [modelSettingsError, setModelSettingsError] = useState('');
|
||||
|
||||
const selectedType = PROVIDER_TYPES.find((p) => p.id === selectedTypeId) || PROVIDER_TYPES[0];
|
||||
|
||||
const fetchProviders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/provider/list');
|
||||
const data = response.data.provider_list || {};
|
||||
const providerArray: Provider[] = Object.values(data);
|
||||
setProviders(providerArray);
|
||||
setProviders(Object.values(response.data.provider_list || {}));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers", error);
|
||||
console.error('Failed to fetch providers', error);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchProviders();
|
||||
}, []);
|
||||
useEffect(() => { fetchProviders(); }, []);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setFormData({
|
||||
provider_type: 'openai',
|
||||
provider_title: '',
|
||||
provider_url: '',
|
||||
provider_apikey: ''
|
||||
});
|
||||
const openAddModal = () => {
|
||||
setEditingProvider(null);
|
||||
setSelectedTypeId('openai');
|
||||
setFormData({ provider_title: '', provider_url: PROVIDER_TYPES[0].defaultUrl, provider_apikey: '', custom_models: '', model_settings: '' });
|
||||
setError('');
|
||||
setModelSettingsError('');
|
||||
setTestResult(null);
|
||||
setShowAdvanced(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
const openEditModal = (provider: Provider) => {
|
||||
const typeId = detectTypeFromProvider(provider);
|
||||
setEditingProvider(provider.provider_title);
|
||||
setSelectedTypeId(typeId);
|
||||
setFormData({
|
||||
provider_title: provider.provider_title,
|
||||
provider_url: provider.provider_url || '',
|
||||
provider_apikey: '',
|
||||
custom_models: '',
|
||||
model_settings: provider.model_settings && Object.keys(provider.model_settings).length > 0
|
||||
? JSON.stringify(provider.model_settings, null, 2)
|
||||
: '',
|
||||
});
|
||||
setError('');
|
||||
setModelSettingsError('');
|
||||
setTestResult(null);
|
||||
setShowAdvanced(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
const handleSelectType = (typeId: string) => {
|
||||
setSelectedTypeId(typeId);
|
||||
if (!editingProvider) {
|
||||
const tp = PROVIDER_TYPES.find((p) => p.id === typeId);
|
||||
if (tp) setFormData((prev) => ({ ...prev, provider_url: tp.defaultUrl }));
|
||||
}
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const parseModelSettings = (): { ok: true; value: Record<string, Record<string, unknown>> | undefined } | { ok: false } => {
|
||||
const raw = formData.model_settings.trim();
|
||||
if (!raw) return { ok: true, value: undefined };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
setModelSettingsError(t('agent.providerModelSettingsInvalid'));
|
||||
return { ok: false };
|
||||
}
|
||||
for (const v of Object.values(parsed)) {
|
||||
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
||||
setModelSettingsError(t('agent.providerModelSettingsInvalid'));
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
setModelSettingsError('');
|
||||
return { ok: true, value: parsed as Record<string, Record<string, unknown>> };
|
||||
} catch {
|
||||
setModelSettingsError(t('agent.providerModelSettingsInvalid'));
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayload = () => {
|
||||
const customModels = formData.custom_models
|
||||
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const payload: any = {
|
||||
provider_type: selectedType.backendType,
|
||||
provider_title: formData.provider_title,
|
||||
provider_url: formData.provider_url,
|
||||
provider_apikey: formData.provider_apikey,
|
||||
};
|
||||
if (customModels.length > 0) payload.custom_models = customModels;
|
||||
const ms = parseModelSettings();
|
||||
if (ms.ok && ms.value) payload.model_settings = ms.value;
|
||||
return payload;
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!formData.provider_url || !formData.provider_apikey) {
|
||||
setError(t('agent.providerFillUrlAndKey'));
|
||||
return;
|
||||
}
|
||||
setTestLoading(true);
|
||||
setTestResult(null);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await apiClient.post('/api/v1/provider/test', buildPayload());
|
||||
setTestResult(resp.data);
|
||||
} catch {
|
||||
setTestResult({ success: false, error: 'Request failed' });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.provider_title || !formData.provider_url || !formData.provider_apikey) {
|
||||
setError('Please fill in all fields.');
|
||||
setError(t('agent.providerFillAll'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ms = parseModelSettings();
|
||||
if (!ms.ok) return;
|
||||
setSubmitLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post('/api/v1/provider', formData);
|
||||
if (editingProvider) {
|
||||
await apiClient.delete(`/api/v1/provider/${editingProvider}`);
|
||||
}
|
||||
await apiClient.post('/api/v1/provider', buildPayload());
|
||||
await fetchProviders();
|
||||
handleCloseModal();
|
||||
setIsModalOpen(false);
|
||||
setEditingProvider(null);
|
||||
} catch (err) {
|
||||
console.error("Error adding provider", err);
|
||||
setError('Failed to add provider. Please check your inputs and try again.');
|
||||
setError(t('agent.providerAddFailed'));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-slate-800">Provider Management</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">Configure external AI model providers and API keys.</p>
|
||||
<h3 className="text-lg font-bold text-text-primary">{t('agent.providerManagement')}</h3>
|
||||
<p className="text-sm text-text-muted mt-0.5">{t('agent.providerDesc')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenModal}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
|
||||
>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Provider
|
||||
<button onClick={openAddModal}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
|
||||
<Plus size={14} /> {t('agent.addProvider')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-500 py-8">Loading providers...</div>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={24} className="animate-spin mb-3" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="text-center text-slate-500 py-8 bg-white rounded-xl border border-slate-200">
|
||||
No providers configured yet. Click "Add Provider" to get started.
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-bg-card rounded-2xl border border-border-primary border-dashed text-text-muted">
|
||||
<Boxes size={32} className="mb-3 opacity-40" />
|
||||
<span className="text-sm">{t('agent.noProviders')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{providers.map((provider, i) => (
|
||||
<div key={i} className="bg-white border border-slate-200 p-5 rounded-xl shadow-sm hover:border-blue-200 transition-colors flex flex-col justify-between">
|
||||
<div>
|
||||
<div key={i} className="bg-bg-card border border-border-primary rounded-2xl p-5 card-hover">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-100 flex items-center justify-center mr-3">
|
||||
<Box size={20} className="text-slate-600" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-bg-secondary border border-border-secondary flex items-center justify-center" style={{ color: getProviderBrandColor(provider.provider_type) }}>
|
||||
<ProviderIcon type={provider.provider_type} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800">{provider.provider_title}</h4>
|
||||
<span className="text-xs text-slate-500 font-mono uppercase">{provider.provider_type}</span>
|
||||
<h4 className="font-semibold text-sm text-text-primary">{provider.provider_title}</h4>
|
||||
<span className="text-[10px] text-text-muted font-mono uppercase">{provider.provider_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex items-center text-xs font-medium px-2 py-1 rounded-md border ${provider.status === 'Connected' ? 'bg-green-50 text-green-700 border-green-200' : 'bg-slate-50 text-slate-500 border-slate-200'}`}>
|
||||
{provider.status === 'Connected' && <span className="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5"></span>}
|
||||
{provider.status || 'Unknown'}
|
||||
<span className={`flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-lg border ${provider.status === 'Connected' || provider.provider_status === 'up' ? 'bg-success-bg text-success border-success/20' : 'bg-bg-secondary text-text-muted border-border-primary'}`}>
|
||||
{(provider.status === 'Connected' || provider.provider_status === 'up') && <span className="w-1 h-1 rounded-full bg-success" />}
|
||||
{provider.provider_status === 'up' ? 'Connected' : provider.status || t('common.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-slate-600 mb-1">URL / Endpoint:</p>
|
||||
<div className="bg-slate-50 border border-slate-100 rounded text-sm px-3 py-1.5 font-mono text-slate-700 truncate" title={provider.provider_url}>
|
||||
{provider.provider_url || 'Default'}
|
||||
<div className="bg-bg-secondary rounded-lg px-3 py-2 mb-3">
|
||||
<p className="text-[10px] text-text-muted mb-0.5">{t('agent.endpoint')}</p>
|
||||
<p className="text-xs font-mono text-text-secondary truncate">{provider.provider_url || t('common.default')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mt-2">
|
||||
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded hover:bg-slate-50 transition-colors cursor-pointer">Edit</button>
|
||||
{provider.provider_models && provider.provider_models.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('Are you sure you want to delete this provider?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/provider/${provider.provider_title}`);
|
||||
fetchProviders();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete provider', err);
|
||||
alert('Failed to delete provider');
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-slate-200 rounded hover:bg-red-50 transition-colors cursor-pointer"
|
||||
onClick={() => setExpandedProvider(expandedProvider === provider.provider_title ? null : provider.provider_title)}
|
||||
className="flex items-center gap-1.5 text-[11px] text-text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
Delete
|
||||
{expandedProvider === provider.provider_title ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{provider.provider_models.length} {t('agent.providerModels')}
|
||||
</button>
|
||||
{expandedProvider === provider.provider_title && (
|
||||
<div className="mt-2 bg-bg-secondary rounded-lg p-2 max-h-32 overflow-y-auto">
|
||||
{provider.provider_models.map((model: string) => (
|
||||
<div key={model} className="text-[11px] font-mono text-text-secondary py-0.5 px-1.5">{model}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => openEditModal(provider)} className="px-3 py-1.5 text-xs font-medium text-text-secondary bg-bg-secondary hover:bg-bg-hover rounded-lg transition-colors border border-border-primary">{t('common.edit')}</button>
|
||||
<button onClick={async () => {
|
||||
if (!confirm(t('agent.deleteProviderConfirm'))) return;
|
||||
try { await apiClient.delete(`/api/v1/provider/${provider.provider_title}`); fetchProviders(); } catch { alert(t('common.deleteFailed')); }
|
||||
}} className="px-3 py-1.5 text-xs font-medium text-danger bg-danger-bg hover:bg-danger-bg/80 rounded-lg transition-colors border border-danger/20">{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Provider Modal */}
|
||||
{/* Modal — 2 column layout */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center p-5 border-b border-slate-100">
|
||||
<h3 className="text-lg font-semibold text-slate-800">Add New Provider</h3>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={20} />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden border border-border-primary animate-fade-in-scale flex flex-col max-h-[90vh]">
|
||||
<div className="flex justify-between items-center px-5 py-4 border-b border-border-primary shrink-0">
|
||||
<h3 className="text-base font-bold text-text-primary">
|
||||
{editingProvider ? t('agent.editProvider') : t('agent.addNewProvider')}
|
||||
</h3>
|
||||
<button onClick={() => { setIsModalOpen(false); setEditingProvider(null); }} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100">
|
||||
{error}
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 overflow-hidden">
|
||||
{/* Left: provider type list */}
|
||||
<div className="w-56 shrink-0 border-r border-border-primary bg-bg-secondary/40 overflow-y-auto p-3 space-y-1">
|
||||
<div className="text-[10px] font-bold text-text-muted uppercase tracking-wider px-2 py-1.5">
|
||||
{t('agent.providerType')}
|
||||
</div>
|
||||
{PROVIDER_TYPES.map((type) => {
|
||||
const active = selectedTypeId === type.id;
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectType(type.id)}
|
||||
disabled={!!editingProvider}
|
||||
className={`w-full flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-left transition-all ${
|
||||
active
|
||||
? 'bg-bg-card border border-accent/40 shadow-sm'
|
||||
: 'border border-transparent hover:bg-bg-card/60'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg bg-bg-card border border-border-primary flex items-center justify-center shrink-0"
|
||||
style={{ color: getProviderBrandColor(type.iconKey) }}
|
||||
>
|
||||
<ProviderIcon type={type.iconKey} size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-semibold truncate ${active ? 'text-accent' : 'text-text-primary'}`}>
|
||||
{t(type.nameKey)}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted truncate">{t(type.descKey)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{error && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>}
|
||||
{testResult && (
|
||||
<div className={`p-3 text-sm rounded-xl border ${testResult.success ? 'bg-success-bg text-success border-success/20' : 'bg-danger-bg text-danger border-danger/20'}`}>
|
||||
{testResult.success
|
||||
? `${t('agent.providerTestSuccess')} · ${testResult.model_count} ${t('agent.providerModels')}`
|
||||
: `${t('agent.providerTestFailed')}: ${testResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Type</label>
|
||||
<select
|
||||
name="provider_type"
|
||||
value={formData.provider_type}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="local">Local</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Title</label>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.providerTitle')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="provider_title"
|
||||
placeholder="e.g. My OpenAI Instance"
|
||||
value={formData.provider_title}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
|
||||
onChange={(e) => setFormData({ ...formData, provider_title: e.target.value })}
|
||||
placeholder={t('agent.providerTitlePlaceholder')}
|
||||
disabled={!!editingProvider}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Base URL</label>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.baseUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="provider_url"
|
||||
placeholder="e.g. https://api.openai.com/v1"
|
||||
type="text"
|
||||
value={formData.provider_url}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
|
||||
onChange={(e) => setFormData({ ...formData, provider_url: e.target.value })}
|
||||
placeholder={selectedType.defaultUrl || t('agent.baseUrlPlaceholder')}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono"
|
||||
/>
|
||||
{selectedType.defaultUrl && !editingProvider && (
|
||||
<p className="text-[10px] text-text-muted mt-1">
|
||||
{t('agent.baseUrlHint')}: <span className="font-mono">{selectedType.defaultUrl}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">API Key</label>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="provider_apikey"
|
||||
placeholder="sk-..."
|
||||
value={formData.provider_apikey}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400 font-mono"
|
||||
onChange={(e) => setFormData({ ...formData, provider_apikey: e.target.value })}
|
||||
placeholder={editingProvider ? t('agent.apiKeyEditPlaceholder') : t('agent.apiKeyPlaceholder')}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end space-x-3">
|
||||
{/* 参数设置 — collapsible */}
|
||||
<div className="border border-border-primary rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 bg-bg-secondary/50 hover:bg-bg-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-text-secondary">
|
||||
<SettingsIcon size={13} />
|
||||
{t('agent.providerAdvanced')}
|
||||
</div>
|
||||
{showAdvanced ? <ChevronDown size={14} className="text-text-muted" /> : <ChevronRight size={14} className="text-text-muted" />}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
|
||||
>
|
||||
{submitLoading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
{showAdvanced && (
|
||||
<div className="p-4 space-y-3 bg-bg-card">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.providerCustomModels')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.custom_models}
|
||||
onChange={(e) => setFormData({ ...formData, custom_models: e.target.value })}
|
||||
placeholder={t('agent.providerCustomModelsPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono resize-none"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t('agent.providerCustomModelsHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
|
||||
{t('agent.providerModelSettings')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.model_settings}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, model_settings: e.target.value });
|
||||
if (modelSettingsError) setModelSettingsError('');
|
||||
}}
|
||||
placeholder={t('agent.providerModelSettingsPlaceholder')}
|
||||
rows={8}
|
||||
className={`w-full bg-bg-input border text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 text-text-primary placeholder:text-text-muted/50 font-mono resize-none ${
|
||||
modelSettingsError
|
||||
? 'border-danger/50 focus:ring-danger/20 focus:border-danger'
|
||||
: 'border-border-primary focus:ring-accent/20 focus:border-accent'
|
||||
}`}
|
||||
/>
|
||||
{modelSettingsError ? (
|
||||
<p className="text-[10px] text-danger mt-1">{modelSettingsError}</p>
|
||||
) : (
|
||||
'Add Provider'
|
||||
<p className="text-[10px] text-text-muted mt-1">{t('agent.providerModelSettingsHint')}</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border-primary bg-bg-secondary/30 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-accent bg-accent-light hover:bg-accent/20 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testLoading ? <Loader2 size={12} className="animate-spin" /> : <Zap size={12} />}
|
||||
{t('agent.testConnection')}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { setIsModalOpen(false); setEditingProvider(null); }} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitLoading ? t('common.saving') : editingProvider ? t('common.save') : t('agent.addProvider')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Server, GitBranch, ChevronDown, ChevronRight, Pause, Play } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
interface EventLog {
|
||||
id: number;
|
||||
trace_id: string;
|
||||
event_type: string;
|
||||
level: string;
|
||||
node_name: string | null;
|
||||
message: string;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface WorkflowSummary {
|
||||
trace_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface WorkflowStep {
|
||||
name: string;
|
||||
step: number;
|
||||
node: string;
|
||||
action: string;
|
||||
status: string;
|
||||
agent_id: string | null;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
error: 'text-red-400',
|
||||
warn: 'text-yellow-400',
|
||||
info: 'text-green-400',
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
completed: { bg: 'bg-success-bg', text: 'text-success' },
|
||||
failed: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]' },
|
||||
working: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]' },
|
||||
pending: { bg: 'bg-bg-secondary', text: 'text-text-muted' },
|
||||
};
|
||||
|
||||
export function SystemLogsView() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<'system' | 'workflow'>('system');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
// System logs state
|
||||
const [logs, setLogs] = useState<EventLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [traceFilter, setTraceFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Workflow logs state
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [wfLoading, setWfLoading] = useState(false);
|
||||
const [expandedStep, setExpandedStep] = useState<number | null>(null);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (traceFilter) params.set('trace_id', traceFilter);
|
||||
if (typeFilter) params.set('event_type', typeFilter);
|
||||
if (levelFilter) params.set('level', levelFilter);
|
||||
params.set('limit', '200');
|
||||
const resp = await apiClient.get(`/api/v1/system/logs?${params.toString()}`);
|
||||
setLogs(resp.data.logs || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [traceFilter, typeFilter, levelFilter]);
|
||||
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
const resp = await apiClient.get('/api/v1/workflow/list');
|
||||
setWorkflows(resp.data.workflows || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWorkflowDetail = async (traceId: string) => {
|
||||
setWfLoading(true);
|
||||
setExpandedStep(null);
|
||||
try {
|
||||
const resp = await apiClient.get(`/api/v1/workflow/${traceId}`);
|
||||
setWorkflowSteps(resp.data.steps || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setWorkflowSteps([]);
|
||||
} finally {
|
||||
setWfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'system') fetchLogs();
|
||||
else fetchWorkflows();
|
||||
}, [tab, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'system' || !autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [tab, autoRefresh, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTrace) fetchWorkflowDetail(selectedTrace);
|
||||
}, [selectedTrace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const formatTime = (ts: string | null) => {
|
||||
if (!ts) return '--:--:--';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex items-center gap-6 mb-6">
|
||||
<button
|
||||
onClick={() => setTab('system')}
|
||||
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'system' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
|
||||
>
|
||||
<Server size={14} />
|
||||
{t('agent.systemLogs')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('workflow')}
|
||||
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'workflow' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
{t('agent.workflowLogs')}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{tab === 'system' && (
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`p-2 rounded-lg transition-all ${autoRefresh ? 'text-accent bg-accent-light' : 'text-text-muted hover:text-accent hover:bg-accent-light'}`}
|
||||
title={autoRefresh ? t('agent.logPauseRefresh') : t('agent.logResumeRefresh')}
|
||||
>
|
||||
{autoRefresh ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => tab === 'system' ? fetchLogs() : fetchWorkflows()}
|
||||
disabled={loading || wfLoading}
|
||||
className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"
|
||||
>
|
||||
<RefreshCw size={16} className={(loading || wfLoading) ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'system' ? (
|
||||
<>
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={traceFilter}
|
||||
onChange={(e) => setTraceFilter(e.target.value)}
|
||||
placeholder="Trace ID"
|
||||
className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15 w-40"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
|
||||
>
|
||||
<option value="">{t('agent.logFilterAllTypes')}</option>
|
||||
<option value="workflow_start">workflow_start</option>
|
||||
<option value="step_enter">step_enter</option>
|
||||
<option value="step_complete">step_complete</option>
|
||||
<option value="step_error">step_error</option>
|
||||
<option value="workflow_complete">workflow_complete</option>
|
||||
<option value="workflow_fail">workflow_fail</option>
|
||||
<option value="system">system</option>
|
||||
</select>
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
|
||||
>
|
||||
<option value="">{t('agent.logFilterAllLevels')}</option>
|
||||
<option value="info">INFO</option>
|
||||
<option value="warn">WARN</option>
|
||||
<option value="error">ERROR</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{t('agent.logSearch')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal-style log display */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="bg-[#1a1b1e] border border-border-primary rounded-xl p-4 h-[60vh] overflow-y-auto font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">{t('agent.noLogs')}</div>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const levelColor = LEVEL_COLORS[log.level] || 'text-gray-400';
|
||||
return (
|
||||
<div key={log.id} className="py-0.5 hover:bg-white/5 px-1 rounded">
|
||||
<span className="text-gray-500">[{formatTime(log.created_at)}]</span>{' '}
|
||||
<span className={`font-bold ${levelColor}`}>{log.level.toUpperCase().padEnd(5)}</span>{' '}
|
||||
<span className="text-blue-300">{log.node_name || 'system'}</span>{' '}
|
||||
<span className="text-gray-200">{log.message}</span>
|
||||
{log.trace_id && <span className="text-gray-600 ml-2">#{log.trace_id.slice(-6)}</span>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-4 h-[calc(100vh-220px)]">
|
||||
{/* Workflow List */}
|
||||
<div className="w-72 shrink-0 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-border-primary bg-bg-secondary">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowLogList')}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-border-secondary">
|
||||
{workflows.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-text-muted text-xs">{t('workflow.noWorkflows')}</div>
|
||||
) : (
|
||||
workflows.map((wf) => (
|
||||
<button
|
||||
key={wf.trace_id}
|
||||
onClick={() => setSelectedTrace(wf.trace_id)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-bg-hover transition-colors ${selectedTrace === wf.trace_id ? 'bg-accent-light border-l-2 border-accent' : ''}`}
|
||||
>
|
||||
<div className="text-xs font-medium text-text-primary truncate">{wf.title}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).bg} ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).text}`}>
|
||||
{wf.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps Detail */}
|
||||
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
|
||||
{!selectedTrace ? (
|
||||
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
|
||||
{t('agent.selectWorkflowToView')}
|
||||
</div>
|
||||
) : wfLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{workflowSteps.length === 0 ? (
|
||||
<div className="text-center text-text-muted text-xs py-8">{t('workflow.noStepsYet')}</div>
|
||||
) : (
|
||||
workflowSteps.map((step, idx) => {
|
||||
const ss = STATUS_STYLES[step.status] || STATUS_STYLES.pending;
|
||||
const isExpanded = expandedStep === idx;
|
||||
return (
|
||||
<div key={idx} className="border border-border-primary rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : idx)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-secondary/50 transition-colors text-left"
|
||||
>
|
||||
{step.output ? (
|
||||
isExpanded ? <ChevronDown size={14} className="text-text-muted shrink-0" /> : <ChevronRight size={14} className="text-text-muted shrink-0" />
|
||||
) : (
|
||||
<div className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-text-muted w-6">{step.step || idx + 1}</span>
|
||||
<span className="text-xs font-medium text-text-primary flex-1 truncate">{step.name}</span>
|
||||
<span className="text-xs text-text-secondary mr-2">{step.node}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${ss.bg} ${ss.text}`}>{step.status}</span>
|
||||
</button>
|
||||
{isExpanded && step.output && (
|
||||
<div className="px-4 pb-3 pt-1 border-t border-border-secondary">
|
||||
<div className="bg-[#1a1b1e] rounded-lg p-3 text-xs text-gray-200 font-mono whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||
{step.output}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '../../api/client';
|
||||
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react';
|
||||
import { Save, Plus, Edit2, Trash2, X, Bot, Loader2, Users } from 'lucide-react';
|
||||
import type { Provider } from '../../types';
|
||||
|
||||
interface WorkerIndividual {
|
||||
@@ -10,83 +11,94 @@ interface WorkerIndividual {
|
||||
description?: string;
|
||||
provider_title: string;
|
||||
model_id: string;
|
||||
system_prompt?: string;
|
||||
output_template?: string; // Change to string for the form state
|
||||
bound_skill?: string; // Change to string for the form state
|
||||
workspace?: string; // Change to string for the form state
|
||||
tools?: string; // Form state for tools JSON array
|
||||
persona_id?: string;
|
||||
output_template?: string;
|
||||
bound_skill?: string;
|
||||
workspace?: string;
|
||||
toolsets?: string;
|
||||
}
|
||||
|
||||
interface PersonaTemplate {
|
||||
template_id: string;
|
||||
name: string;
|
||||
system_prompt: string;
|
||||
}
|
||||
|
||||
interface ToolsetItem {
|
||||
toolset_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tools: string[];
|
||||
is_system: boolean;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export function WorkerIndividualSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [workers, setWorkers] = useState<WorkerIndividual[]>([]);
|
||||
const [systemNodes, setSystemNodes] = useState<any[]>([]);
|
||||
const [personaTemplates, setPersonaTemplates] = useState<PersonaTemplate[]>([]);
|
||||
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
|
||||
const [availableTools, setAvailableTools] = useState<string[]>([]);
|
||||
const [availableToolsets, setAvailableToolsets] = useState<ToolsetItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<WorkerIndividual>>({});
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
|
||||
const [modalMessage, setModalMessage] = useState('');
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [provRes, workRes, sysRes, toolsRes, skillsRes] = await Promise.all([
|
||||
const [provRes, workRes, sysRes, toolsetRes, skillsRes, tplRes] = await Promise.all([
|
||||
apiClient.get('/api/v1/provider/list'),
|
||||
apiClient.get('/api/v1/agent/worker'),
|
||||
apiClient.get('/api/v1/agent'),
|
||||
apiClient.get('/api/v1/resource/tool'),
|
||||
apiClient.get('/api/v1/resource/skill')
|
||||
apiClient.get('/api/v1/resource/custom-toolset'),
|
||||
apiClient.get('/api/v1/resource/skill'),
|
||||
apiClient.get('/api/v1/agent/template')
|
||||
]);
|
||||
setProviders(Object.values(provRes.data.provider_list || {}));
|
||||
setWorkers(workRes.data.workers || []);
|
||||
|
||||
const allTools = toolsRes.data.tools || [];
|
||||
setAvailableTools(allTools);
|
||||
setAvailableToolsets(toolsetRes.data.toolsets || []);
|
||||
setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
|
||||
|
||||
const sysNodesData = sysRes.data.system_nodes || [];
|
||||
const defaultSysNodes = ['supervisory_node', 'consciousness_node', 'control_node'];
|
||||
setPersonaTemplates(tplRes.data.templates || []);
|
||||
|
||||
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
|
||||
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
|
||||
const sysNodesData = sysRes.data.system_nodes || [];
|
||||
const defaultSysNodes = ['regulatory_node', 'consciousness_node'];
|
||||
|
||||
const formattedSysNodes = defaultSysNodes.map(nodeName => {
|
||||
setSystemNodes(defaultSysNodes.map(nodeName => {
|
||||
const found = sysNodesData.find((n: any) => n.node_name === nodeName);
|
||||
return {
|
||||
agent_id: nodeName,
|
||||
agent_name: nodeName,
|
||||
agent_type: 'System Node',
|
||||
provider_title: found && found.provider_title ? found.provider_title : defaultProvider,
|
||||
model_id: found && found.model_id ? found.model_id : '',
|
||||
tools: found && found.tools ? JSON.stringify(found.tools) : '[]',
|
||||
agent_id: nodeName, agent_name: nodeName, agent_type: 'System Node',
|
||||
display_name: found?.display_name || '',
|
||||
provider_title: found?.provider_title || defaultProvider,
|
||||
model_id: found?.model_id || '',
|
||||
toolsets: found?.tools ? JSON.stringify(found.tools) : '[]',
|
||||
persona_id: found?.persona_id || '',
|
||||
is_system: true
|
||||
};
|
||||
});
|
||||
setSystemNodes(formattedSysNodes);
|
||||
}));
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('Failed to load data');
|
||||
setError(t('agent.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const handleEdit = (worker: any) => { // Accept the backend object which might have objects instead of strings
|
||||
const handleEdit = (worker: any) => {
|
||||
setEditData({
|
||||
...worker,
|
||||
output_template: typeof worker.output_template === 'string' ? worker.output_template : JSON.stringify(worker.output_template || {}),
|
||||
bound_skill: typeof worker.bound_skill === 'string' ? worker.bound_skill : JSON.stringify(worker.bound_skill || {}),
|
||||
workspace: typeof worker.workspace === 'string' ? worker.workspace : JSON.stringify(worker.workspace || []),
|
||||
tools: typeof worker.tools === 'string' ? worker.tools : JSON.stringify(worker.tools || [])
|
||||
toolsets: typeof worker.toolsets === 'string' ? worker.toolsets : JSON.stringify(worker.toolsets || worker.tools || [])
|
||||
});
|
||||
setIsNew(false);
|
||||
setIsEditing(true);
|
||||
@@ -94,137 +106,134 @@ export function WorkerIndividualSettings() {
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditData({
|
||||
agent_name: '',
|
||||
agent_type: 'ordinary_individual',
|
||||
description: '',
|
||||
provider_title: providers.length > 0 ? providers[0].provider_title : '',
|
||||
model_id: '',
|
||||
system_prompt: '',
|
||||
output_template: '{}',
|
||||
bound_skill: '{}',
|
||||
workspace: '[]',
|
||||
tools: '[]'
|
||||
});
|
||||
setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '',
|
||||
provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '',
|
||||
persona_id: '', output_template: '{}', bound_skill: '{}', workspace: '[]', toolsets: '[]' });
|
||||
setIsNew(true);
|
||||
setIsEditing(true);
|
||||
setModalMessage('');
|
||||
};
|
||||
|
||||
const handleDelete = async (agent_id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this agent?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/agent/worker/${agent_id}`);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert('Failed to delete agent');
|
||||
}
|
||||
if (!confirm(t('agent.deleteWorkerConfirm'))) return;
|
||||
try { await apiClient.delete(`/api/v1/agent/worker/${agent_id}`); fetchData(); } catch { alert(t('common.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleModalSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setModalMessage('');
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
if ((editData as any).is_system) {
|
||||
const payload = {
|
||||
await apiClient.post('/api/v1/agent', {
|
||||
individual_name: editData.agent_name,
|
||||
provider_title: editData.provider_title,
|
||||
model_id: editData.model_id,
|
||||
tools: JSON.parse(editData.tools || '[]')
|
||||
};
|
||||
await apiClient.post('/api/v1/agent', payload);
|
||||
toolsets: JSON.parse(editData.toolsets || '[]'),
|
||||
persona_id: (editData as any).persona_id || null,
|
||||
display_name: (editData as any).display_name || null
|
||||
});
|
||||
} else {
|
||||
const payload = {
|
||||
...editData,
|
||||
output_template: JSON.parse(editData.output_template || '{}'),
|
||||
bound_skill: JSON.parse(editData.bound_skill || '{}'),
|
||||
workspace: JSON.parse(editData.workspace || '[]'),
|
||||
tools: JSON.parse(editData.tools || '[]')
|
||||
toolsets: JSON.parse(editData.toolsets || '[]')
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
await apiClient.post('/api/v1/agent/worker', payload);
|
||||
} else {
|
||||
await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload);
|
||||
if (isNew) await apiClient.post('/api/v1/agent/worker', payload);
|
||||
else await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setModalMessage(err.response?.data?.detail || err.message || 'Failed to save');
|
||||
setModalMessage(err.response?.data?.detail || err.message || t('common.saveFailed'));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string, isSystem?: boolean) => {
|
||||
if (isSystem) return <span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-accent-light text-accent uppercase tracking-wider">{t('agent.system')}</span>;
|
||||
const colors: Record<string, string> = {
|
||||
ordinary_individual: 'bg-bg-secondary text-text-muted',
|
||||
skill_individual: 'bg-success-bg text-success',
|
||||
special_individual: 'bg-warning-bg text-warning',
|
||||
};
|
||||
return <span className={`px-2 py-0.5 rounded-md text-[10px] font-medium ${colors[type] || colors.ordinary_individual}`}>{t(`agent.type.${type}`, type.replace('_', ' '))}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 relative">
|
||||
<div className="mb-8 flex justify-between items-end">
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Individual</h1>
|
||||
<p className="text-slate-500 mt-1">Manage all system nodes and custom workers.</p>
|
||||
<h1 className="text-lg font-bold text-text-primary">{t('agent.individual')}</h1>
|
||||
<p className="text-sm text-text-muted mt-0.5">{t('agent.individualDesc')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Worker
|
||||
<button onClick={handleAddNew} className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
|
||||
<Plus size={14} /> {t('agent.addWorker')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
{error && <div className="text-sm text-danger bg-danger-bg border border-danger/20 rounded-xl p-3">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-0">
|
||||
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-6 text-slate-500">Loading...</div>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={24} className="animate-spin mb-3" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : (workers.length === 0 && systemNodes.length === 0) ? (
|
||||
<div className="p-6 text-slate-500">No individuals found.</div>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<Users size={32} className="mb-3 opacity-40" />
|
||||
<span className="text-sm">{t('agent.noIndividuals')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200 text-slate-600 text-sm">
|
||||
<th className="p-4 font-semibold">Name</th>
|
||||
<th className="p-4 font-semibold">Type</th>
|
||||
<th className="p-4 font-semibold">Provider / Model ID</th>
|
||||
<th className="p-4 font-semibold text-right">Actions</th>
|
||||
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider">
|
||||
<th className="px-5 py-3 font-semibold">{t('agent.name')}</th>
|
||||
<th className="px-5 py-3 font-semibold">{t('agent.type')}</th>
|
||||
<th className="px-5 py-3 font-semibold">{t('agent.providerModel')}</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-border-secondary">
|
||||
{systemNodes.map((w) => (
|
||||
<tr key={w.agent_id} className="border-b border-slate-100 bg-slate-50 hover:bg-slate-100 transition-colors">
|
||||
<td className="p-4 font-medium text-slate-800">{w.agent_name}</td>
|
||||
<td className="p-4 text-slate-600">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{w.agent_type}</span>
|
||||
<tr key={w.agent_id} className="bg-bg-secondary/50 hover:bg-bg-hover transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent-light flex items-center justify-center">
|
||||
<Bot size={14} className="text-accent" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-text-primary text-xs">{w.display_name || w.agent_name}</span>
|
||||
{w.display_name && <span className="text-[10px] text-text-muted">{w.agent_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-slate-600 text-sm">
|
||||
{w.provider_title} <span className="text-slate-400">/</span> {w.model_id}
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button onClick={() => handleEdit(w)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Edit">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<td className="px-5 py-3">{getTypeBadge(w.agent_type, true)}</td>
|
||||
<td className="px-5 py-3 text-xs text-text-muted">{w.provider_title} / {w.model_id}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<button onClick={() => handleEdit(w)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{workers.map((w) => (
|
||||
<tr key={w.agent_id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||
<td className="p-4 font-medium text-slate-800">{w.agent_name}</td>
|
||||
<td className="p-4 text-slate-600">
|
||||
<span className="px-2 py-1 bg-slate-100 rounded text-xs">{w.agent_type}</span>
|
||||
<tr key={w.agent_id} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center">
|
||||
<Bot size={14} className="text-text-muted" />
|
||||
</div>
|
||||
<span className="font-medium text-text-primary text-xs">{w.agent_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-slate-600 text-sm">
|
||||
{w.provider_title} <span className="text-slate-400">/</span> {w.model_id}
|
||||
</td>
|
||||
<td className="p-4 text-right space-x-2">
|
||||
<button onClick={() => handleEdit(w)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Edit">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(w.agent_id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<td className="px-5 py-3">{getTypeBadge(w.agent_type)}</td>
|
||||
<td className="px-5 py-3 text-xs text-text-muted">{w.provider_title} / {w.model_id}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<button onClick={() => handleEdit(w)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all mr-0.5"><Edit2 size={14} /></button>
|
||||
<button onClick={() => handleDelete(w.agent_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -232,215 +241,135 @@ export function WorkerIndividualSettings() {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit/Create Modal */}
|
||||
{/* Modal */}
|
||||
{isEditing && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-100 sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
{(editData as any).is_system ? 'Edit System Node' : (isNew ? 'Create Worker' : 'Edit Worker')}
|
||||
</h2>
|
||||
<button onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-border-primary animate-fade-in-scale">
|
||||
<div className="flex justify-between items-center p-5 border-b border-border-primary sticky top-0 bg-bg-card z-10">
|
||||
<h2 className="text-base font-bold text-text-primary">{(editData as any).is_system ? t('agent.editSystemNode') : (isNew ? t('agent.createWorker') : t('agent.editWorker'))}</h2>
|
||||
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleModalSave} className="p-6 space-y-4">
|
||||
<form onSubmit={handleModalSave} className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Agent Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editData.agent_name || ''}
|
||||
onChange={(e) => setEditData({...editData, agent_name: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
disabled={(editData as any).is_system}
|
||||
/>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{(editData as any).is_system ? t('agent.displayName') : t('agent.name')}</label>
|
||||
<input type="text" required={!(editData as any).is_system} value={(editData as any).is_system ? ((editData as any).display_name || '') : (editData.agent_name || '')} onChange={(e) => {
|
||||
if ((editData as any).is_system) setEditData({...editData, display_name: e.target.value} as any);
|
||||
else setEditData({...editData, agent_name: e.target.value});
|
||||
}}
|
||||
placeholder={(editData as any).is_system ? editData.agent_name : ''}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Agent Type</label>
|
||||
<select
|
||||
value={editData.agent_type || 'ordinary_individual'}
|
||||
onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
disabled={(editData as any).is_system}
|
||||
>
|
||||
<option value="ordinary_individual">Ordinary Individual</option>
|
||||
<option value="skill_individual">Skill Individual</option>
|
||||
<option value="special_individual">Special Individual</option>
|
||||
{(editData as any).is_system && (
|
||||
<option value="System Node">System Node</option>
|
||||
)}
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.type')}</label>
|
||||
<select value={editData.agent_type || 'ordinary_individual'} onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={(editData as any).is_system}>
|
||||
<option value="ordinary_individual">{t('agent.type.ordinary_individual')}</option>
|
||||
<option value="skill_individual">{t('agent.type.skill_individual')}</option>
|
||||
<option value="special_individual">{t('agent.type.special_individual')}</option>
|
||||
{(editData as any).is_system && <option value="System Node">{t('agent.system')}</option>}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Provider Title</label>
|
||||
<select
|
||||
value={editData.provider_title || ''}
|
||||
onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="" disabled>Select Provider</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>
|
||||
))}
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.provider')}</label>
|
||||
<select value={editData.provider_title || ''} onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})} required
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
|
||||
<option value="" disabled>{t('common.select')}</option>
|
||||
{providers.map((p) => (<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Model ID</label>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.model')}</label>
|
||||
{(() => {
|
||||
const selectedProvider = providers.find(p => p.provider_title === editData.provider_title);
|
||||
const models = selectedProvider?.provider_models || [];
|
||||
const sp = providers.find(p => p.provider_title === editData.provider_title);
|
||||
const models = sp?.provider_models || [];
|
||||
return (
|
||||
<select
|
||||
value={editData.model_id || ''}
|
||||
onChange={(e) => setEditData({...editData, model_id: e.target.value})}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="" disabled>Select a model</option>
|
||||
<select value={editData.model_id || ''} onChange={(e) => setEditData({...editData, model_id: e.target.value})} required
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
|
||||
<option value="" disabled>{t('common.select')}</option>
|
||||
{models.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(editData as any).is_system && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
|
||||
<select value={(editData as any).persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value || null} as any)}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
|
||||
<option value="">{t('common.none')}</option>
|
||||
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!(editData as any).is_system && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={editData.description || ''}
|
||||
onChange={(e) => setEditData({...editData, description: e.target.value})}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.description')}</label>
|
||||
<textarea value={editData.description || ''} onChange={(e) => setEditData({...editData, description: e.target.value})} rows={2}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">System Prompt</label>
|
||||
<textarea
|
||||
value={editData.system_prompt || ''}
|
||||
onChange={(e) => setEditData({...editData, system_prompt: e.target.value})}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
|
||||
<select value={editData.persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value})} required
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
|
||||
<option value="" disabled>{t('agent.selectPersona')}</option>
|
||||
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Output Template (JSON)</label>
|
||||
<textarea
|
||||
value={editData.output_template || '{}'}
|
||||
onChange={(e) => setEditData({...editData, output_template: e.target.value})}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.outputTemplate')}</label>
|
||||
<textarea value={editData.output_template || '{}'} onChange={(e) => setEditData({...editData, output_template: e.target.value})} rows={3}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Bound Skill (Select)</label>
|
||||
<select
|
||||
value={(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(editData.bound_skill || '{}');
|
||||
return Object.keys(parsed)[0] || '';
|
||||
} catch { return ''; }
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const newSkill = val ? { [val]: [] } : {};
|
||||
setEditData({...editData, bound_skill: JSON.stringify(newSkill)});
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
disabled={editData.agent_type !== 'skill_individual'}
|
||||
>
|
||||
<option value="">No Skill Bound</option>
|
||||
{availableSkills.map(skill => (
|
||||
<option key={skill} value={skill}>{skill}</option>
|
||||
))}
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.boundSkill')}</label>
|
||||
<select value={(() => { try { return Object.keys(JSON.parse(editData.bound_skill || '{}'))[0] || ''; } catch { return ''; } })()}
|
||||
onChange={(e) => setEditData({...editData, bound_skill: JSON.stringify(e.target.value ? { [e.target.value]: [] } : {})})}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={editData.agent_type !== 'skill_individual'}>
|
||||
<option value="">{t('common.none')}</option>
|
||||
{availableSkills.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Workspace (JSON Array)</label>
|
||||
<textarea
|
||||
value={editData.workspace || '[]'}
|
||||
onChange={(e) => setEditData({...editData, workspace: e.target.value})}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.workspace')}</label>
|
||||
<textarea value={editData.workspace || '[]'} onChange={(e) => setEditData({...editData, workspace: e.target.value})} rows={2}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Tools (Select Multiple)</label>
|
||||
<div className="flex flex-wrap gap-2 p-4 border border-slate-200 rounded-lg max-h-48 overflow-y-auto">
|
||||
{availableTools.map(tool => {
|
||||
let currentTools: string[] = [];
|
||||
try {
|
||||
currentTools = JSON.parse(editData.tools || '[]');
|
||||
} catch { currentTools = []; }
|
||||
|
||||
const isSelected = currentTools.includes(tool);
|
||||
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.toolsets')}</label>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 bg-bg-input border border-border-primary rounded-xl max-h-48 overflow-y-auto">
|
||||
{availableToolsets.map(ts => {
|
||||
let currentToolsets: string[] = [];
|
||||
try { currentToolsets = JSON.parse(editData.toolsets || '[]'); } catch { }
|
||||
const isSelected = currentToolsets.includes(ts.toolset_id);
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
let updatedTools = [...currentTools];
|
||||
if (isSelected) {
|
||||
updatedTools = updatedTools.filter(t => t !== tool);
|
||||
} else {
|
||||
updatedTools.push(tool);
|
||||
}
|
||||
setEditData({...editData, tools: JSON.stringify(updatedTools)});
|
||||
<button key={ts.toolset_id} type="button" onClick={() => {
|
||||
const updated = isSelected ? currentToolsets.filter(id => id !== ts.toolset_id) : [...currentToolsets, ts.toolset_id];
|
||||
setEditData({...editData, toolsets: JSON.stringify(updated)});
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||
: 'bg-slate-50 text-slate-600 border border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{tool}
|
||||
className={`flex flex-col items-start p-2.5 rounded-lg text-left transition-all ${isSelected ? 'bg-accent-light border border-accent/30' : 'bg-bg-secondary border border-border-primary hover:border-text-muted'}`}>
|
||||
<span className="text-xs font-medium text-text-primary">{ts.name}</span>
|
||||
<span className="text-[10px] text-text-muted mt-0.5">{ts.tools.length} {t('agent.toolsCount')}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{availableTools.length === 0 && (
|
||||
<span className="text-sm text-slate-500">No tools available</span>
|
||||
)}
|
||||
{availableToolsets.length === 0 && <span className="text-xs text-text-muted col-span-2">{t('plugin.toolsetEmpty')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalMessage && (
|
||||
<div className="p-3 bg-red-50 text-red-700 text-sm rounded-lg">
|
||||
{modalMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 flex justify-end space-x-3 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
Save Worker
|
||||
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
|
||||
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
|
||||
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={submitLoading} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
|
||||
<Save size={14} /> {submitLoading ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WorkflowConfig {
|
||||
retry: {
|
||||
max_attempts: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkflowConfigSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<WorkflowConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/v1/system/config/workflow', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load config: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const response = await fetch('/api/v1/system/config/workflow', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `Failed to save: ${response.statusText}`);
|
||||
}
|
||||
setSuccessMessage(t('agent.configSaved'));
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaxAttemptsChange = (value: string) => {
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue) && numValue >= 1 && numValue <= 100) {
|
||||
setConfig((prev) => prev ? {
|
||||
...prev,
|
||||
retry: { ...prev.retry, max_attempts: numValue }
|
||||
} : null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-muted">{t('common.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-error">{error || 'No configuration available'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6">
|
||||
{t('agent.workflowConfig')}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="mb-4 p-4 bg-success/10 border border-success/20 rounded-lg text-success text-sm">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-bg-card border border-border-primary rounded-lg p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('agent.maxRetryAttempts')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.retry.max_attempts}
|
||||
onChange={(e) => handleMaxAttemptsChange(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-bg-secondary border border-border-primary rounded-lg text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
{t('agent.maxRetryAttemptsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary bg-bg-secondary border border-border-primary rounded-lg hover:bg-bg-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('common.reset')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-lg hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '../../api/client';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
interface AuthPageProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AuthPage({ onLoginSuccess }: AuthPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [userName, setUserName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -20,78 +22,100 @@ export function AuthPage({ onLoginSuccess }: AuthPageProps) {
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
// Login
|
||||
const response = await apiClient.post('/api/v1/auth/login', {
|
||||
user_name: userName,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (response.data.token) {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
onLoginSuccess();
|
||||
}
|
||||
} else {
|
||||
// Register
|
||||
const response = await apiClient.post('/api/v1/auth/register', {
|
||||
user_name: userName,
|
||||
password: password,
|
||||
});
|
||||
|
||||
// After successful register, we can automatically log them in
|
||||
// or just show a message and switch to login. Let's switch to login.
|
||||
if (response.data.message === 'success') {
|
||||
setIsLogin(true);
|
||||
setError('Registration successful. Please log in.');
|
||||
setError(t('auth.registerSuccess'));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.response?.data?.detail || err.response?.data?.message || 'Authentication failed');
|
||||
setError(err.response?.data?.detail || err.response?.data?.message || t('auth.authFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-slate-50">
|
||||
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8 border border-slate-100">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center text-white mb-4 shadow-md shadow-blue-200">
|
||||
<Activity size={24} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">
|
||||
{isLogin ? 'Welcome Back' : 'Create Account'}
|
||||
</h2>
|
||||
<p className="text-slate-500 mt-2 text-sm">
|
||||
{isLogin ? 'Enter your credentials to access your account' : 'Sign up to start using the platform'}
|
||||
<div className="min-h-screen w-full bg-bg-primary flex items-center justify-center">
|
||||
<div className="flex items-center gap-[70px] w-[900px]">
|
||||
{/* Left: Brand */}
|
||||
<div className="flex-1">
|
||||
<div className="w-10 h-[3px] rounded-sm bg-gradient-to-r from-accent to-clay mb-6" />
|
||||
<h1 className="text-[40px] font-bold tracking-tight leading-tight mb-3 text-text-primary">
|
||||
Kilo<span className="not-italic text-accent">Star</span>
|
||||
</h1>
|
||||
<p className="text-[15px] text-text-muted leading-relaxed mb-8">
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-text-muted">
|
||||
<div className="w-[5px] h-[5px] rounded-full bg-clay" />
|
||||
<span>Multi-Agent Orchestration</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-text-muted">
|
||||
<div className="w-[5px] h-[5px] rounded-full bg-clay" />
|
||||
<span>Visual Pipeline Builder</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-text-muted">
|
||||
<div className="w-[5px] h-[5px] rounded-full bg-clay" />
|
||||
<span>Intelligent Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Card */}
|
||||
<div className="w-[380px] bg-bg-card rounded-[20px] p-10 shadow-[0_1px_3px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.03)] border border-border-primary">
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-1">
|
||||
{isLogin ? t('auth.welcomeBack') : t('auth.createAccount')}
|
||||
</h2>
|
||||
<p className="text-[13px] text-text-muted mb-7">
|
||||
{isLogin ? t('auth.enterCredentials') : t('auth.signUpToStart')}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-sm ${error.includes('successful') ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
|
||||
<div className={`mb-5 p-3 rounded-[10px] text-sm border ${error.includes('success') || error.includes('成功') ? 'bg-success-bg/50 text-success border-success/20' : 'bg-danger-bg/50 text-danger border-danger/20'}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-[18px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Username</label>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-3 rounded-[10px] border border-border-primary bg-bg-primary text-sm text-text-primary placeholder:text-[#bbb5ae] outline-none transition-all focus:border-accent focus:shadow-[0_0_0_3px_rgba(156,175,136,0.1)]"
|
||||
placeholder={t('auth.usernamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-3 rounded-[10px] border border-border-primary bg-bg-primary text-sm text-text-primary placeholder:text-[#bbb5ae] outline-none transition-all focus:border-accent focus:shadow-[0_0_0_3px_rgba(156,175,136,0.1)]"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -99,23 +123,35 @@ export function AuthPage({ onLoginSuccess }: AuthPageProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors disabled:opacity-50"
|
||||
className="w-full py-3 rounded-[10px] bg-accent text-white text-sm font-semibold hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_6px_20px_rgba(156,175,136,0.3)] transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? 'Processing...' : (isLogin ? 'Sign In' : 'Sign Up')}
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('auth.processing')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{isLogin ? t('auth.signIn') : t('auth.signUp')}
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">
|
||||
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
||||
<div className="flex items-center gap-3.5 my-5 text-[#bbb5ae] text-xs before:flex-1 before:h-px before:bg-border-primary after:flex-1 after:h-px after:bg-border-primary">
|
||||
or
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted">
|
||||
{isLogin ? t('auth.noAccount') : t('auth.hasAccount')}{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLogin(!isLogin);
|
||||
setError('');
|
||||
}}
|
||||
className="text-blue-600 font-medium hover:text-blue-700 focus:outline-none"
|
||||
onClick={() => { setIsLogin(!isLogin); setError(''); }}
|
||||
className="text-accent font-medium hover:text-accent-hover transition-colors"
|
||||
>
|
||||
{isLogin ? 'Sign up' : 'Sign in'}
|
||||
{isLogin ? t('auth.signUp') : t('auth.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,265 +1,246 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { MessageSquare, Activity, Terminal, ChevronRight, Plus } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
import type { ChatSession, Message } from '../../App';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search, Paperclip } from 'lucide-react';
|
||||
import { useChatStore } from '../../store/useChatStore';
|
||||
|
||||
interface ChatPanelProps {
|
||||
chatSessions: ChatSession[];
|
||||
setChatSessions: React.Dispatch<React.SetStateAction<ChatSession[]>>;
|
||||
activeSessionId: string | null;
|
||||
setActiveSessionId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}
|
||||
export function ChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setActiveSessionId }: ChatPanelProps) {
|
||||
const quickActions = [
|
||||
{ icon: Sparkles, label: t('chat.quickActions.brainstorm'), prompt: '帮我头脑风暴一些创意' },
|
||||
{ icon: Code, label: t('chat.quickActions.writeCode'), prompt: '帮我写一段 Python 代码' },
|
||||
{ icon: FileText, label: t('chat.quickActions.summarize'), prompt: '帮我总结这篇文档' },
|
||||
{ icon: Search, label: t('chat.quickActions.search'), prompt: '帮我搜索相关资料' },
|
||||
];
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'chat' | 'deploy'>('chat');
|
||||
const [showPlusMenu, setShowPlusMenu] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const plusMenuRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeSession = chatSessions.find((s) => s.id === activeSessionId) || null;
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
loadingMessages,
|
||||
loadMessages,
|
||||
createChat,
|
||||
sendMessage,
|
||||
} = useChatStore();
|
||||
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId) || null;
|
||||
const messages = activeSession ? activeSession.messages : [];
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const updateSessionMessages = (newMessages: Message[]) => {
|
||||
if (!activeSessionId) return;
|
||||
setChatSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId
|
||||
? { ...s, messages: newMessages, updatedAt: Date.now() }
|
||||
: s
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeSessionId) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/adapter/client/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const aiMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: `已上传文件: ${response.data.filename}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
updateSessionMessages([...messages, aiMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error uploading file', error);
|
||||
const errorMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: '文件上传失败。',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
updateSessionMessages([...messages, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
const session = sessions.find((s) => s.id === activeSessionId);
|
||||
if (session && session.messages.length === 0) {
|
||||
loadMessages(activeSessionId);
|
||||
}
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (plusMenuRef.current && !plusMenuRef.current.contains(e.target as Node)) {
|
||||
setShowPlusMenu(false);
|
||||
}
|
||||
};
|
||||
if (showPlusMenu) document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showPlusMenu]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!input.trim() || !activeSessionId) return;
|
||||
|
||||
const userText = input;
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: userText,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
updateSessionMessages([...messages, userMessage]);
|
||||
if (!input.trim()) return;
|
||||
const text = input.trim();
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const promptModifier = mode === 'deploy' ? '[DEPLOY TASK] ' : '';
|
||||
const response = await apiClient.post('/api/v1/adapter/client', {
|
||||
message: promptModifier + userMessage.content,
|
||||
});
|
||||
|
||||
const responseData = response.data.message;
|
||||
let aiContent = responseData || 'I received your message.';
|
||||
|
||||
// Auto-update title if it's the first user message
|
||||
if (messages.length <= 1 && userText.length > 0) {
|
||||
setChatSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId
|
||||
? { ...s, title: userText.slice(0, 20) + (userText.length > 20 ? '...' : '') }
|
||||
: s
|
||||
)
|
||||
);
|
||||
if (!activeSessionId) {
|
||||
const title = text.slice(0, 20) + (text.length > 20 ? '...' : '');
|
||||
await createChat(title, text);
|
||||
} else {
|
||||
await sendMessage(activeSessionId, text);
|
||||
}
|
||||
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: aiContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
updateSessionMessages([...messages, userMessage, aiMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error sending message', error);
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error communicating with the server.',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
updateSessionMessages([...messages, userMessage, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const handleQuickAction = (prompt: string) => {
|
||||
if (!activeSessionId) {
|
||||
createChat(prompt.slice(0, 20), prompt);
|
||||
} else {
|
||||
sendMessage(activeSessionId, prompt);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeSessionId) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white overflow-hidden items-center justify-center">
|
||||
<Activity size={48} className="text-slate-300 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-slate-600">Pretor Assistant</h2>
|
||||
<p className="text-slate-400 mt-2">Select a chat history or create a new one to start.</p>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-10 relative overflow-hidden">
|
||||
<div className="relative z-10 flex flex-col items-center animate-fade-in-scale">
|
||||
<div className="w-14 h-14 rounded-2xl bg-accent-light flex items-center justify-center text-accent mb-5">
|
||||
<Activity size={24} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-1.5">{t('chat.assistantName')}</h2>
|
||||
<p className="text-text-secondary text-sm mb-7 text-center max-w-sm">
|
||||
{t('chat.selectChat')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 mb-7 w-full max-w-[380px]">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newSession: ChatSession = {
|
||||
id: Date.now().toString(),
|
||||
title: 'New Chat',
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: 'Hello! I am Pretor Assistant. How can I help you today?',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setChatSessions([newSession, ...chatSessions]);
|
||||
setActiveSessionId(newSession.id);
|
||||
}}
|
||||
className="mt-6 px-6 py-2 bg-blue-200 text-slate-800 rounded-xl shadow-sm hover:bg-blue-300 transition-colors"
|
||||
key={action.label}
|
||||
onClick={() => handleQuickAction(action.prompt)}
|
||||
className="flex items-center gap-2.5 px-3.5 py-3 bg-bg-card border border-border-primary rounded-[10px] text-left hover:border-border-secondary hover:bg-bg-hover transition-all group shadow-[0_1px_2px_rgba(0,0,0,0.02)]"
|
||||
>
|
||||
Start New Chat
|
||||
<action.icon size={14} className="text-accent flex-shrink-0" />
|
||||
<span className="text-xs text-text-secondary group-hover:text-text-primary transition-colors">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[480px]">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.placeholder')}
|
||||
rows={1}
|
||||
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-4 py-3 focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-light),0_1px_3px_rgba(0,0,0,0.02)] transition-all text-text-primary placeholder:text-[#bbb5ae] text-[13px] resize-none min-h-[44px] max-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!input.trim()}
|
||||
className="w-10 h-10 rounded-[10px] bg-accent text-white hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(156,175,136,0.25)] transition-all disabled:opacity-30 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Notice we removed the outer padded div, since App.tsx is handling that layout now
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white overflow-hidden relative">
|
||||
<div className="h-14 border-b border-slate-100 bg-white flex items-center justify-between px-6 z-10 shrink-0">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare size={18} className="text-blue-600 mr-3" />
|
||||
<h1 className="font-semibold text-slate-800">{activeSession?.title || 'Chat'}</h1>
|
||||
<div className="flex-1 flex flex-col bg-bg-primary overflow-hidden relative">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="max-w-[720px] mx-auto flex flex-col gap-6">
|
||||
{loadingMessages ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
|
||||
</div>
|
||||
<div className="flex space-x-2 bg-slate-50 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setMode('chat')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
||||
mode === 'chat' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.id} className={msg.role === 'user' ? 'flex justify-end' : ''}>
|
||||
{msg.role === 'user' ? (
|
||||
<div className="max-w-[80%] px-4 py-2.5 bg-bg-card border border-border-primary rounded-2xl rounded-br-sm text-[13px] text-text-primary leading-relaxed whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
|
||||
{msg.content ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const inline = !match && !String(children).includes('\n');
|
||||
if (inline) {
|
||||
return <code className="px-1.5 py-0.5 bg-bg-secondary rounded text-[12px] font-mono" {...props}>{children}</code>;
|
||||
}
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match?.[1] || 'text'}
|
||||
PreTag="div"
|
||||
customStyle={{ borderRadius: '10px', fontSize: '12px', margin: '12px 0' }}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('deploy')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
|
||||
mode === 'deploy' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
Deploy Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 p-6 overflow-y-auto space-y-6 bg-white">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
|
||||
<Activity size={16} className="text-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-100 text-slate-800 rounded-2xl rounded-tr-sm'
|
||||
: 'bg-slate-50 border border-slate-100 text-slate-700 rounded-2xl rounded-tl-sm'
|
||||
} p-4 max-w-[80%] shadow-sm`}
|
||||
>
|
||||
<p className="text-sm leading-relaxed mb-1 whitespace-pre-wrap">{msg.content}</p>
|
||||
{typeof msg.content === 'string' && msg.content.includes('-') && msg.role === 'assistant' && (msg.content.length === 36 || msg.content.includes('任务已创建')) && (
|
||||
<div className="mt-2 bg-white border border-slate-100 rounded-lg p-3 flex items-center text-sm shadow-sm">
|
||||
<Terminal size={16} className="text-slate-400 mr-2" />
|
||||
<span className="font-mono text-slate-600 text-xs">Task ID: {msg.content.substring(0, 36)}</span>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
|
||||
<Activity size={16} className="text-blue-600 animate-spin" />
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-100 text-slate-700 p-4 rounded-2xl rounded-tl-sm max-w-[80%] shadow-sm">
|
||||
<span className="flex space-x-1">
|
||||
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce"></span>
|
||||
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-75"></span>
|
||||
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-150"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Input */}
|
||||
<div className="p-4 bg-white border-t border-slate-100 shrink-0">
|
||||
<div className="relative flex items-center">
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileUpload} className="hidden" />
|
||||
{/* Input */}
|
||||
<div className="px-6 pt-3.5 pb-4.5 border-t border-border-primary bg-bg-primary/60 backdrop-blur-[8px] shrink-0">
|
||||
<div className="flex items-end gap-2 max-w-[720px] mx-auto">
|
||||
<input type="file" ref={fileInputRef} className="hidden" />
|
||||
<div className="relative" ref={plusMenuRef}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute left-2 p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors z-10 cursor-pointer"
|
||||
title="Add attachment"
|
||||
onClick={() => setShowPlusMenu(!showPlusMenu)}
|
||||
className="w-10 h-10 rounded-xl text-text-muted hover:text-accent hover:bg-bg-hover transition-all flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
{showPlusMenu && (
|
||||
<div className="absolute bottom-12 left-0 bg-bg-card border border-border-primary rounded-xl shadow-lg py-1.5 min-w-[160px] z-50">
|
||||
<button
|
||||
onClick={() => { fileInputRef.current?.click(); setShowPlusMenu(false); }}
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-xs text-text-secondary hover:bg-bg-hover hover:text-text-primary transition-colors"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
{t('chat.addAttachment')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
placeholder="Ask Pretor to do something..."
|
||||
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-2xl pl-12 pr-12 py-3.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.placeholder')}
|
||||
rows={1}
|
||||
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-4 py-3 focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-light),0_1px_3px_rgba(0,0,0,0.02)] transition-all text-text-primary placeholder:text-[#bbb5ae] text-[13px] resize-none min-h-[44px] max-h-[100px]"
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
className="absolute right-2 p-1.5 bg-blue-200 text-slate-800 rounded-xl hover:bg-blue-300 transition-colors shadow-sm disabled:opacity-50 cursor-pointer"
|
||||
disabled={!input.trim()}
|
||||
className="w-10 h-10 rounded-[10px] bg-accent text-white hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(156,175,136,0.25)] transition-all disabled:opacity-30 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user