Compare commits

..

4 Commits

Author SHA1 Message Date
Hermes Agent 6aba475726 Fix seeded workflow agent reference 2026-06-08 15:13:16 +08:00
Hermes Agent 0a857986f9 Relax admin email schema validation 2026-06-08 15:12:03 +08:00
Hermes Agent 5217af6fe7 Fix backend runtime dependency and web deploy permissions 2026-06-08 15:08:46 +08:00
Hermes Agent 0e485492cf Initial lightweight AI agent admin 2026-06-08 15:05:57 +08:00
2859 changed files with 4520 additions and 550653 deletions
-58
View File
@@ -1,58 +0,0 @@
# Git
.git
.gitignore
.gitattributes
.gitmodules
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Python
**/__pycache__/
**/*.py[cod]
**/*.pyo
**/venv/
**/.venv/
**/*.egg-info/
**/.eggs/
**/.tox/
**/.nox/
**/.pytest_cache/
# Node
**/node_modules/
**/dist/
!docker/uniapp/h5/
!docker/web/index/
**/.turbo/
# Environment files
.env
**/.env.local
**/.env.*.local
backend/env/
# Database
**/*.db
**/*.sqlite3
# Logs
**/*.log
# Docker
**/Dockerfile
**/docker-compose*.yml
**/docker-compose*.yaml
# Project specific
backend/venv/
web/apps/web-ele/dist/
web/apps/web-ele/pnpm-lock.yaml
-49
View File
@@ -1,49 +0,0 @@
# AI Agent Admin production environment template.
# Copy this file to backend-fastapi/env/prod.env on the server and replace all
# placeholder values before starting the non-Docker systemd service.
APP_NAME=ai-agent-admin
ENV=prod
DEBUG=false
APP_HOST=127.0.0.1
APP_PORT=8001
# Database: postgresql / mysql / sqlserver.
DB_TYPE=postgresql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=replace_with_database_password
DB_NAME=ai_agent_admin
# Redis.
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=replace_with_redis_password
REDIS_DB=0
# Security.
JWT_SECRET_KEY=replace_with_a_long_random_secret
ACCESS_TOKEN_EXPIRE_MINUTES=1440
REFRESH_TOKEN_EXPIRE_DAYS=30
ALLOW_MULTI_DEVICE_LOGIN=true
# Local file storage is the default for the lightweight deployment.
FILE_STORAGE_TYPE=local
FILE_STORAGE_LOCAL_PATH=/opt/ai-agent-admin/storage
# Optional MinIO storage. Leave empty when FILE_STORAGE_TYPE=local.
MINIO_ENDPOINT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_BUCKET_NAME=ai-agent-admin
MINIO_SECURE=false
# Optional OAuth callbacks.
DINGTALK_REDIRECT_URI=
DINGTALK_H5_REDIRECT_URI=
WECOM_REDIRECT_URI=
WECOM_H5_REDIRECT_URI=
TIMEZONE=Asia/Shanghai
CACHE_PREFIX=ai-agent-admin:prod:
-6
View File
@@ -1,6 +0,0 @@
*.sh text eol=lf
*.service text eol=lf
*.conf text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
Dockerfile* text eol=lf
+6 -339
View File
@@ -1,340 +1,7 @@
./web/node_modules
.DS_Store
./web/dist
./web/dist-ssr
./web/dist.zip
./web/dist.tar
./web/dist.war
.nitro
.output
*-dist.zip
*-dist.tar
*-dist.war
coverage
*.local
**/.vitepress/cache
./web/.cache
./web/.turbo
./web/.temp
/.context
./web/dev-dist
./web/.stylelintcache
./web/yarn.lock
./web/package-lock.json
.VSCodeCounter
**/backend-mock/data
/.tmp/
/ai-agent-admin-dist-*.tgz
/ai-agent-admin-web-dist*.tgz
# local env files
./web/.env.local
./web/.env.*.local
./web/.eslintcache
./web/logs
*.log
./web/npm-debug.log*
./web/yarn-debug.log*
./web/yarn-error.log*
./web/pnpm-debug.log*
./web/lerna-debug.log*
./web/vite.config.mts.*
./web/vite.config.mjs.*
./web/vite.config.js.*
./web/vite.config.ts.*
# Editor directories and files
.idea
# .vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.history
.cursor
./backend-django/venv
./backend-django/.idea/
./backend-django/static/
./backend-django/.venv/
./backend-django/logs/
*/migrations/
# Python
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
.cursor/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
media/
!web/apps/web-ele/src/components/screen-design/components/ConfigPanel/media/
!web/apps/web-ele/src/components/screen-design/components/ConfigPanel/media/MediaConfig.vue
staticfiles/
# FastAPI / Backend
backend-django/venv/
backend-django/.venv/
backend-django/__pycache__/
backend-django/*.pyc
backend-django/logs/
backend-django/media/
backend-django/static/
backend-django/staticfiles/
backend-django/.pytest_cache/
backend-django/celerybeat-schedule
backend-django/celerybeat.pid
backend-fastapi/venv/
backend-fastapi/.venv/
backend-fastapi/__pycache__/
backend-fastapi/*.pyc
backend-fastapi/.env
backend-fastapi/env/dev.env
backend-fastapi/env/uat.env
backend-fastapi/env/prod.env
backend-fastapi/logs/
backend-fastapi/.pytest_cache/
# backend-fastapi/db_init.json
backend-fastapi/*.db
# Virtual Environment
venv/
.venv/
env.bak/
venv.bak/
# 排除项目根目录和其他位置的 env 目录,但不包括 backend-django/env 和 backend-fastapi/env
/env/
web/env/
# PyCharm
.idea/
*.iml
*.iws
.idea_modules/
# VS Code
.vscode/
*.code-workspace
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Environments
.env
.env.local
.env.*.local
*.env
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
/go-view/
node_modules
# ai-agent-admin: exclude generated bundles and unused product entries.
# Keep design/runtime source used by AI workflow panels tracked; lightness is
# enforced by route/menu filtering and lazy imports, not by missing source.
.ops-backups/
docker/uniapp/
docker/web/index/*
!docker/web/index/.gitkeep
uniapp-zq/
web/apps/web-ele/src/components/import-export-manager/
web/apps/web-ele/src/components/wiki/
web/apps/web-ele/src/components/zq-draw/
web/apps/web-ele/src/components/zq-editor/
web/apps/web-ele/src/components/zq-smart-table/
web/apps/web-ele/src/components/zq-whiteboard/
web/apps/web-ele/src/components/whiteboard/
web/apps/web-ele/src/components/workflow/
web/apps/web-ele/src/views/online-dev/
!web/apps/web-ele/src/views/online-dev/
web/apps/web-ele/src/views/online-dev/*
!web/apps/web-ele/src/views/online-dev/page-render/
!web/apps/web-ele/src/views/online-dev/page-render/**
!web/apps/web-ele/src/views/online-dev/form-manager/
web/apps/web-ele/src/views/online-dev/form-manager/*
!web/apps/web-ele/src/views/online-dev/form-manager/composables/
web/apps/web-ele/src/views/online-dev/form-manager/composables/*
!web/apps/web-ele/src/views/online-dev/form-manager/composables/useFormData.ts
!web/apps/web-ele/src/views/online-dev/form-manager/modules/
web/apps/web-ele/src/views/online-dev/form-manager/modules/*
!web/apps/web-ele/src/views/online-dev/form-manager/modules/ListTypePicker.vue
!web/apps/web-ele/src/views/online-dev/form-manager/modules/data-source-config.vue
!web/apps/web-ele/src/views/online-dev/form-manager/modules/list-design.vue
!web/apps/web-ele/src/views/online-dev/form-manager/utils/
web/apps/web-ele/src/views/online-dev/form-manager/utils/*
!web/apps/web-ele/src/views/online-dev/form-manager/utils/formValidator.ts
!web/apps/web-ele/src/views/online-dev/form-render/
web/apps/web-ele/src/views/online-dev/form-render/*
!web/apps/web-ele/src/views/online-dev/form-render/components/
!web/apps/web-ele/src/views/online-dev/form-render/components/**
!web/apps/web-ele/src/views/online-dev/form-render/utils/
web/apps/web-ele/src/views/online-dev/form-render/utils/*
!web/apps/web-ele/src/views/online-dev/form-render/utils/list-filter-fields.ts
web/apps/web-ele/src/views/dashboard/
!web/apps/web-ele/src/views/dashboard/
web/apps/web-ele/src/views/dashboard/*
!web/apps/web-ele/src/views/dashboard/analytics/
!web/apps/web-ele/src/views/dashboard/analytics/**
!web/apps/web-ele/src/views/dashboard/workspace/
!web/apps/web-ele/src/views/dashboard/workspace/**
web/apps/web-ele/src/views/demos/
web/apps/web-ele/src/views/zq-smart-table/
web/apps/web-ele/src/components/document-designer/
web/apps/web-ele/src/components/workflow/designer/
web/docs/src/public/guide/
web/apps/web-ele/src/locales/langs/*/*.json_new
web/apps/web-ele/src/locales/langs/*/*.json_sync
web/apps/web-ele/src/locales/langs/*/*.json.tmp
web/apps/web-ele/src/locales/langs/*/draw.json
web/apps/web-ele/src/locales/langs/*/whiteboard.json
web/apps/web-ele/src/locales/langs/*/wiki.json
web/apps/web-ele/src/locales/langs/*/zq-editor.json
web/apps/web-ele/src/locales/langs/*/zq-smart-table.json
web/apps/web-ele/src/router/routes/modules/form-layout.ts
web/apps/web-ele/src/router/routes/modules/online-dev.ts
web/apps/web-ele/src/router/routes/modules/chat.ts
web/apps/web-ele/src/router/routes/modules/demos.ts
web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts
web/apps/web-ele/src/api/smart-table.ts
web/apps/web-ele/src/composables/use-offline-cache.ts
web/apps/web-ele/src/store/wiki.ts
web/apps/web-ele/src/store/zq-smart-table.ts
web/apps/web-ele/src/styles/zq-smart-table/
web/apps/web-ele/src/components/report-design/
backend-fastapi/online_dev/
!backend-fastapi/online_dev/
backend-fastapi/online_dev/*
!backend-fastapi/online_dev/__init__.py
!backend-fastapi/online_dev/router.py
!backend-fastapi/online_dev/page_manager/
!backend-fastapi/online_dev/page_manager/**
backend-fastapi/online_dev/page_manager/*
!backend-fastapi/online_dev/page_manager/__init__.py
!backend-fastapi/online_dev/page_manager/api.py
!backend-fastapi/online_dev/page_manager/model.py
!backend-fastapi/online_dev/page_manager/service.py
!backend-fastapi/online_dev/form_manager/
!backend-fastapi/online_dev/form_manager/**
backend-fastapi/online_dev/form_manager/*
!backend-fastapi/online_dev/form_manager/__init__.py
!backend-fastapi/online_dev/form_manager/model.py
!backend-fastapi/online_dev/form_manager/service.py
!backend-fastapi/online_dev/
backend-fastapi/online_dev/*
!backend-fastapi/online_dev/__init__.py
!backend-fastapi/online_dev/router.py
!backend-fastapi/online_dev/form_manager/
!backend-fastapi/online_dev/form_manager/**
!backend-fastapi/online_dev/form_data_manager/
!backend-fastapi/online_dev/form_data_manager/**
!backend-fastapi/online_dev/page_manager/
!backend-fastapi/online_dev/page_manager/**
backend-fastapi/zq_demo/
backend-fastapi/zq_smart_table/
backend-fastapi/ai_platform/nodes/builtin/app_*_node.py
backend-fastapi/ai_platform/nodes/builtin/dashboard_*_node.py
backend-fastapi/ai_platform/nodes/builtin/form_*_node.py
backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_update_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_data_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_publish_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
web/apps/web-ele/src/api/core/database-monitor.ts
!web/apps/web-ele/src/api/core/database-monitor.ts
web/apps/web-ele/src/api/core/demo.ts
web/apps/web-ele/src/api/core/link-preview.ts
web/apps/web-ele/src/api/core/redis-manager.ts
web/apps/web-ele/src/api/core/redis-monitor.ts
!web/apps/web-ele/src/api/core/redis-monitor.ts
web/apps/web-ele/src/api/core/region.ts
web/apps/web-ele/src/api/core/scheduler.ts
web/apps/web-ele/src/api/core/server-monitor.ts
!web/apps/web-ele/src/api/core/server-monitor.ts
web/apps/web-ele/src/router/routes/modules/wiki.ts
web/apps/web-ele/src/views/_core/approval-center/
web/apps/web-ele/src/views/_core/data-source/
web/apps/web-ele/src/views/_core/database-connection/
web/apps/web-ele/src/views/_core/database-manager/
web/apps/web-ele/src/views/_core/database-monitor/
!web/apps/web-ele/src/views/_core/database-monitor/
!web/apps/web-ele/src/views/_core/database-monitor/**
web/apps/web-ele/src/views/_core/demo/
web/apps/web-ele/src/views/_core/login-log/README.md
web/apps/web-ele/src/views/_core/mobile-signature/
web/apps/web-ele/src/views/_core/redis-manager/
web/apps/web-ele/src/views/_core/redis-monitor/
!web/apps/web-ele/src/views/_core/redis-monitor/
!web/apps/web-ele/src/views/_core/redis-monitor/**
web/apps/web-ele/src/views/_core/region-manager/
web/apps/web-ele/src/views/_core/scheduler/
web/apps/web-ele/src/views/_core/server-monitor/
web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue
web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue
!web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue
!web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue
web/apps/web-ele/src/views/wiki/
dist
.venv
__pycache__
*.pyc
.env
.DS_Store
-443
View File
@@ -1,443 +0,0 @@
# ZQ Platform
<div align="center">
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](backend/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.121+-green.svg)](backend/)
[![Vue](https://img.shields.io/badge/Vue-3.5+-brightgreen.svg)](web/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](web/)
**A Full-Stack Enterprise Low-Code / No-Code Development Platform Based on FastAPI + Vue 3**
English | [简体中文](README.md)
</div>
## 🌐 Links
- **Official Website**: <https://zq-platform.com/>
- **Demo Link**: <https://opensource.zq-platform.com/>
- **Demo Account**: zhangwei
- **Demo Password**: 123456
***
## 📖 Introduction
ZQ Platform is a full-featured enterprise-level development platform with a decoupled frontend-backend architecture. The backend is built on **FastAPI** asynchronous framework, and the frontend is powered by **Vue 3** + **Element Plus** + **Vben Admin 5.x**.
The platform integrates RBAC permission management, organization management, **online form/page design (no-code)**, instant messaging, AI chat, third-party login and organization sync, system monitoring, scheduled tasks, data source management, code generator, and many other enterprise-grade modules, significantly accelerating enterprise application development.
## 🎯 No-Code Capabilities — Fully Open-Source, Free, and Unrestricted
The no-code (online development) module of ZQ Platform is one of its core highlights. Compared to mainstream no-code/low-code platforms on the market, it offers the significant advantages of being **fully open-source, completely free, and entirely unrestricted**.
### Comparison with Similar Platforms
| Dimension | ZQ Platform | DingTalk YiDa | Baidu AiSuDa | NocoBase |
| ---------------------- | --------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------- |
| **License** | fully open-source | ❌ Closed-source commercial | ❌ Closed-source commercial | ⚠️ AGPL 3.0 (copyleft, commercial restrictions) |
| **Free to Use** | ✅ Completely free, no hidden costs | ❌ Pay per user/form/process | ❌ Pay per edition, feature limitations | ⚠️ Community edition limited, enterprise edition paid |
| **User Limit** | ✅ Unlimited | ❌ Limited by plan | ❌ Limited by edition | ⚠️ Community edition has user limits |
| **Deployment** | ✅ Self-hosted, data fully under your control | ⚠️ SaaS-focused, very high self-hosting cost | ⚠️ SaaS-focused, self-hosting needs enterprise edition | ✅ Supports self-hosting |
| **Custom Development** | ✅ Unlimited, can modify source code freely | ❌ Not supported | ❌ Limited support | ⚠️ AGPL restricts commercial customization |
| **Commercial Use** | ✅ Apache 2.0 allows free + LICENSE\_SUPPLEMENTAL commercial use | ❌ Must purchase commercial license | ❌ Must purchase commercial license | ⚠️ AGPL requires purchased license for commercial use |
| **Database Support** | PostgreSQL / MySQL | Cloud database only | Cloud database only | PostgreSQL |
| **Frontend Tech** | Vue 3 + Element Plus (mainstream) | Closed stack, cannot customize | Closed stack | React + Ant Design |
| **Backend Tech** | Python FastAPI (async, high-performance) | Closed | Closed | Node.js + Koa |
### No-Core Features
#### 📝 Online Form Designer
- **Drag-and-Drop Design**: Intuitive drag-and-drop operations to build complex forms without writing any code
- **Rich Component Library**: 30+ components including text input, number, date, dropdown select, cascade select, file upload, rich text, sub-forms, etc.
- **Complex Layout**: Supports multi-column, grouping, table layout, tabs, and other complex page layouts
- **Conditional Logic**: Field visibility conditions, validation rules, and linkage logic configuration
- **Mobile Adaptation**: Automatically adapts to both PC and mobile displays
#### 📄 Online Page Designer
- **Visual Page Composition**: Freely combine forms, charts, data tables and other components into complete business pages
- **Data Binding**: Supports data source binding and API integration for real-time interaction with backend data
- **Permission Integration**: Page-level access control, seamlessly integrated with the RBAC permission system
#### 📊 Dashboard Designer
- **Rich Chart Components**: 20+ chart types including line, bar, pie, radar, funnel, heatmap, sankey, K-line, etc.
- **Business Components**: Announcement list, to-do items, quick links, ranking list, server monitor, weather widget, etc.
- **Data Filtering**: Supports interactive data filtering with date picker, input search, etc.
- **Free Layout**: Drag-and-drop free layout with customizable size and position
#### 🔗 Data Management
- **Data Source Management**: Connect external databases (PostgreSQL / MySQL / SQL Server) as data sources
- **Form Data Management**: Auto-generates list, detail, edit, and delete functionality for form data
- **Data Import/Export**: Supports Excel format data import and export
### Use Cases
- **Enterprise Internal Systems**: OA, CRM, ERP, inventory management, project management, etc.
- **Business Process Digitalization**: Approval workflows, ticketing systems, reporting systems, etc.
- **Rapid Prototyping**: Quick validation and iteration of business requirements
- **SaaS Platform Construction**: Multi-tenant application development
- **Data Collection & Analysis**: Surveys, data gathering, statistical analysis
***
## 🏗 Tech Stack
### Backend
| Technology | Description |
| ------------------------------------------------------------------ | ----------------------- |
| [FastAPI](https://fastapi.tiangolo.com/) | Web framework (0.121+) |
| [SQLAlchemy 2.0](https://www.sqlalchemy.org/) | Async ORM |
| [Alembic](https://alembic.sqlalchemy.org/) | Database migrations |
| [PostgreSQL 16+](https://www.postgresql.org/) / MySQL / SQL Server | Database |
| [Redis](https://redis.io/) | Cache & messaging |
| [APScheduler 4.x](https://apscheduler.readthedocs.io/) | Task scheduling |
| [MinIO](https://min.io/) / OSS / Azure Blob | File storage |
| [Qdrant](https://qdrant.tech/) | Vector database (RAG) |
| [OpenAI](https://openai.com/) / Anthropic / DashScope | AI LLM integration |
| [WebSocket](https://websockets.readthedocs.io/) | Real-time communication |
### Frontend
| Technology | Description |
| ---------------------------------------------------------- | ---------------------------------------- |
| [Vue 3](https://vuejs.org/) | Frontend framework |
| [TypeScript](https://www.typescriptlang.org/) | Type safety |
| [Element Plus](https://element-plus.org/) | UI component library |
| [Vben Admin 5.x](https://github.com/vbenjs/vue-vben-admin) | Admin framework |
| [Vite](https://vitejs.dev/) | Build tool |
| [Pinia](https://pinia.vuejs.org/) | State management |
| [Vue Router](https://router.vuejs.org/) | Routing |
| [i18n](https://vue-i18n.intlify.nuxt.dev/) | Internationalization (zh-CN/en-US/zh-TW) |
| [ECharts](https://echarts.apache.org/) | Chart visualization |
| [Tiptap](https://tiptap.dev/) | Rich text editor |
| [CodeMirror](https://codemirror.net/) | Code editor |
## ✨ Key Features
### 🔐 User & Permissions
- **User Management**: Full CRUD, avatar upload, password policies
- **Role Management**: RBAC-based role permission assignment
- **Menu Management**: Dynamic menu configuration with permission control
- **Department Management**: Tree-based organization structure
- **Position Management**: Position association and staffing
- **Resource Permissions**: Granular field-level permission control
- **Data Permissions**: Department/user-based data scope isolation
### 🏢 Organization
- **Org Chart**: Visual organization chart
- **Department Tree**: Unlimited hierarchical department management
- **Enterprise Sync**: DingTalk, Feishu (Lark), WeCom organization/user sync
### 🔗 Third-Party Integration
- **OAuth Login**: Gitee, GitHub, QQ, Google, WeChat, Microsoft, DingTalk, Feishu, WeCom
- **Notifications**: Email (SMTP), SMS (Aliyun/Tencent), DingTalk bot, Feishu bot, WeCom bot, WeChat Official Account
- **File Storage**: Local storage, MinIO, Aliyun OSS, Azure Blob Storage
### 📱 No-Code Capabilities
- **Online Form Designer**: Drag-and-drop form builder with complex form design
- **Form Data Management**: Form data CRUD with dynamic queries
- **Online Page Designer**: Visual page editor
- **Dashboard Designer**: Drag-and-drop dashboard builder with rich chart components
- **Code Generator**: Multiple encoding modes (date sequence, serial numbers, etc.)
### 💬 Instant Messaging
- **Real-time Chat**: WebSocket-based private and group chat
- **Message Management**: Unread counts, mute notifications, pinned conversations
- **System Notifications**: System alerts and announcement management
### 🤖 AI Capabilities
- **AI Chat**: Integration with OpenAI, Anthropic (Claude), DashScope (Tongyi Qianwen) and more
- **Knowledge Base**: RAG (Retrieval-Augmented Generation) based on Qdrant vector database
### ⚙️ System Tools
- **Data Dictionary**: Business dictionary management (tree/list)
- **System Configuration**: Dynamic system parameter configuration
- **UI Configuration**: Frontend preference settings (dynamically loaded from backend)
- **Scheduled Tasks**: APScheduler-based task scheduling management
- **File Management**: File upload, preview, chunked upload
- **Data Source Manager**: External database connection management
- **API Tokens**: API access token management
- **Region Manager**: Geographic data management
### 🌐 Internationalization
- Chinese Simplified, Chinese Traditional, English
- Fully internationalized frontend UI
- Backend error message i18n support
## 🚀 Quick Start
### Prerequisites
- Python 3.12+
- Node.js 20.10+
- pnpm 9.12+
- PostgreSQL 16+
- Redis
### Backend Setup
```bash
cd backend
# Create virtual environment
python -m venv venv
source venv/bin/activate # Linux/Mac
# Install dependencies
pip install -r requirements.txt
# Configure environment variables
cp env/example.env env/dev.env
# Edit env/dev.env to configure database connection, etc.
# Run database migrations
alembic upgrade head
# Start development server
uvicorn main:app --reload --port 8000
```
### Frontend Setup
```bash
cd web
# Install dependencies
pnpm install
# Start development server (Element Plus version by default)
pnpm dev
```
Visit <http://localhost:5777> to access the system.
### 🐳 Docker Compose Deployment
The project provides a complete Docker Compose deployment solution located in the `docker/` directory. It can start PostgreSQL, Redis, backend, and frontend services with a single command.
#### Prerequisites
- [Docker](https://www.docker.com/) >= 24.0
- [Docker Compose](https://docs.docker.com/compose/) >= 2.20
#### Deployment Steps
```bash
# 1. Navigate to the project root
cd zq-platform
# 2. (Optional) Configure environment variables
# For production, be sure to modify JWT_SECRET_KEY, DB_PASSWORD, and REDIS_PASSWORD
vim .env
# 3. Build and start all services
docker compose up -d
# 4. View startup logs
docker compose logs -f
# 5. Access the system
# Open http://localhost in your browser
```
#### Service Architecture
```text
┌─────────────┐ ┌──────────────────────────────────┐
│ Browser │ ───▶ │ Nginx (Port 80) │
└─────────────┘ │ ├─ Static files → Vue SPA │
│ ├─ /basic-api/* → backend:8000 │
│ └─ /ws/* → backend:8000 │
└──────────┬───────────────────────┘
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Backend │
│ :5432 │ │ :6379 │ │ :8000 │
└─────────────┘ └──────────────┘ └──────────────┘
```
#### Service Details
| Service | Image | Port | Description |
|---------|-------|------|-------------|
| **postgres** | postgres:16-alpine | 5432 | Database |
| **redis** | redis:7-alpine | 6379 | Cache (AOF persistence) |
| **backend** | Custom build | 8000 | FastAPI (Uvicorn 4 workers) |
| **web** | Custom build | **80** | Nginx serving frontend + proxying API/WebSocket |
#### Common Commands
```bash
# Build and start
docker compose up -d
# View logs
docker compose logs -f
# Stop services
docker compose down
# Full cleanup (removes volumes, use for initial deployment)
docker compose down -v
# Rebuild a specific service (after code changes)
docker compose build backend
docker compose up -d backend
# Check service status
docker compose ps
```
#### Security Notes
1. **Before deploying to production, be sure to update the following secrets in `.env`**:
- `JWT_SECRET_KEY` — JWT signing key
- `DB_PASSWORD` — Database password
- `REDIS_PASSWORD` — Redis password
2. The default database is PostgreSQL. To switch to MySQL, modify the service configuration in `docker-compose.yml`.
3. File storage defaults to `local` mode. To use MinIO or OSS, configure it in `.env`.
## 🏛 Project Structure
```
zq-platform/
├── backend/ # Backend Python service
│ ├── app/ # Core application modules
│ │ ├── base_model.py # Base model
│ │ ├── base_schema.py # Common schemas
│ │ ├── base_service.py # Base service
│ │ ├── config.py # System configuration
│ │ ├── database.py # Database connection
│ │ └── ...
│ ├── core/ # Core business modules
│ │ ├── auth/ # Authentication
│ │ ├── user/ # User management
│ │ ├── role/ # Role management
│ │ ├── menu/ # Menu management
│ │ ├── dept/ # Department management
│ │ ├── permission/ # Permission management
│ │ ├── chat/ # Instant messaging
│ │ ├── file_manager/ # File management
│ │ ├── message/ # Notifications
│ │ ├── oauth/ # Third-party login
│ │ ├── code_generator/ # Code generator
│ │ ├── data_source/ # Data source management
│ │ ├── system_config/ # System configuration
│ │ ├── ui_config/ # UI configuration
│ │ ├── server_monitor/ # Server monitoring
│ │ ├── redis_monitor/ # Redis monitoring
│ │ ├── database_monitor/ # Database monitoring
│ │ ├── redis_manager/ # Redis manager
│ │ ├── database_manager/ # Database manager
│ │ ├── dingtalk_sync/ # DingTalk sync
│ │ ├── feishu_sync/ # Feishu sync
│ │ ├── wecom_sync/ # WeCom sync
│ │ ├── application/ # Application management
│ │ ├── device/ # Device management
│ │ ├── region/ # Region management
│ │ ├── login_log/ # Login logs
│ │ ├── api_token/ # API tokens
│ │ ├── link_preview/ # Link preview
│ │ ├── dict/ # Data dictionary
│ │ ├── post/ # Position management
│ │ └── resource_scope/ # Resource permissions
│ ├── online_dev/ # Online development
│ │ ├── form_manager/ # Form management
│ │ ├── form_data_manager/ # Form data management
│ │ └── page_manager/ # Page management
│ ├── scheduler/ # Scheduled tasks
│ ├── zq_demo/ # Demo module
│ ├── alembic/ # Database migrations
│ ├── main.py # Application entrypoint
│ └── requirements.txt # Python dependencies
├── web/ # Frontend project
│ ├── apps/
│ │ └── web-ele/ # Element Plus app version
│ │ └── src/
│ │ ├── api/ # API interfaces
│ │ ├── components/ # Business components
│ │ ├── views/ # Page views
│ │ ├── router/ # Route configuration
│ │ ├── store/ # State management
│ │ ├── locales/ # i18n
│ │ └── layouts/ # Layout components
│ ├── packages/ # Shared packages
│ │ ├── @core/ # Core packages (UI components, utils, etc.)
│ │ ├── effects/ # Business logic
│ │ ├── constants/ # Constants
│ │ ├── hooks/ # Composables
│ │ ├── icons/ # Icon library
│ │ ├── locales/ # Internationalization
│ │ ├── preferences/ # User preferences
│ │ ├── request/ # HTTP requests
│ │ ├── stores/ # State management
│ │ ├── styles/ # Styles
│ │ ├── types/ # TypeScript types
│ │ └── utils/ # Utility functions
│ └── package.json # Frontend dependencies
└── README.md # Project documentation
```
## 📸 Screenshots
> (Add project screenshots here)
## ✅ Environment Requirements
| Dependency | Version |
| ---------- | -------- |
| Python | >= 3.12 |
| Node.js | >= 20.10 |
| pnpm | >= 9.12 |
| PostgreSQL | >= 16 |
| Redis | >= 6.0 |
## 🤝 Contributing
Issues and Pull Requests are welcome to help improve the project.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'feat: add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 📄 License
This project adopts a dual-license structure:
- **Core License**: [Apache License 2.0](LICENSE) — Standard Apache 2.0 open-source license, permitting free use, modification, and redistribution
- **Supplemental Terms**: [ZQ-Platform Public License Supplemental Terms](LICENSE_SUPPLEMENTAL) — Additional terms governing attribution ("powered by ZQ-Platform") and trademark usage
By using this software, you agree to comply with all terms in both documents.
## 📞 Contact
- **Official Website**: <https://zq-platform.com/>
- **Source Code**: <https://github.com/jiangzhikj/zq-platform>
- **Full Version Demo**: <https://demo.zq-platform.com/>
- **Open-Source Demo**: <https://opensource.zq-platform.com/>
- **Author Email**: <jiangzhikj@outlook.com>
- **Issues**: [GitHub Issues](https://github.com/jiangzhikj/zq-platform/issues)
+5 -577
View File
@@ -1,580 +1,8 @@
# ZQ Platform
# ai-agent-admin
<div align="center">
轻量化 AI Agent 管理后台,保留基础 Admin 能力,并提供 Provider、Model、Agent、Workflow、Knowledge Base 和 Agent Team 协作模块。
[![许可证](https://img.shields.io/badge/许可证-Apache%202.0-blue.svg)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](backend/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.121+-green.svg)](backend/)
[![Vue](https://img.shields.io/badge/Vue-3.5+-brightgreen.svg)](web/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](web/)
**一个基于 FastAPI + Vue 3 的全栈企业级低代码/零代码开发平台**
[English](README.en.md) | 简体中文
</div>
## 🌐 相关链接
- **官方网站**<https://zq-platform.com/>
- **社区版演示**<https://opensource.zq-platform.com/>
- **演示账号**zhangwei
- **演示密码**123456
***
## 📖 项目简介
ZQ Platform 是一个功能完善的全栈企业级开发平台,采用前后端分离架构。后端基于 **FastAPI** 异步框架,前端基于 **Vue 3** + **Element Plus** + **Vben Admin 5.x** 构建。
平台集成了 RBAC 权限管理、组织架构管理、**在线表单/页面设计(零代码)**、即时通讯、AI 对话、第三方登录与组织同步、系统监控、定时任务、数据源管理、代码生成等丰富的企业级功能模块,可大幅加速企业级应用开发。
## 🎯 零代码能力 — 完全开源、免费、无限制
ZQ Platform 的零代码(在线开发)模块是其核心亮点之一,与市面上主流的零代码/低代码平台相比,拥有 **完全开源、完全免费、完全无限制** 的显著优势。
### 与同类平台对比
| 对比维度 | ZQ Platform | 钉钉宜搭 | 百度爱速搭 | NocoBase |
| --------- | ---------------------------- | ----------------- | ----------------- | ----------------------- |
| **开源协议** | 完全开源 | ❌ 闭源商业软件 | ❌ 闭源商业软件 | ⚠️ AGPL 3.0(传染性协议,商用受限) |
| **是否免费** | ✅ 完全免费,无任何隐藏费用 | ❌ 按用户/表单/流程数收费 | ❌ 按版本收费,有功能限制 | ⚠️ 社区版功能受限,企业版收费 |
| **用户数限制** | ✅ 无限制 | ❌ 按套餐限制用户数 | ❌ 按版本限制用户数 | ⚠️ 社区版有用户限制 |
| **部署方式** | ✅ 私有化部署,数据完全自主可控 | ⚠️ SaaS为主,私有化成本极高 | ⚠️ SaaS为主,私有化需企业版 | ✅ 支持私有化部署 |
| **二次开发** | ✅ 无限制,可任意修改源码 | ❌ 不支持 | ❌ 有限支持 | ⚠️ AGPL协议限制商业二次开发 |
| **商用授权** | ✅ Apache 2.0 协议 + 附加条款,可自由商用 | ❌ 需购买商业许可 | ❌ 需购买商业许可 | ⚠️ AGPL商用需购买授权 |
| **数据库支持** | PostgreSQL / MySQL | 仅支持云数据库 | 仅支持云数据库 | PostgreSQL |
| **前端技术** | Vue 3 + Element Plus(主流技术栈) | 封闭技术栈,无法自定义 | 封闭技术栈 | React + Ant Design |
| **后端技术** | Python FastAPI(异步高性能) | 封闭 | 封闭 | Node.js + Koa |
### 零代码核心功能
#### 📝 在线表单设计器
- **拖拽式设计**:直观的拖拽操作,无需编写任何代码即可构建复杂表单
- **丰富组件库**:文本输入、数字、日期、下拉选择、级联选择、文件上传、富文本、子表单等 30+ 组件
- **复杂布局**:支持分栏、分组、表格布局、标签页等复杂页面布局
- **条件逻辑**:支持字段显隐条件、校验规则、联动逻辑配置
- **移动端适配**:自动适配 PC 端与移动端展示
#### 📄 在线页面设计器
- **可视化页面编排**:将表单、图表、数据表格等组件自由组合成完整的业务页面
- **数据绑定**:支持数据源绑定、API 对接,实现页面与后端数据的实时交互
- **权限集成**:页面级别的访问权限控制,与 RBAC 权限体系无缝集成
#### 📊 仪表盘设计器
- **丰富图表组件**:支持折线图、柱状图、饼图、雷达图、漏斗图、热力图、桑基图、K 线图等 20+ 图表类型
- **业务组件**:公告列表、待办事项、快捷入口、排行列表、服务器监控、天气组件等业务组件
- **数据过滤**:支持日期筛选、输入筛选等交互式数据过滤
- **自由布局**:拖拽式自由布局,支持自定义尺寸和位置
#### 🔗 数据管理
- **数据源管理**:支持接入外部数据库(PostgreSQL / MySQL / SQL Server)作为数据源
- **表单数据管理**:自动生成表单数据的列表、详情、编辑和删除功能
- **数据导入导出**:支持 Excel 格式的数据导入与导出
### 适用场景
- **企业内部管理系统**:OA、CRM、ERP、进销存、项目管理等
- **业务流程数字化**:审批流程、工单系统、报表系统等
- **快速原型开发**:业务需求快速验证与迭代
- **SaaS 平台构建**:多租户应用开发
- **数据收集与分析**:调查问卷、数据采集、统计分析
***
## 🏗 技术栈
### 后端 (Backend)
| 技术 | 说明 |
| ------------------------------------------------------ | --------------- |
| [FastAPI](https://fastapi.tiangolo.com/) | Web 框架 (0.121+) |
| [SQLAlchemy 2.0](https://www.sqlalchemy.org/) | 异步 ORM |
| [Alembic](https://alembic.sqlalchemy.org/) | 数据库迁移 |
| [PostgreSQL 16+](https://www.postgresql.org/) / MySQL | 数据库 |
| [Redis](https://redis.io/) | 缓存与消息 |
| [APScheduler 4.x](https://apscheduler.readthedocs.io/) | 定时任务调度 |
| [MinIO](https://min.io/) / OSS / Azure Blob | 文件存储 |
| [Qdrant](https://qdrant.tech/) | 向量数据库 (RAG) |
| [OpenAI](https://openai.com/) / Anthropic / DashScope | AI 大模型集成 |
| [WebSocket](https://websockets.readthedocs.io/) | 实时通信 |
### 前端 (Frontend)
| 技术 | 说明 |
| ---------------------------------------------------------- | ----------------------- |
| [Vue 3](https://vuejs.org/) | 前端框架 |
| [TypeScript](https://www.typescriptlang.org/) | 类型安全 |
| [Element Plus](https://element-plus.org/) | UI 组件库 |
| [Vben Admin 5.x](https://github.com/vbenjs/vue-vben-admin) | 后台管理框架 |
| [Vite](https://vitejs.dev/) | 构建工具 |
| [Pinia](https://pinia.vuejs.org/) | 状态管理 |
| [Vue Router](https://router.vuejs.org/) | 路由管理 |
| [i18n](https://vue-i18n.intlify.nuxt.dev/) | 国际化 (zh-CN/en-US/zh-TW) |
| [ECharts](https://echarts.apache.org/) | 图表可视化 |
| [Tiptap](https://tiptap.dev/) | 富文本编辑器 |
| [CodeMirror](https://codemirror.net/) | 代码编辑器 |
## ✨ 核心特性
### 🔐 用户与权限
- **用户管理**:完整的用户 CRUD、头像上传、密码策略
- **角色管理**:基于 RBAC 的角色权限分配
- **菜单管理**:动态菜单配置与权限控制
- **部门管理**:树形组织架构管理
- **岗位管理**:岗位关联与人员管理
- **资源权限**:资源级别的细粒度字段权限控制
- **数据权限**:基于部门/用户的数据范围隔离
### 🏢 组织管理
- **组织架构**:可视化组织架构图
- **部门树**:无限级部门层级管理
- **企业同步**:支持钉钉、飞书、企业微信组织架构与用户同步
### 🔗 第三方集成
- **OAuth 登录**:支持 Gitee、GitHub、QQ、Google、微信、Microsoft、钉钉、飞书、企业微信
- **消息通知**:邮件 (SMTP)、短信 (阿里云/腾讯云)、钉钉机器人、飞书机器人、企业微信机器人、微信公众号
- **文件存储**:支持本地存储、MinIO、阿里云 OSS、Azure Blob Storage
### 📱 零代码能力
- **在线表单设计**:拖拽式表单构建器,支持复杂表单设计
- **表单数据管理**:表单数据的 CRUD 与动态查询
- **在线页面设计**:可视化页面编辑器
- **仪表盘设计**:仪表盘设计器,支持丰富图表组件
- **代码生成器**:支持多种编码模式 (日期序列、流水号等)
### 💬 即时通讯
- **实时聊天**:基于 WebSocket 的单聊与群聊
- **消息管理**:未读计数、消息免打扰、置顶会话
- **消息通知**:系统通知与公告管理
### 🤖 AI 能力
- **AI 对话**:集成 OpenAI、Anthropic (Claude)、DashScope (通义千问) 等多种大模型
- **知识库**:基于 Qdrant 向量数据库的 RAG 检索增强生成
### ⚙️ 系统工具
- **数据字典**:业务字典管理 (支持树形/列表)
- **系统配置**:动态系统参数配置
- **UI 配置**:前端界面偏好设置 (可从后端动态加载)
- **定时任务**:基于 APScheduler 的任务调度管理
- **文件管理**:文件上传、预览、分片上传
- **数据源管理**:外部数据库连接管理
- **API 令牌**API 访问令牌管理
- **区域管理**:省市区地理数据管理
### 🌐 国际化
- 支持中文简体、中文繁体、英文
- 前端 UI 界面完全国际化
- 后端错误消息国际化支持
## 🚀 快速开始
### 前置条件
- Python 3.12+
- Node.js 20.10+
- pnpm 9.12+
- PostgreSQL 16+
- Redis
### 后端启动
```bash
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Linux/Mac
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp env/example.env env/dev.env
# 编辑 env/dev.env 配置数据库连接等信息
# 运行数据库迁移
alembic upgrade head
# 启动开发服务器
uvicorn main:app --reload --port 8000
```
### 前端启动
```bash
cd web
# 安装依赖
pnpm install
# 启动开发服务器(默认使用 Element Plus 版本)
pnpm dev
```
访问 <http://localhost:5777> 即可进入系统。
### 🐳 Docker Compose 部署
项目提供了完善的 Docker 化部署方案,支持两种模式:
| 模式 | 配置文件 | 适用场景 |
|------|----------|----------|
| **全量部署** | `docker-compose.yml` | 全新部署,自动启动 PostgreSQL + Redis |
| **生产部署** | `docker-compose.prod.yml` | 连接已有的外部数据库和 Redis |
| **预构建部署** | `docker-compose.web-direct.yml` | 连接外部数据库和 Redis,跳过前端构建,直接使用预构建静态文件 |
#### 前置条件
- [Docker](https://www.docker.com/) >= 24.0
- [Docker Compose](https://docs.docker.com/compose/) >= 2.20
---
#### 模式一:全量部署(一键启动)
适用于本地开发或全新部署场景,自动创建并启动 PostgreSQL 和 Redis 容器。
```bash
# 1. 进入项目根目录
cd zq-platform
# 2. (可选)创建环境变量文件
cat > .env << EOF
DB_PASSWORD=your_db_password
REDIS_PASSWORD=your_redis_password
JWT_SECRET_KEY=your-secret-key-change-in-production
EOF
# 3. 构建并启动所有服务(PostgreSQL + Redis + 后端 + 前端)
docker compose up -d
# 4. 查看日志
docker compose logs -f
```
#### 模式二:生产部署(连接外部数据库和 Redis)
适用于已有数据库和 Redis 实例的场景,**不启动 postgres/redis 容器**,仅启动业务服务。
```bash
# 1. 复制环境变量模板
cp .env.prod.example .env.prod
# 2. 编辑 .env.prod,填入外部服务连接信息
vim .env.prod
# 3. 构建并启动业务服务(仅 backend + web
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d
# 4. 查看日志
docker compose -f docker-compose.prod.yml logs -f
```
---
#### 模式三:预构建部署(跳过前端构建)
适用于已有前端构建产物,**跳过 Node.js 构建步骤**的场景。将预构建的 Vue 静态文件放入 `docker/web/index/` 目录,直接构建 Nginx 镜像,节省部署时间和资源。
```bash
# 1. 复制环境变量模板
cp .env.prod.example .env.prod
# 2. 编辑 .env.prod,填入外部服务连接信息
vim .env.prod
# 3. 准备预构建的前端静态文件(先手动构建 Vue 项目)
cd web && pnpm run build --filter=@vben/web-ele && cd ..
cp -r web/apps/web-ele/dist/* docker/web/index/
# 4. 构建并启动业务服务(跳过前端构建步骤)
docker compose -f docker-compose.web-direct.yml --env-file .env.prod up -d
# 5. 查看日志
docker compose -f docker-compose.web-direct.yml logs -f
```
> **提示**Web 服务的 Nginx 配置与 `docker-compose.prod.yml` 中完全一致,仅构建方式不同(直接使用 `docker/web/index/` 中的预构建文件,而非在 Docker 内执行 `pnpm build`)。
#### 服务架构
```text
┌─────────────┐ ┌──────────────────────────────────┐
│ Browser │ ───▶ │ Nginx (Port 80) │
└─────────────┘ │ ├─ 静态文件 → Vue SPA │
│ ├─ /basic-api/* → backend:8000 │
│ └─ /ws/* → backend:8000 │
└──────────┬───────────────────────┘
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Backend │
│ :5432 │ │ :6379 │ │ :8000 │
└─────────────┘ └──────────────┘ └──────────────┘
```
> 生产模式下 PostgreSQL 和 Redis 为外部服务,不包含在 Docker Compose 中
#### 服务说明
| 服务名 | 镜像 | 端口 | 说明 |
|--------|------|------|------|
| **postgres** | postgres:16-alpine | 5432 | 数据库(仅全量部署) |
| **redis** | redis:7-alpine | 6379 | 缓存(仅全量部署) |
| **backend** | 自建 | 8000 | FastAPIUvicorn 4 workers |
| **web** | 自建 | **80** | Nginx 服务 Vue 前端 + 代理 API/WebSocket |
| **uniapp** | 自建 | **90** | Nginx 服务 UniApp H5 移动端 + 代理 API/WebSocket |
#### 常用命令
> **提示**:以下命令中,全量部署通过 `docker compose` 执行;生产部署和预构建部署需加上对应参数。
| 操作 | 全量部署 | 生产部署 | 预构建部署 |
|------|----------|----------|------------|
| 构建并启动 | `docker compose up -d` | `docker compose -f docker-compose.prod.yml --env-file .env.prod up -d` | `docker compose -f docker-compose.web-direct.yml --env-file .env.prod up -d` |
| 全部重建 | `docker compose up -d --build` | `docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build` | `docker compose -f docker-compose.web-direct.yml --env-file .env.prod up -d --build` |
| 仅重建后端 | `docker compose up -d --build backend` | `docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build backend` | `docker compose -f docker-compose.web-direct.yml --env-file .env.prod up -d --build backend` |
| 仅重建 Vue 前端 | `docker compose up -d --build web` | `docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build web` | `docker compose -f docker-compose.web-direct.yml --env-file .env.prod up -d --build web` |
| 仅重建 UniApp 移动端 | `docker compose up -d --build uniapp` | `docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build uniapp` | `docker compose -f docker-compose.web-direct.yml --env-file .env.prod up -d --build uniapp` |
| 查看日志 | `docker compose logs -f` | `docker compose -f docker-compose.prod.yml logs -f` | `docker compose -f docker-compose.web-direct.yml logs -f` |
| 停止服务 | `docker compose down` | `docker compose -f docker-compose.prod.yml down` | `docker compose -f docker-compose.web-direct.yml down` |
| 完全清理(删除数据卷) | `docker compose down -v` | `docker compose -f docker-compose.prod.yml down -v` | `docker compose -f docker-compose.web-direct.yml down -v` |
| 查看服务状态 | `docker compose ps` | `docker compose ps -f docker-compose.prod.yml` | `docker compose ps -f docker-compose.web-direct.yml` |
如果使用生产部署频率较高,建议配置 shell 别名:
```bash
# 添加至 ~/.zshrc 或 ~/.bashrc
alias dc-prod="docker compose -f docker-compose.prod.yml --env-file .env.prod"
# 使用
dc-prod up -d
dc-prod up -d --build backend
dc-prod logs -f
```
#### 生产部署关键环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `DB_HOST` | 外部数据库地址 | **必填** |
| `DB_PORT` | 数据库端口 | `5432` |
| `DB_USER` | 数据库用户 | **必填** |
| `DB_PASSWORD` | 数据库密码 | **必填** |
| `DB_NAME` | 数据库名 | `zq_platform` |
| `DB_TYPE` | 数据库类型(postgresql/mysql/sqlserver | `postgresql` |
| `REDIS_HOST` | 外部 Redis 地址 | **必填** |
| `REDIS_PORT` | Redis 端口 | `6379` |
| `REDIS_PASSWORD` | Redis 密码 | `redis123` |
| `JWT_SECRET_KEY` | JWT 签名密钥 | **必填** |
| `BACKEND_PORT_MAPPING` | 后端端口映射 | `8000` |
| `WEB_PORT` | Vue 前端端口 | `80` |
| `UNIAPP_PORT` | UniApp H5 移动端端口 | `90` |
> **注意**:容器内 `localhost` 指向容器自身而非宿主机。如需连接宿主机上的数据库/Redis,请填写宿主机内网 IP 或使用 `host.docker.internal`macOS 可用)。
##### 连接同主机上的其他 Docker 容器
如果 PostgreSQL / Redis 也是以 Docker 容器运行在同一台机器上,推荐让两个容器加入同一个自定义网络进行通信。
**方案 1:加入同一个自定义网络(推荐,生产级)**
这是最规范、稳定的做法,通过 Docker 内部 DNS 解析容器名,不依赖 IP。
```bash
# 1. 创建自定义网络(如果尚未创建)
docker network create zq-network
# 2. 将生产后端容器加入网络
docker network connect zq-network zq-backend
# 3. 将数据库容器加入同一个网络
docker network connect zq-network 1Panel-postgresql-01NQ
```
加入后,在 `docker-compose.prod.yml` 中指定 `DB_HOST` 为数据库容器名(如 `1Panel-postgresql-01NQ`),并确保 `networks` 配置也使用了 `zq-network`
```yaml
services:
backend:
networks:
- zq-network
networks:
zq-network:
external: true
```
#### 项目 Docker 文件结构
```
docker/
├── backend/
│ ├── Dockerfile # 后端镜像构建(Python 3.12-slim
│ ├── entrypoint.sh # 启动入口(等待依赖、自动迁移、启动服务)
│ └── wait-for-it.sh # 等待依赖服务就绪
├── web/
│ ├── Dockerfile # Vue 前端镜像构建(Node 20 → Nginx
│ ├── Dockerfile.direct # Vue 前端镜像构建(跳过 Node 构建,直接使用预构建文件)
│ └── index/ # 预构建的 Vue 前端静态文件(用于 Dockerfile.direct
├── uniapp/
│ ├── Dockerfile # UniApp H5 移动端镜像构建(Nginx
│ ├── h5/ # 预构建的 UniApp H5 静态文件
│ └── conf.d/
│ └── default.conf # 站点配置(API 反向代理 / WebSocket / 静态资源)
└── nginx/
├── nginx.conf # Nginx 主配置
└── conf.d/
└── default.conf # 站点配置(API 反向代理 / WebSocket / 静态资源)
.dockerignore # 构建上下文忽略规则
docker-compose.yml # 全量部署编排
docker-compose.prod.yml # 生产部署编排(连接外部服务)
docker-compose.web-direct.yml # 预构建部署编排(跳过前端构建,连接外部服务)
.env.prod.example # 生产部署环境变量模板
```
#### 安全注意事项
1. **生产部署前务必修改密钥**
- `JWT_SECRET_KEY` — JWT 签名密钥
- `DB_PASSWORD` — 数据库密码
- `REDIS_PASSWORD` — Redis 密码
2. 默认使用 PostgreSQL,如需切换 MySQL 请修改 `docker-compose.yml` 中的 `DB_TYPE` 环境变量
3. 文件存储默认使用 `local` 模式,如需 MinIO/OSS 可在 `.env` 中配置
## 🏛 项目结构
```
zq-platform/
├── backend/ # 后端 Python 服务
│ ├── app/ # 核心应用模块
│ │ ├── base_model.py # 基础模型
│ │ ├── base_schema.py # 通用 Schema
│ │ ├── base_service.py # 基础服务
│ │ ├── config.py # 系统配置
│ │ ├── database.py # 数据库连接
│ │ └── ...
│ ├── core/ # 核心业务模块
│ │ ├── auth/ # 认证模块
│ │ ├── user/ # 用户管理
│ │ ├── role/ # 角色管理
│ │ ├── menu/ # 菜单管理
│ │ ├── dept/ # 部门管理
│ │ ├── permission/ # 权限管理
│ │ ├── chat/ # 即时通讯
│ │ ├── file_manager/ # 文件管理
│ │ ├── message/ # 消息通知
│ │ ├── oauth/ # 第三方登录
│ │ ├── code_generator/ # 代码生成器
│ │ ├── data_source/ # 数据源管理
│ │ ├── system_config/ # 系统配置
│ │ ├── ui_config/ # UI 配置
│ │ ├── server_monitor/ # 服务监控
│ │ ├── redis_monitor/ # Redis 监控
│ │ ├── database_monitor/ # 数据库监控
│ │ ├── redis_manager/ # Redis 管理
│ │ ├── database_manager/ # 数据库管理
│ │ ├── dingtalk_sync/ # 钉钉同步
│ │ ├── feishu_sync/ # 飞书同步
│ │ ├── wecom_sync/ # 企业微信同步
│ │ ├── application/ # 应用管理
│ │ ├── device/ # 设备管理
│ │ ├── region/ # 区域管理
│ │ ├── login_log/ # 登录日志
│ │ ├── api_token/ # API 令牌
│ │ ├── link_preview/ # 链接预览
│ │ ├── dict/ # 数据字典
│ │ ├── post/ # 岗位管理
│ │ └── resource_scope/ # 资源权限
│ ├── online_dev/ # 在线开发模块
│ │ ├── form_manager/ # 表单管理
│ │ ├── form_data_manager/ # 表单数据管理
│ │ └── page_manager/ # 页面管理
│ ├── scheduler/ # 定时任务
│ ├── zq_demo/ # 示例模块
│ ├── alembic/ # 数据库迁移
│ ├── main.py # 应用入口
│ └── requirements.txt # Python 依赖
├── web/ # 前端工程
│ ├── apps/
│ │ └── web-ele/ # Element Plus 版本应用
│ │ └── src/
│ │ ├── api/ # API 接口
│ │ ├── components/ # 业务组件
│ │ ├── views/ # 页面视图
│ │ ├── router/ # 路由配置
│ │ ├── store/ # 状态管理
│ │ ├── locales/ # 国际化
│ │ └── layouts/ # 布局组件
│ ├── packages/ # 共享包
│ │ ├── @core/ # 核心包 (UI 组件、工具库等)
│ │ ├── effects/ # 业务逻辑
│ │ ├── constants/ # 常量定义
│ │ ├── hooks/ # 组合式函数
│ │ ├── icons/ # 图标库
│ │ ├── locales/ # 国际化
│ │ ├── preferences/ # 偏好设置
│ │ ├── request/ # HTTP 请求
│ │ ├── stores/ # 状态管理
│ │ ├── styles/ # 样式
│ │ ├── types/ # TypeScript 类型
│ │ └── utils/ # 工具函数
│ └── package.json # 前端依赖
└── README.md # 项目文档
```
## ✅ 环境要求
| 依赖 | 版本要求 |
| ---------- | -------- |
| Python | >= 3.12 |
| Node.js | >= 20.10 |
| pnpm | >= 9.12 |
| PostgreSQL | >= 16 |
| Redis | >= 6.0 |
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request 来帮助改进项目。
1. Fork 本仓库
2. 创建您的特性分支 (`git checkout -b feature/amazing-feature`)
3. 提交您的更改 (`git commit -m 'feat: 添加某个功能'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 提交 Pull Request
## 📄 许可证
本项目采用双重许可证结构:
- **核心许可**[Apache License 2.0](LICENSE) — 标准 Apache 2.0 开源协议,允许自由使用、修改和再发布
- **附加条款**[ZQ-Platform Public License Supplemental Terms](LICENSE_SUPPLEMENTAL) — 包含署名("powered by ZQ-Platform")与商标使用的补充约定
使用本代码即表示您同意遵守以上两份文件中的所有条款。
## 📞 联系我们
- **官方网站**<https://zq-platform.com/>
- **开源仓库**<https://github.com/jiangzhikj/zq-platform>
- **完整版演示**<https://demo.zq-platform.com/>
- **开源版演示**<https://opensource.zq-platform.com/>
- **作者邮箱**<jiangzhikj@outlook.com>
- **问题反馈**[GitHub Issues](https://github.com/jiangzhikj/zq-platform/issues)
默认管理员:
- 用户名:admin
- 密码:admin123456
-339
View File
@@ -1,339 +0,0 @@
# zq-platform(芷青开发平台)
[English](./README.md) | 简体中文
<div align="center">
一个现代化的企业级后台管理系统,基于 Django + Vue3 + Element Plus 构建
[![Django](https://img.shields.io/badge/Django-5.2.7-green.svg)](https://www.djangoproject.com/)
[![Vue](https://img.shields.io/badge/Vue-3.x-brightgreen.svg)](https://vuejs.org/)
[![Element Plus](https://img.shields.io/badge/Element%20Plus-latest-blue.svg)](https://element-plus.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
</div>
## 演示链接
[https://django-ninja.zq-platform.cn](https://django-ninja.zq-platform.cn/)
## 📖 项目简介
zq-platform 是一个功能完善的企业级后台管理系统解决方案,采用前后端分离架构。后端使用 Django 5.2 + Django Ninja 构建高性能 RESTful API,前端基于 Vue 3 + Vben Admin + Element Plus 打造现代化的管理界面。
### ✨ 核心特性
- 🎯 **完整的 RBAC 权限系统** - 用户、角色、权限、部门、岗位多维度权限控制
- 🔐 **JWT 认证机制** - 安全的 Token 认证,支持 Access Token 和 Refresh Token
- 📊 **系统监控** - 服务器监控、Redis 监控、数据库监控,实时掌握系统状态
- 📁 **文件管理** - 完善的文件上传、下载、预览功能
- 📝 **操作日志** - 详细的登录日志和操作审计
- 🗂️ **数据字典** - 灵活的字典管理,支持多级分类
-**任务调度** - 基于 APScheduler 的定时任务管理
- 🔌 **WebSocket 支持** - 实时通信能力
- 🌐 **多数据库支持** - MySQL、PostgreSQL、SQL Server、SQLite
- 🎨 **现代化 UI** - 响应式设计,支持暗黑模式
- 📦 **Monorepo 架构** - 基于 pnpm workspace 的前端工程化方案
## 🏗️ 技术栈
### 后端技术
- **核心框架**: Django 5.2.7
- **API 框架**: Django Ninja 1.4.5 (高性能 API 框架)
- **认证**: PyJWT 2.8.0
- **异步任务**: Celery 5.4.0 + Django Celery Beat
- **任务调度**: APScheduler 3.10.4
- **缓存**: Redis + django-redis
- **WebSocket**: Django Channels 4.2
- **数据库驱动**: psycopg2-binary, pymysql, pyodbc
- **服务器**: Uvicorn 0.38.0 / Gunicorn 23.0.0
- **其他**: openpyxl, geoip2, psutil, cryptography
### 前端技术
- **核心框架**: Vue 3.x
- **构建工具**: Vite 5.x
- **UI 组件库**: Element Plus
- **状态管理**: Pinia
- **路由**: Vue Router
- **HTTP 客户端**: Axios
- **工具库**: VueUse, dayjs, lodash-es
- **代码规范**: ESLint, Prettier, Stylelint
- **包管理**: pnpm 10.14.0
- **Monorepo**: Turbo
## 📁 项目结构
```
zq-platform/
├── backend-django/ # Django 后端
│ ├── application/ # 项目配置
│ ├── core/ # 核心业务模块
│ │ ├── auth/ # 认证授权
│ │ ├── user/ # 用户管理
│ │ ├── role/ # 角色管理
│ │ ├── permission/ # 权限管理
│ │ ├── dept/ # 部门管理
│ │ ├── post/ # 岗位管理
│ │ ├── menu/ # 菜单管理
│ │ ├── dict/ # 字典管理
│ │ ├── login_log/ # 登录日志
│ │ ├── file_manager/ # 文件管理
│ │ ├── server_monitor/ # 服务器监控
│ │ ├── redis_monitor/ # Redis 监控
│ │ ├── redis_manager/ # Redis 管理
│ │ ├── database_monitor/ # 数据库监控
│ │ └── database_manager/ # 数据库管理
│ ├── scheduler/ # 任务调度模块
│ ├── common/ # 公共模块
│ ├── env/ # 环境配置
│ ├── requirements.txt # Python 依赖
│ └── manage.py # Django 管理脚本
└── web/ # Vue 前端 (Monorepo)
├── apps/
│ └── web-ele/ # Element Plus 版本主应用
│ ├── src/
│ │ ├── api/ # API 接口
│ │ ├── views/ # 页面组件
│ │ ├── router/ # 路由配置
│ │ └── store/ # 状态管理
│ └── package.json
├── packages/ # 共享包
│ ├── @core/ # 核心包
│ ├── effects/ # 副作用包
│ ├── hooks/ # Hooks
│ ├── icons/ # 图标
│ ├── locales/ # 国际化
│ ├── stores/ # 状态管理
│ └── utils/ # 工具函数
├── internal/ # 内部工具
└── package.json # 根配置
```
## 🚀 快速开始
### 环境要求
- **后端**
- Python >= 3.10
- MySQL >= 5.7 / PostgreSQL >= 12 / SQL Server / SQLite
- Redis >= 5.0
- **前端**
- Node.js >= 20.10.0
- pnpm >= 9.12.0
### 后端安装
1. **克隆项目**
```bash
git clone https://github.com/jiangzhikj/zq-platform.git
cd zq-platform/backend-django
```
2. **创建虚拟环境**
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
# 或
venv\Scripts\activate # Windows
```
3. **安装依赖**
```bash
pip install -r requirements.txt
```
4. **配置环境变量**
```bash
cp env
# 编辑 .env 文件,配置数据库、Redis、JWT 密钥等
```
主要配置项:
```env
# JWT 密钥
JWT_ACCESS_SECRET_KEY=your-jwt-access-secret
JWT_REFRESH_SECRET_KEY=your-jwt-refresh-secret
# 数据库配置
DATABASE_TYPE=MYSQL # MYSQL/POSTGRESQL/SQLSERVER/SQLITE3
DATABASE_HOST=127.0.0.1
DATABASE_PORT=3306
DATABASE_USER=root
DATABASE_PASSWORD=password
DATABASE_NAME=zq_admin
# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=2
```
5. **数据库迁移**
```bash
python manage.py makemigrations core scheduler
python manage.py migrate
```
6. **初始化数据**
```bash
python manage.py loaddata db_init.json
```
7. **启动服务**
```bash
# 开发环境
python manage.py runserver 0.0.0.0:8000
```
8. **启动任务调度器(可选)**
```bash
# 生产环境
python start_scheduler.py
```
### 前端安装
1. **进入前端目录**
```bash
cd zq-platform/web
```
2. **安装依赖**
```bash
pnpm install
```
3. **配置环境变量**
```bash
cd apps/web-ele
cp .env.development .env
# 编辑 .env 文件,配置后端 API 地址
```
4. **启动开发服务器**
```bash
# 在 web 根目录下
pnpm dev
```
5. **构建生产版本**
```bash
pnpm build:ele
```
## 📝 默认账号
初始化数据后,可使用以下账号登录:
- 账号: `superadmin`
- 密码: 请查看 `123456` 或联系管理员
## 🔧 主要功能模块
### 系统管理
- **用户管理**: 用户的增删改查、密码重置、状态管理
- **角色管理**: 角色权限分配、数据权限控制
- **权限管理**: 接口权限、按钮权限细粒度控制
- **部门管理**: 树形部门结构管理
- **岗位管理**: 岗位信息维护
- **菜单管理**: 动态菜单配置、路由管理
- **字典管理**: 系统字典维护
### 系统监控
- **服务器监控**: CPU、内存、磁盘、网络实时监控
- **Redis 监控**: Redis 性能指标、键值管理
- **数据库监控**: 数据库连接、性能监控
- **登录日志**: 用户登录记录、IP 地理位置
### 任务调度
- **定时任务**: Cron 表达式配置
- **任务日志**: 执行历史、结果查看
- **任务管理**: 启动、停止、立即执行
### 文件管理
- **文件上传**: 支持多文件上传
- **文件预览**: 图片、文档在线预览
- **文件下载**: 批量下载功能
## 🔐 API 文档
后端启动后,访问以下地址查看 API 文档:
- Swagger UI: `http://localhost:8000/api/docs`
- ReDoc: `http://localhost:8000/api/redoc`
## 🛠️ 开发指南
### 后端开发
1. **添加新模块**
-`core/` 或创建新 app
- 定义 models、schemas、services、api
- 在 router 中注册路由
2. **API 开发规范**
- 使用 Django Ninja 装饰器
- 统一返回格式
- 异常处理
- 权限验证
### 前端开发
1. **添加新页面**
-`src/views/` 创建页面组件
-`src/router/routes/modules/` 添加路由
-`src/api/` 添加接口定义
2. **组件开发规范**
- 使用 Element Plus 组件
- 优先使用 Tailwind CSS
- 支持暗黑模式
- 图标从 `@vben/icons` 导入
## 📦 部署
1. **后端部署**
- 使用 Gunicorn + Nginx
- 配置 Supervisor 进程守护
- 配置 SSL 证书
2. **前端部署**
- 执行 `pnpm build` 构建
-`dist` 目录部署到 Nginx
- 配置反向代理
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 本项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 🙏 致谢
- [Django](https://www.djangoproject.com/) - 强大的 Python Web 框架
- [Django Ninja](https://django-ninja.rest-framework.com/) - 快速的 Django REST 框架
- [Vue Vben Admin](https://github.com/vbenjs/vue-vben-admin) - 优秀的 Vue3 后台管理模板
- [Element Plus](https://element-plus.org/) - 基于 Vue 3 的组件库
## 📞 联系方式
如有问题或建议,请通过以下方式联系:
- Issue: [GitHub Issues](../../issues)
- Email: jiangzhikj@outlook.com
---
<div align="center">
Made with ❤️ by ZQ Team
</div>
-70
View File
@@ -1,70 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
media/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/*
env/*
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment files - 保护敏感信息
env/*
!env/example.env
# Database
*.db
*.sqlite3
# Logs
*.log
logs/
# Media files
media/
!media/.gitkeep
# Chunk uploads temp
media/chunk_uploads/
# Cache
.cache/
*.pyc
# Test
.pytest_cache/
.coverage
htmlcov/
# Alembic
alembic/versions/*.pyc
# OS
.DS_Store
Thumbs.db
-777
View File
@@ -1,777 +0,0 @@
# ZQ Platform - FastAPI Backend
基于 FastAPI 的现代化异步后端服务,使用 SQLAlchemy 异步 ORM + Alembic 数据库迁移 + PostgreSQL。
## 技术栈
- **框架**: FastAPI 0.115+
- **数据库**: PostgreSQL 16+
- **ORM**: SQLAlchemy 2.0+ (异步)
- **迁移**: Alembic
- **认证**: JWT
- **缓存**: Redis
- **Python**: 3.12+
## 项目结构
```
backend-fastapi/
├── app/ # 核心应用模块
│ ├── config.py # 配置管理
│ ├── database.py # 数据库连接
│ ├── base_model.py # BaseModel 基类
│ ├── base_schema.py # 通用 Schema
│ ├── base_service.py # BaseService 基类
│ ├── redis.py # Redis 缓存
│ └── excel.py # Excel 工具
├── core/ # 核心业务模块
│ ├── user/ # 用户管理
│ ├── role/ # 角色管理
│ ├── menu/ # 菜单管理
│ ├── dept/ # 部门管理
│ ├── permission/ # 权限管理
│ └── ...
├── scheduler/ # 定时任务模块
│ ├── model.py
│ ├── service.py
│ └── tasks.py
├── zq_demo/ # 示例模块
│ ├── demo/
│ └── demo_cache/
├── scripts/ # 工具脚本
│ ├── dumpdata.py # 数据导出
│ └── loaddata.py # 数据导入
├── alembic/ # 数据库迁移
│ ├── versions/
│ └── env.py
├── env/ # 环境配置
│ ├── dev.env
│ ├── uat.env
│ └── prod.env
├── main.py # 应用入口
├── requirements.txt # 依赖列表
└── README.md
```
## 快速开始
### 1. 环境准备
```bash
# 创建虚拟环境
conda create -n zq-fastapi python=3.12
conda activate zq-fastapi
# 或使用 venv
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 配置环境变量
复制环境配置文件:
```bash
cp env/example.env env/dev.env
```
编辑 `env/dev.env`,配置数据库连接:
```env
# 数据库配置
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/dbname
# Redis 配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
# JWT 配置
SECRET_KEY=your-secret-key-here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
```
### 4. 数据库迁移
```bash
# 首次使用:生成初始迁移
alembic revision --autogenerate -m "init tables"
# 执行迁移
alembic upgrade head
# 导入数据
python scripts/loaddata.py db_init.json
```
### 5. 启动服务
```bash
# 开发模式(自动重载)
python main.py
# 或使用 uvicorn
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 6. 访问 API 文档
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
## 数据库操作
### 迁移命令
```bash
# 查看当前版本
alembic current
# 查看迁移历史
alembic history
# 生成新的迁移文件
alembic revision --autogenerate -m "描述信息"
# 升级到最新版本
alembic upgrade head
# 回滚一个版本
alembic downgrade -1
# 回滚到指定版本
alembic downgrade <revision_id>
```
### 数据导入导出
#### 导出数据(dumpdata.py
```bash
# 导出所有数据到文件
python scripts/dumpdata.py -o db_init.json -f
# 导出指定模块(如 core
python scripts/dumpdata.py core -o core_data.json -f
# 导出到标准输出(不指定 -o 参数)
python scripts/dumpdata.py > data.json
# 导出指定模块到标准输出
python scripts/dumpdata.py core > core_data.json
```
**参数说明:**
- `app_name`(位置参数,可选):指定要导出的应用/模块名称
- 例如:`core``scheduler``zq_demo`
- 不指定则导出所有数据
- `-o, --output`:指定输出文件路径
- 例如:`-o db_init.json`
- 不指定则输出到标准输出(stdout)
- `-f, --force`:强制覆盖已存在的文件
- 如果输出文件已存在且未使用此参数,脚本会报错并退出
- 使用此参数可以强制覆盖现有文件
**示例:**
```bash
# 导出所有数据,如果文件存在则覆盖
python scripts/dumpdata.py -o db_init.json -f
# 导出 core 模块数据,不覆盖已存在文件(文件存在会报错)
python scripts/dumpdata.py core -o core_data.json
# 导出 scheduler 模块数据到标准输出,然后重定向到文件
python scripts/dumpdata.py scheduler > scheduler_data.json
```
#### 导入数据(loaddata.py
```bash
# 导入数据
python scripts/loaddata.py db_init.json
# 导入多个文件
python scripts/loaddata.py core_data.json scheduler_data.json
```
**参数说明:**
- `files`(位置参数,必需):要导入的 JSON 文件路径,可以指定多个文件
## 开发指南
### 新建模块
按照以下步骤创建新的业务模块(以 `example` 为例):
#### 1. 创建模块目录
```bash
mkdir -p core/example
touch core/example/__init__.py
touch core/example/model.py
touch core/example/schema.py
touch core/example/service.py
touch core/example/api.py
```
#### 2. 定义模型 (model.py)
```python
from sqlalchemy import Column, String, Boolean
from app.base_model import BaseModel
class Example(BaseModel):
__tablename__ = "core_example"
name = Column(String(100), nullable=False, comment="名称")
description = Column(String(500), comment="描述")
is_active = Column(Boolean, default=True, comment="是否激活")
```
#### 3. 定义 Schema (schema.py)
```python
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
class ExampleBase(BaseModel):
name: str
description: Optional[str] = None
is_active: bool = True
class ExampleCreate(ExampleBase):
pass
class ExampleUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class ExampleResponse(ExampleBase):
id: str
sort: int = 0
is_deleted: bool = False
sys_create_datetime: Optional[datetime] = None
sys_update_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
```
#### 4. 定义服务 (service.py)
```python
from app.base_service import BaseService
from core.example.model import Example
from core.example.schema import ExampleCreate, ExampleUpdate
class ExampleService(BaseService[Example, ExampleCreate, ExampleUpdate]):
model = Example
```
#### 5. 定义 API (api.py)
```python
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from core.example.schema import ExampleCreate, ExampleUpdate, ExampleResponse
from core.example.service import ExampleService
router = APIRouter(prefix="/example", tags=["示例管理"])
@router.post("", response_model=ExampleResponse, summary="创建")
async def create(data: ExampleCreate, db: AsyncSession = Depends(get_db)):
return await ExampleService.create(db=db, data=data)
@router.get("", response_model=PaginatedResponse[ExampleResponse], summary="获取列表")
async def get_list(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
db: AsyncSession = Depends(get_db)
):
items, total = await ExampleService.get_list(db, page=page, page_size=page_size)
return PaginatedResponse(items=items, total=total)
@router.get("/{record_id}", response_model=ExampleResponse, summary="获取详情")
async def get_by_id(record_id: str, db: AsyncSession = Depends(get_db)):
result = await ExampleService.get_by_id(db, record_id=record_id)
if not result:
raise HTTPException(status_code=404, detail="记录不存在")
return result
@router.put("/{record_id}", response_model=ExampleResponse, summary="更新")
async def update(record_id: str, data: ExampleUpdate, db: AsyncSession = Depends(get_db)):
result = await ExampleService.update(db, record_id=record_id, data=data)
if not result:
raise HTTPException(status_code=404, detail="记录不存在")
return result
@router.delete("/{record_id}", response_model=ResponseModel, summary="删除")
async def delete(record_id: str, db: AsyncSession = Depends(get_db)):
success = await ExampleService.delete(db, record_id=record_id)
if not success:
raise HTTPException(status_code=404, detail="记录不存在")
return ResponseModel(message="删除成功")
```
#### 6. 注册路由
`core/router.py` 中添加:
```python
from core.example.api import router as example_router
router.include_router(example_router)
```
#### 7. 生成数据库迁移
```bash
alembic revision --autogenerate -m "add example table"
alembic upgrade head
```
## 核心功能
### BaseModel
所有模型继承自 `BaseModel`,自动包含以下字段:
- `id`: UUID 主键
- `sort`: 排序字段
- `is_deleted`: 软删除标记
- `sys_create_datetime`: 创建时间
- `sys_update_datetime`: 更新时间
- `sys_creator_id`: 创建人ID
- `sys_modifier_id`: 修改人ID
### BaseService
提供通用 CRUD 操作:
- `create()`: 创建记录
- `get_by_id()`: 根据ID获取
- `get_list()`: 分页查询
- `update()`: 更新记录
- `delete()`: 删除记录(软删除/硬删除)
- `check_unique()`: 唯一性检查
- `export_to_excel()`: 导出Excel
- `import_from_excel()`: 导入Excel
### 缓存支持
使用 Redis 缓存,继承 `CacheService` 获得缓存功能:
```python
from app.cache_service import CacheService
class ExampleService(CacheService[Example, ExampleCreate, ExampleUpdate]):
model = Example
cache_prefix = "example"
cache_ttl = 3600 # 1小时
```
## 环境配置
项目支持多环境配置:
- `env/dev.env`: 开发环境
- `env/uat.env`: UAT环境
- `env/prod.env`: 生产环境
通过环境变量 `ENV` 切换:
```bash
export ENV=prod # 使用生产环境配置
python main.py
```
## API 规范
### 响应格式
成功响应:
```json
{
"code": 200,
"message": "success",
"data": {...}
}
```
分页响应:
```json
{
"items": [...],
"total": 100
}
```
错误响应:
```json
{
"detail": "错误信息"
}
```
### 路由命名规范
- 使用小写短横线:`/api/core/user-profile`
- 静态路由在前:`/api/core/menu/check/name`
- 动态路由在后:`/api/core/menu/{menu_id}`
## 常见问题
### 1. 迁移文件为空
确保 `alembic/env.py` 中的 `auto_import_models()` 函数正确扫描了所有模型文件。
### 2. 路由重定向 307
检查路由定义,使用 `@router.post("")` 而不是 `@router.post("/")`
### 3. 数据库连接失败
检查 `env/dev.env` 中的 `DATABASE_URL` 配置是否正确。
# WeasyPrint 安装与配置指南
本文档介绍如何在不同操作系统上安装和配置 WeasyPrint 及其依赖。
## 目录
- [macOS](#macos)
- [Linux (Ubuntu/Debian)](#linux-ubuntudebian)
- [Linux (CentOS/RHEL)](#linux-centosrhel)
- [Windows](#windows)
- [验证安装](#验证安装)
- [常见问题](#常见问题)
---
## macOS
### 1. 安装系统依赖
使用 Homebrew 安装所需的系统库:
```bash
brew install pango glib gobject-introspection harfbuzz cairo fontconfig freetype
```
### 2. 安装 Python 依赖
```bash
pip install weasyprint==62.3
```
### 3. 配置环境变量
WeasyPrint 需要能够找到系统库。将以下内容添加到 `~/.zshrc`(如果使用 bash,则添加到 `~/.bash_profile`):
```bash
export DYLD_LIBRARY_PATH="/opt/homebrew/opt/glib/lib:/opt/homebrew/opt/pango/lib:/opt/homebrew/opt/harfbuzz/lib:/opt/homebrew/opt/cairo/lib:/opt/homebrew/opt/fontconfig/lib:/opt/homebrew/opt/freetype/lib:$DYLD_LIBRARY_PATH"
```
**自动添加方法:**
```bash
echo 'export DYLD_LIBRARY_PATH="/opt/homebrew/opt/glib/lib:/opt/homebrew/opt/pango/lib:/opt/homebrew/opt/harfbuzz/lib:/opt/homebrew/opt/cairo/lib:/opt/homebrew/opt/fontconfig/lib:/opt/homebrew/opt/freetype/lib:$DYLD_LIBRARY_PATH"' >> ~/.zshrc
source ~/.zshrc
```
### 4. 重启终端或 IDE
关闭并重新打开终端窗口,或者重启 IDE,使环境变量生效。
---
## Linux (Ubuntu/Debian)
### 1. 安装系统依赖
```bash
sudo apt-get update
sudo apt-get install -y \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libffi-dev \
libcairo2 \
libglib2.0-0 \
libharfbuzz0b \
libfontconfig1 \
libfreetype6
```
### 2. 安装 Python 依赖
```bash
pip install weasyprint==62.3
```
### 3. 配置环境变量(通常不需要)
在 Linux 上,系统库通常已经在标准路径中,不需要额外配置环境变量。
如果遇到库加载问题,可以添加到 `~/.bashrc`
```bash
export LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
source ~/.bashrc
```
---
## Linux (CentOS/RHEL)
### 1. 安装系统依赖
```bash
sudo yum install -y \
pango \
pango-devel \
cairo \
cairo-devel \
glib2 \
glib2-devel \
harfbuzz \
harfbuzz-devel \
fontconfig \
fontconfig-devel \
freetype \
freetype-devel \
libffi-devel
```
或者使用 dnfCentOS 8+):
```bash
sudo dnf install -y \
pango \
pango-devel \
cairo \
cairo-devel \
glib2 \
glib2-devel \
harfbuzz \
harfbuzz-devel \
fontconfig \
fontconfig-devel \
freetype \
freetype-devel \
libffi-devel
```
### 2. 安装 Python 依赖
```bash
pip install weasyprint==62.3
```
### 3. 配置环境变量(如果需要)
```bash
export LD_LIBRARY_PATH="/usr/lib64:$LD_LIBRARY_PATH"
echo 'export LD_LIBRARY_PATH="/usr/lib64:$LD_LIBRARY_PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
## Windows
### 方法 1:使用 GTK3 Runtime(推荐)
1. **下载并安装 GTK3 Runtime**
访问 [GTK for Windows Runtime](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases),下载最新版本的安装程序(例如 `gtk3-runtime-3.24.31-2022-01-04-ts-win64.exe`)。
2. **运行安装程序**
双击安装程序,按照提示完成安装。默认安装路径为 `C:\Program Files\GTK3-Runtime Win64`
3. **添加到系统 PATH**
- 右键点击"此电脑" → "属性" → "高级系统设置" → "环境变量"
- 在"系统变量"中找到 `Path`,点击"编辑"
- 添加以下路径:
```
C:\Program Files\GTK3-Runtime Win64\bin
```
- 点击"确定"保存
4. **安装 Python 依赖**
```cmd
pip install weasyprint==62.3
```
5. **重启命令提示符或 PowerShell**
### 方法 2:使用 MSYS2(开发者推荐)
1. **安装 MSYS2**
访问 [MSYS2 官网](https://www.msys2.org/),下载并安装 MSYS2。
2. **安装依赖包**
打开 MSYS2 终端,运行:
```bash
pacman -S mingw-w64-x86_64-pango mingw-w64-x86_64-cairo mingw-w64-x86_64-glib2
```
3. **添加到系统 PATH**
将 MSYS2 的 bin 目录添加到系统 PATH:
```
C:\msys64\mingw64\bin
```
4. **安装 Python 依赖**
```cmd
pip install weasyprint==62.3
```
---
## 验证安装
运行以下 Python 代码验证 WeasyPrint 是否正确安装:
```python
from weasyprint import HTML
html_content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>测试中文字体</h1>
<p>这是一个测试文档,用于验证 WeasyPrint 是否正确安装。</p>
<p>Test English text and 中文文本。</p>
</body>
</html>
"""
try:
pdf_bytes = HTML(string=html_content).write_pdf()
with open('test.pdf', 'wb') as f:
f.write(pdf_bytes)
print("✓ WeasyPrint 安装成功!已生成 test.pdf")
except Exception as e:
print(f"✗ WeasyPrint 安装失败:{e}")
```
如果成功,会在当前目录生成 `test.pdf` 文件。
---
## 常见问题
### 1. macOS: `OSError: cannot load library 'libgobject-2.0-0'`
**原因**:环境变量未正确设置。
**解决方案**
- 确保已添加环境变量到 `~/.zshrc`
- 重启终端或运行 `source ~/.zshrc`
- 如果使用 IDE,需要重启 IDE
### 2. Linux: `ImportError: cannot import name 'HTML' from 'weasyprint'`
**原因**:系统依赖未安装。
**解决方案**
```bash
sudo apt-get install -y libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
```
### 3. Windows: `OSError: no library called "cairo" was found`
**原因**GTK3 Runtime 未安装或未添加到 PATH。
**解决方案**
- 确保已安装 GTK3 Runtime
- 检查 `C:\Program Files\GTK3-Runtime Win64\bin` 是否在系统 PATH 中
- 重启命令提示符
### 4. 中文字体显示为方块
**原因**:系统缺少中文字体。
**解决方案**
**macOS**
```bash
# 系统自带中文字体,通常不需要额外安装
```
**Linux**
```bash
sudo apt-get install fonts-noto-cjk fonts-wqy-zenhei
```
**Windows**
- 确保系统已安装中文字体(如微软雅黑、宋体等)
- Windows 10/11 默认已包含中文字体
### 5. Conda 环境中的问题
如果在 Conda 环境中遇到库加载问题,尝试:
```bash
# 安装 conda-forge 版本
conda install -c conda-forge weasyprint
```
或者确保环境变量在激活 Conda 环境后仍然有效。
---
## 项目启动
配置完成后,启动后端服务:
```bash
cd /path/to/backend-fastapi
python -m uvicorn main:app --reload
```
如果一切正常,服务应该能够成功启动,并且 PDF 预览功能可以正常使用。
---
## 参考链接
- [WeasyPrint 官方文档](https://doc.courtbouillon.org/weasyprint/stable/)
- [WeasyPrint 安装指南](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation)
- [WeasyPrint 故障排除](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#troubleshooting)
-10
View File
@@ -1,10 +0,0 @@
"""
AI 平台模块
提供类似 Dify 的 LLM 应用开发能力:
- 模型配置管理
- 对话功能
- 工作流编排
- 智能体
- 工具系统
"""
@@ -1,3 +0,0 @@
"""
AI 平台 API
"""
File diff suppressed because it is too large Load Diff
-295
View File
@@ -1,295 +0,0 @@
"""
AI 应用 API
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from ai_platform.models import AIApp, LLMModel
from ai_platform.schemas.app_schema import (
AppCreate,
AppUpdate,
AppResponse,
AppListResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/apps", tags=["AI-应用"])
@router.get("", response_model=PaginatedResponse[AppListResponse], summary="应用列表")
async def list_apps(
name: Optional[str] = Query(None, description="名称"),
app_type: Optional[str] = Query(None, description="类型"),
status: Optional[str] = Query(None, description="状态"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取应用列表"""
query = select(AIApp).where(AIApp.is_deleted == False)
if name:
query = query.where(AIApp.name.ilike(f"%{name}%"))
if app_type:
query = query.where(AIApp.app_type == app_type)
if status:
query = query.where(AIApp.status == status)
# 获取总数
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(AIApp.sort.desc(), AIApp.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
apps = result.scalars().all()
items = []
for app in apps:
model_name = ""
if app.model_id:
model_result = await db.execute(
select(LLMModel).where(LLMModel.id == app.model_id)
)
model = model_result.scalar_one_or_none()
model_name = model.display_name if model else ""
items.append({
"id": app.id,
"name": app.name,
"code": app.code,
"description": app.description or "",
"icon": app.icon or "",
"app_type": app.app_type or "chat",
"status": app.status or "draft",
"model_name": model_name,
"is_public": app.is_public or False,
"conversation_count": app.conversation_count or 0,
"message_count": app.message_count or 0,
"sys_create_datetime": app.sys_create_datetime,
})
return PaginatedResponse(items=items, total=total)
@router.get("/published", response_model=List[AppListResponse], summary="获取已发布应用")
async def list_published_apps(db: AsyncSession = Depends(get_db)):
"""获取已发布的应用列表(用于用户选择)"""
query = select(AIApp).where(
AIApp.is_deleted == False,
AIApp.status == "published"
).order_by(AIApp.sort.desc())
result = await db.execute(query)
apps = result.scalars().all()
items = []
for app in apps:
model_name = ""
if app.model_id:
model_result = await db.execute(
select(LLMModel).where(LLMModel.id == app.model_id)
)
model = model_result.scalar_one_or_none()
model_name = model.display_name if model else ""
items.append({
"id": app.id,
"name": app.name,
"code": app.code,
"description": app.description or "",
"icon": app.icon or "",
"app_type": app.app_type or "chat",
"status": app.status or "draft",
"model_name": model_name,
"is_public": app.is_public or False,
"conversation_count": app.conversation_count or 0,
"message_count": app.message_count or 0,
"sys_create_datetime": app.sys_create_datetime,
})
return items
@router.get("/code/{code}", response_model=AppResponse, summary="根据编码获取应用")
async def get_app_by_code(code: str, db: AsyncSession = Depends(get_db)):
"""根据编码获取应用"""
result = await db.execute(
select(AIApp).where(AIApp.code == code, AIApp.is_deleted == False)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
return await _build_app_response(app, db)
@router.get("/{app_id}", response_model=AppResponse, summary="应用详情")
async def get_app(app_id: str, db: AsyncSession = Depends(get_db)):
"""获取应用详情"""
result = await db.execute(
select(AIApp).where(AIApp.id == app_id, AIApp.is_deleted == False)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
return await _build_app_response(app, db)
@router.post("", response_model=AppResponse, summary="创建应用")
async def create_app(data: AppCreate, db: AsyncSession = Depends(get_db)):
"""创建应用"""
# 检查编码是否重复
exists_result = await db.execute(
select(AIApp).where(AIApp.code == data.code, AIApp.is_deleted == False)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"应用编码 {data.code} 已存在")
# 验证模型
if data.model_id:
model_result = await db.execute(
select(LLMModel).where(LLMModel.id == data.model_id, LLMModel.is_deleted == False)
)
if not model_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="模型不存在")
app = AIApp(**data.model_dump())
db.add(app)
await db.commit()
await db.refresh(app)
return await _build_app_response(app, db)
@router.put("/{app_id}", response_model=AppResponse, summary="更新应用")
async def update_app(
app_id: str,
data: AppUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新应用"""
result = await db.execute(
select(AIApp).where(AIApp.id == app_id, AIApp.is_deleted == False)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 验证模型
if "model_id" in update_data and update_data["model_id"]:
model_result = await db.execute(
select(LLMModel).where(
LLMModel.id == update_data["model_id"],
LLMModel.is_deleted == False
)
)
if not model_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="模型不存在")
for key, value in update_data.items():
setattr(app, key, value)
await db.commit()
await db.refresh(app)
return await _build_app_response(app, db)
@router.delete("/{app_id}", response_model=ResponseModel, summary="删除应用")
async def delete_app(app_id: str, db: AsyncSession = Depends(get_db)):
"""删除应用"""
result = await db.execute(
select(AIApp).where(AIApp.id == app_id, AIApp.is_deleted == False)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
app.is_deleted = True
await db.commit()
return ResponseModel(message="删除成功")
@router.post("/{app_id}/publish", response_model=AppResponse, summary="发布应用")
async def publish_app(app_id: str, db: AsyncSession = Depends(get_db)):
"""发布应用"""
result = await db.execute(
select(AIApp).where(AIApp.id == app_id, AIApp.is_deleted == False)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
app.status = "published"
await db.commit()
await db.refresh(app)
return await _build_app_response(app, db)
@router.post("/{app_id}/disable", response_model=AppResponse, summary="停用应用")
async def disable_app(app_id: str, db: AsyncSession = Depends(get_db)):
"""停用应用"""
result = await db.execute(
select(AIApp).where(AIApp.id == app_id, AIApp.is_deleted == False)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
app.status = "disabled"
await db.commit()
await db.refresh(app)
return await _build_app_response(app, db)
async def _build_app_response(app: AIApp, db: AsyncSession) -> dict:
"""构建应用输出"""
model_name = ""
if app.model_id:
model_result = await db.execute(
select(LLMModel).where(LLMModel.id == app.model_id)
)
model = model_result.scalar_one_or_none()
model_name = model.display_name if model else ""
return {
"id": app.id,
"name": app.name,
"code": app.code,
"description": app.description or "",
"icon": app.icon or "",
"app_type": app.app_type or "chat",
"status": app.status or "draft",
"model_id": app.model_id,
"model_name": model_name,
"system_prompt": app.system_prompt or "",
"temperature": app.temperature or 0.7,
"top_p": app.top_p or 1.0,
"max_tokens": app.max_tokens or 2048,
"opening_statement": app.opening_statement or "",
"suggested_questions": app.suggested_questions or [],
"workflow_definition": app.workflow_definition or {},
"is_public": app.is_public or False,
"conversation_count": app.conversation_count or 0,
"message_count": app.message_count or 0,
"sort": app.sort or 0,
"sys_create_datetime": app.sys_create_datetime,
"sys_update_datetime": app.sys_update_datetime,
}
-337
View File
@@ -1,337 +0,0 @@
"""
对话 API
"""
import json
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from utils.context import get_current_user_id_from_context
from ai_platform.models import Conversation, Message, AIApp
from ai_platform.schemas.chat_schema import (
ConversationCreate,
ConversationUpdate,
ConversationResponse,
ConversationListResponse,
MessageResponse,
SendMessageInput,
MessageFeedbackInput,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chat", tags=["AI-对话"])
@router.get("/conversations", response_model=PaginatedResponse[ConversationListResponse], summary="对话列表")
async def list_conversations(
app_id: str = Query(..., description="应用 ID"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取对话列表"""
query = select(Conversation).where(
Conversation.app_id == app_id,
Conversation.is_deleted == False
)
# 获取总数
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(
Conversation.is_pinned.desc(),
Conversation.sys_update_datetime.desc()
)
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
conversations = result.scalars().all()
items = [
{
"id": c.id,
"title": c.title or "",
"message_count": c.message_count or 0,
"is_pinned": c.is_pinned or False,
"sys_update_datetime": c.sys_update_datetime,
}
for c in conversations
]
return PaginatedResponse(items=items, total=total)
@router.post("/conversations", response_model=ConversationResponse, summary="创建对话")
async def create_conversation(data: ConversationCreate, db: AsyncSession = Depends(get_db)):
"""创建对话"""
# 验证应用
app_result = await db.execute(
select(AIApp).where(AIApp.id == data.app_id, AIApp.is_deleted == False)
)
app = app_result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=400, detail="应用不存在")
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未提供认证凭据")
conversation = Conversation(
app_id=data.app_id,
user_id=user_id,
title=data.title or "新对话",
)
db.add(conversation)
await db.commit()
await db.refresh(conversation)
return await _build_conversation_response(conversation, db)
@router.get("/conversations/{conversation_id}", response_model=ConversationResponse, summary="对话详情")
async def get_conversation(conversation_id: str, db: AsyncSession = Depends(get_db)):
"""获取对话详情"""
result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.is_deleted == False
)
)
conversation = result.scalar_one_or_none()
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
return await _build_conversation_response(conversation, db)
@router.put("/conversations/{conversation_id}", response_model=ConversationResponse, summary="更新对话")
async def update_conversation(
conversation_id: str,
data: ConversationUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新对话"""
result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.is_deleted == False
)
)
conversation = result.scalar_one_or_none()
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
if data.title is not None:
conversation.title = data.title
if data.is_pinned is not None:
conversation.is_pinned = data.is_pinned
await db.commit()
await db.refresh(conversation)
return await _build_conversation_response(conversation, db)
@router.delete("/conversations/{conversation_id}", response_model=ResponseModel, summary="删除对话")
async def delete_conversation(conversation_id: str, db: AsyncSession = Depends(get_db)):
"""删除对话"""
result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.is_deleted == False
)
)
conversation = result.scalar_one_or_none()
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
conversation.is_deleted = True
await db.commit()
return ResponseModel(message="删除成功")
@router.get("/conversations/{conversation_id}/messages", response_model=List[MessageResponse], summary="获取消息列表")
async def get_messages(
conversation_id: str,
limit: int = Query(50, ge=1, le=200, description="限制数量"),
db: AsyncSession = Depends(get_db),
):
"""获取对话消息"""
# 验证对话存在
conv_result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.is_deleted == False
)
)
if not conv_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="对话不存在")
query = select(Message).where(
Message.conversation_id == conversation_id,
Message.is_deleted == False
).order_by(Message.sys_create_datetime).limit(limit)
result = await db.execute(query)
messages = result.scalars().all()
return [
{
"id": m.id,
"role": m.role,
"content": m.content or "",
"status": m.status or "completed",
"prompt_tokens": m.prompt_tokens or 0,
"completion_tokens": m.completion_tokens or 0,
"total_tokens": m.total_tokens or 0,
"model_name": m.model_name or "",
"latency": m.latency or 0,
"error_message": m.error_message or "",
"feedback": m.feedback or "",
"sys_create_datetime": m.sys_create_datetime,
}
for m in messages
]
@router.post("/conversations/{conversation_id}/messages", response_model=MessageResponse, summary="发送消息")
async def send_message(
conversation_id: str,
data: SendMessageInput,
db: AsyncSession = Depends(get_db),
):
"""发送消息并获取 AI 回复"""
from ai_platform.services.chat_service import ChatService
# 验证对话存在
conv_result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.is_deleted == False
)
)
conversation = conv_result.scalar_one_or_none()
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
# 使用ChatService发送消息
chat_service = ChatService(db)
_, assistant_message = await chat_service.send_message(
conversation_id=conversation_id,
content=data.content,
)
return {
"id": assistant_message.id,
"role": assistant_message.role,
"content": assistant_message.content or "",
"status": assistant_message.status or "completed",
"prompt_tokens": assistant_message.prompt_tokens or 0,
"completion_tokens": assistant_message.completion_tokens or 0,
"total_tokens": assistant_message.total_tokens or 0,
"model_name": assistant_message.model_name or "",
"latency": assistant_message.latency or 0,
"error_message": assistant_message.error_message or "",
"feedback": assistant_message.feedback or "",
"sys_create_datetime": assistant_message.sys_create_datetime,
}
@router.post("/conversations/{conversation_id}/messages/stream", summary="流式发送消息")
async def send_message_stream(
conversation_id: str,
data: SendMessageInput,
db: AsyncSession = Depends(get_db),
):
"""发送消息并获取 AI 流式回复(SSE)"""
from ai_platform.services.chat_service import ChatService
# 验证对话存在
conv_result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.is_deleted == False
)
)
conversation = conv_result.scalar_one_or_none()
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
async def generate():
chat_service = ChatService(db)
async for event in chat_service.send_message_stream(
conversation_id=conversation_id,
content=data.content,
):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
@router.post("/messages/{message_id}/feedback", response_model=ResponseModel, summary="消息反馈")
async def message_feedback(
message_id: str,
data: MessageFeedbackInput,
db: AsyncSession = Depends(get_db),
):
"""对消息进行反馈"""
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.is_deleted == False
)
)
message = result.scalar_one_or_none()
if not message:
raise HTTPException(status_code=404, detail="消息不存在")
if data.feedback not in ("like", "dislike", ""):
raise HTTPException(status_code=400, detail="无效的反馈类型")
message.feedback = data.feedback
await db.commit()
return ResponseModel(message="反馈成功")
async def _build_conversation_response(conversation: Conversation, db: AsyncSession) -> dict:
"""构建对话输出"""
app_name = ""
if conversation.app_id:
app_result = await db.execute(
select(AIApp).where(AIApp.id == conversation.app_id)
)
app = app_result.scalar_one_or_none()
app_name = app.name if app else ""
return {
"id": conversation.id,
"app_id": conversation.app_id,
"app_name": app_name,
"title": conversation.title or "",
"message_count": conversation.message_count or 0,
"total_tokens": conversation.total_tokens or 0,
"is_pinned": conversation.is_pinned or False,
"sort": conversation.sort or 0,
"sys_create_datetime": conversation.sys_create_datetime,
"sys_update_datetime": conversation.sys_update_datetime,
}
@@ -1,96 +0,0 @@
"""
Lightweight form metadata API for AI workflow panels.
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_schema import PaginatedResponse
from app.database import get_db
from core.application.model import Application
from online_dev.form_manager.model import FormMeta
router = APIRouter(prefix="/forms", tags=["AI-Forms"])
def _build_form_list_out(form, application_name: str = "", application_code: str = "") -> dict:
return {
"id": str(form.id),
"application_id": form.application_id,
"application_name": application_name or "Main App",
"application_code": application_code or "",
"name": form.name,
"code": form.code,
"status": form.status,
}
@router.get("/list", summary="List form metadata for AI workflow form-data nodes")
async def list_forms(
application_id: Optional[str] = Query(None, alias="applicationId"),
name: Optional[str] = Query(None),
code: Optional[str] = Query(None),
status: Optional[str] = Query(None),
include_globally_visible: bool = Query(False, alias="includeGloballyVisible"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
conditions = [FormMeta.is_deleted.is_(False)]
if application_id:
app_condition = FormMeta.application_id == application_id
if include_globally_visible:
app_condition = or_(
app_condition,
and_(
FormMeta.globally_visible.is_(True),
FormMeta.status == "published",
),
)
conditions.append(app_condition)
elif not include_globally_visible:
conditions.append(FormMeta.application_id.is_(None))
else:
conditions.append(
or_(
FormMeta.application_id.is_(None),
and_(
FormMeta.globally_visible.is_(True),
FormMeta.status == "published",
),
)
)
if name:
conditions.append(FormMeta.name.ilike(f"%{name}%"))
if code:
conditions.append(FormMeta.code.ilike(f"%{code}%"))
if status:
conditions.append(FormMeta.status == status)
count_stmt = select(func.count(FormMeta.id)).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
offset = (page - 1) * page_size
stmt = (
select(
FormMeta,
Application.name.label("application_name"),
Application.code.label("application_code"),
)
.outerjoin(Application, FormMeta.application_id == Application.id)
.where(and_(*conditions))
.order_by(FormMeta.sort, FormMeta.sys_create_datetime.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
items = [
_build_form_list_out(form, application_name, application_code)
for form, application_name, application_code in result
]
return PaginatedResponse(items=items, total=total)
@@ -1,295 +0,0 @@
"""
LLM 模型 API
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from ai_platform.models import LLMModel, LLMProvider
from ai_platform.schemas.model_schema import (
ModelCreate,
ModelUpdate,
ModelResponse,
ModelListResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/model", tags=["AI-模型"])
@router.get("/list", response_model=PaginatedResponse[ModelListResponse], summary="模型列表")
async def list_models(
provider_id: Optional[str] = Query(None, description="提供商 ID"),
model_type: Optional[str] = Query(None, description="模型类型"),
is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取模型列表"""
query = select(LLMModel).where(LLMModel.is_deleted == False)
if provider_id:
query = query.where(LLMModel.provider_id == provider_id)
if model_type:
query = query.where(LLMModel.model_type == model_type)
if is_active is not None:
query = query.where(LLMModel.is_active == is_active)
# 获取总数
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
models = result.scalars().all()
# 获取提供商名称
items = []
for model in models:
provider_result = await db.execute(
select(LLMProvider).where(LLMProvider.id == model.provider_id)
)
provider = provider_result.scalar_one_or_none()
items.append({
"id": model.id,
"provider_id": model.provider_id,
"provider_name": provider.name if provider else "",
"model_name": model.model_name,
"display_name": model.display_name,
"model_type": model.model_type or "chat",
"is_active": model.is_active,
})
return PaginatedResponse(items=items, total=total)
@router.get("/active", response_model=List[ModelListResponse], summary="获取可用模型列表")
async def list_active_models(db: AsyncSession = Depends(get_db)):
"""获取所有可用的模型(用于选择器)"""
query = select(LLMModel).where(
LLMModel.is_deleted == False,
LLMModel.is_active == True
).order_by(LLMModel.display_name)
result = await db.execute(query)
models = result.scalars().all()
items = []
for model in models:
provider_result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == model.provider_id,
LLMProvider.is_deleted == False,
LLMProvider.is_active == True
)
)
provider = provider_result.scalar_one_or_none()
if provider:
items.append({
"id": model.id,
"provider_id": model.provider_id,
"provider_name": provider.name,
"model_name": model.model_name,
"display_name": model.display_name,
"model_type": model.model_type or "chat",
"is_active": model.is_active,
})
return items
@router.get("/{model_id}", response_model=ModelResponse, summary="模型详情")
async def get_model(model_id: str, db: AsyncSession = Depends(get_db)):
"""获取模型详情"""
result = await db.execute(
select(LLMModel).where(
LLMModel.id == model_id,
LLMModel.is_deleted == False
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
# 获取提供商名称
provider_result = await db.execute(
select(LLMProvider).where(LLMProvider.id == model.provider_id)
)
provider = provider_result.scalar_one_or_none()
return _build_model_response(model, provider)
@router.post("", response_model=ModelResponse, summary="创建模型")
async def create_model(data: ModelCreate, db: AsyncSession = Depends(get_db)):
"""创建模型"""
# 验证提供商
provider_result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == data.provider_id,
LLMProvider.is_deleted == False
)
)
provider = provider_result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=400, detail="提供商不存在")
# 检查模型名称是否重复
exists_result = await db.execute(
select(LLMModel).where(
LLMModel.provider_id == data.provider_id,
LLMModel.model_name == data.model_name,
LLMModel.is_deleted == False
)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"模型 {data.model_name} 已存在")
model = LLMModel(**data.model_dump())
db.add(model)
await db.commit()
await db.refresh(model)
return _build_model_response(model, provider)
@router.post("/batch", response_model=ResponseModel, summary="批量创建模型")
async def batch_create_models(
provider_id: str = Query(..., description="提供商 ID"),
models: List[dict] = Body(..., description="模型列表"),
db: AsyncSession = Depends(get_db),
):
"""批量创建模型(从默认模型列表)"""
provider_result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = provider_result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=400, detail="提供商不存在")
created_count = 0
for model_data in models:
model_name = model_data.get("model_name", "")
if not model_name:
continue
# 跳过已存在的
exists_result = await db.execute(
select(LLMModel).where(
LLMModel.provider_id == provider_id,
LLMModel.model_name == model_name,
LLMModel.is_deleted == False
)
)
if exists_result.scalar_one_or_none():
continue
model = LLMModel(
provider_id=provider_id,
model_name=model_name,
display_name=model_data.get("display_name", model_name),
model_type=model_data.get("model_type", "chat"),
max_tokens=model_data.get("max_tokens", 4096),
context_window=model_data.get("context_window", 4096),
supports_vision=model_data.get("supports_vision", False),
supports_function_call=model_data.get("supports_function_call", False),
input_price=model_data.get("input_price", 0),
output_price=model_data.get("output_price", 0),
is_active=True,
)
db.add(model)
created_count += 1
await db.commit()
return ResponseModel(message=f"成功创建 {created_count} 个模型")
@router.put("/{model_id}", response_model=ModelResponse, summary="更新模型")
async def update_model(
model_id: str,
data: ModelUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新模型"""
result = await db.execute(
select(LLMModel).where(
LLMModel.id == model_id,
LLMModel.is_deleted == False
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(model, key, value)
await db.commit()
await db.refresh(model)
# 获取提供商名称
provider_result = await db.execute(
select(LLMProvider).where(LLMProvider.id == model.provider_id)
)
provider = provider_result.scalar_one_or_none()
return _build_model_response(model, provider)
@router.delete("/{model_id}", response_model=ResponseModel, summary="删除模型")
async def delete_model(model_id: str, db: AsyncSession = Depends(get_db)):
"""删除模型"""
result = await db.execute(
select(LLMModel).where(
LLMModel.id == model_id,
LLMModel.is_deleted == False
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
model.is_deleted = True
await db.commit()
return ResponseModel(message="删除成功")
def _build_model_response(model: LLMModel, provider: Optional[LLMProvider] = None) -> dict:
"""构建模型输出"""
return {
"id": model.id,
"provider_id": model.provider_id,
"provider_name": provider.name if provider else "",
"model_name": model.model_name,
"display_name": model.display_name,
"model_type": model.model_type or "chat",
"max_tokens": model.max_tokens or 4096,
"context_window": model.context_window or 4096,
"default_temperature": model.default_temperature or 0.7,
"default_top_p": model.default_top_p or 1.0,
"input_price": model.input_price or 0,
"output_price": model.output_price or 0,
"supports_vision": model.supports_vision or False,
"supports_function_call": model.supports_function_call or False,
"supports_streaming": model.supports_streaming if model.supports_streaming is not None else True,
"is_active": model.is_active,
"sort": model.sort or 0,
"sys_create_datetime": model.sys_create_datetime,
"sys_update_datetime": model.sys_update_datetime,
}
@@ -1,377 +0,0 @@
"""
LLM 提供商 API
"""
import logging
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from ai_platform.models import LLMProvider
from ai_platform.schemas.provider_schema import (
ProviderCreate,
ProviderUpdate,
ProviderResponse,
ProviderListResponse,
ProviderTypeResponse,
)
from ai_platform.providers import ProviderRegistry
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/provider", tags=["AI-提供商"])
def _pick_message_from_payload(payload: Any) -> Optional[str]:
if isinstance(payload, dict):
for key in ('message', 'detail', 'error_description'):
value = payload.get(key)
if isinstance(value, str) and value:
return value
error = payload.get('error')
if isinstance(error, str) and error:
return error
if isinstance(error, dict):
return _pick_message_from_payload(error)
if isinstance(payload, list):
for item in payload:
message = _pick_message_from_payload(item)
if message:
return message
return None
def _extract_upstream_error(exc: Exception) -> str:
response = getattr(exc, 'response', None)
if response is not None:
status_code = getattr(response, 'status_code', None)
try:
message = _pick_message_from_payload(response.json())
except Exception:
message = None
if not message:
message = (getattr(response, 'text', '') or '').strip()
if status_code and message:
return f'HTTP {status_code}: {message[:240]}'
if status_code:
return f'HTTP {status_code}'
if message:
return message[:240]
return str(exc)[:240]
def _format_provider_test_error(exc: Exception, provider: LLMProvider, api_base: str) -> str:
error_text = f'{type(exc).__name__}: {str(exc)} {_extract_upstream_error(exc)}'
lower_text = error_text.lower()
if (
'401' in lower_text
or '403' in lower_text
or 'unauthorized' in lower_text
or 'forbidden' in lower_text
or 'invalid api key' in lower_text
or 'incorrect api key' in lower_text
or '无效的令牌' in error_text
or '鉴权' in error_text
):
reason = '上游模型鉴权失败,API Key 无效或已过期'
elif '404' in lower_text or 'not found' in lower_text:
reason = '模型接口不存在或 Base URL 路径不正确'
elif 'timeout' in lower_text or 'timed out' in lower_text or '超时' in error_text:
reason = '上游模型接口请求超时'
elif (
'connect' in lower_text
or 'network' in lower_text
or 'name or service not known' in lower_text
or 'connection' in lower_text
):
reason = '无法连接到上游模型接口'
elif '429' in lower_text or 'rate limit' in lower_text or 'quota' in lower_text:
reason = '上游模型限流或额度不足'
else:
reason = '上游模型接口调用失败'
upstream_error = _extract_upstream_error(exc) or ''
return (
f'{reason}:提供商 {provider.name},类型 {provider.provider_type}'
f'Base URL {api_base or "未配置"}'
f'请检查 Base URL、API Key 和上游账号权限。'
f'上游返回:{upstream_error}'
)
@router.get("/types", response_model=List[ProviderTypeResponse], summary="获取提供商类型列表")
async def get_provider_types():
"""获取所有支持的提供商类型"""
return ProviderRegistry.get_all_types()
@router.get("/list", response_model=PaginatedResponse[ProviderListResponse], summary="提供商列表")
async def list_providers(
name: Optional[str] = Query(None, description="名称"),
provider_type: Optional[str] = Query(None, description="类型"),
is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取提供商列表"""
query = select(LLMProvider).where(LLMProvider.is_deleted == False)
if name:
query = query.where(LLMProvider.name.ilike(f"%{name}%"))
if provider_type:
query = query.where(LLMProvider.provider_type == provider_type)
if is_active is not None:
query = query.where(LLMProvider.is_active == is_active)
# 获取总数
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(LLMProvider.sort.desc(), LLMProvider.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
items = result.scalars().all()
return PaginatedResponse(items=items, total=total)
@router.get("/{provider_id}", response_model=ProviderResponse, summary="提供商详情")
async def get_provider(provider_id: str, db: AsyncSession = Depends(get_db)):
"""获取提供商详情"""
result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="提供商不存在")
return _build_provider_response(provider)
@router.post("", response_model=ProviderResponse, summary="创建提供商")
async def create_provider(data: ProviderCreate, db: AsyncSession = Depends(get_db)):
"""创建提供商"""
provider = LLMProvider(**data.model_dump())
db.add(provider)
await db.commit()
await db.refresh(provider)
return _build_provider_response(provider)
@router.put("/{provider_id}", response_model=ProviderResponse, summary="更新提供商")
async def update_provider(
provider_id: str,
data: ProviderUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新提供商"""
result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="提供商不存在")
update_data = data.model_dump(exclude_unset=True)
if update_data.get("api_key") == "":
update_data.pop("api_key")
for key, value in update_data.items():
setattr(provider, key, value)
await db.commit()
await db.refresh(provider)
return _build_provider_response(provider)
@router.delete("/{provider_id}", response_model=ResponseModel, summary="删除提供商")
async def delete_provider(provider_id: str, db: AsyncSession = Depends(get_db)):
"""删除提供商"""
result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="提供商不存在")
provider.is_deleted = True
await db.commit()
return ResponseModel(message="删除成功")
@router.post("/{provider_id}/test", summary="测试提供商连接")
async def test_provider(provider_id: str, db: AsyncSession = Depends(get_db)):
"""通过真实模型接口测试提供商连接"""
result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="提供商不存在")
api_base = provider.api_base or ''
instance_kwargs = {}
if provider.provider_type == 'ollama' and provider.ollama_host:
api_base = provider.ollama_host
instance_kwargs['ollama_host'] = provider.ollama_host
provider_instance = ProviderRegistry.create_instance(
provider_type=provider.provider_type,
api_key=provider.api_key,
api_base=api_base,
**instance_kwargs,
)
if not provider_instance:
raise HTTPException(status_code=400, detail=f"不支持的提供商类型: {provider.provider_type}")
if not provider_instance.validate_config():
raise HTTPException(status_code=400, detail="配置无效,请检查 API Key")
try:
models = await provider_instance.fetch_models_from_api_strict()
except Exception as e:
logger.warning(
"Provider online test failed: upstream error "
f"[provider={provider.name}, type={provider.provider_type}, api_base={api_base or '(empty)'}]: {e}"
)
raise HTTPException(
status_code=400,
detail=_format_provider_test_error(e, provider, api_base),
)
if not models:
logger.warning(
"Provider online test failed: no models returned "
f"[provider={provider.name}, type={provider.provider_type}, api_base={api_base or '(empty)'}]"
)
raise HTTPException(
status_code=400,
detail=(
f"连接失败:模型接口返回为空。提供商 {provider.name}"
f"类型 {provider.provider_type}Base URL {api_base or '未配置'}"
"请检查 Base URL、API Key 和上游账号权限。"
),
)
return {
"success": True,
"message": f"连接成功,已从模型接口拉取到 {len(models)} 个模型",
"source": "api",
"model_count": len(models),
"models": models[:20],
}
@router.get("/{provider_id}/default-models", summary="获取默认模型列表")
async def get_default_models(provider_id: str, db: AsyncSession = Depends(get_db)):
"""获取提供商的默认模型列表"""
result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="提供商不存在")
return ProviderRegistry.get_default_models(provider.provider_type)
@router.get("/{provider_id}/fetch-models", summary="在线拉取提供商模型列表")
async def fetch_provider_models(provider_id: str, db: AsyncSession = Depends(get_db)):
"""
通过提供商 API 在线拉取最新模型列表。
如果在线拉取失败,自动 fallback 到硬编码的默认模型列表。
"""
result = await db.execute(
select(LLMProvider).where(
LLMProvider.id == provider_id,
LLMProvider.is_deleted == False
)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="提供商不存在")
# 构建 kwargsOllama 需要 ollama_host
kwargs = {}
api_base = provider.api_base or ''
if provider.provider_type == 'ollama' and provider.ollama_host:
api_base = provider.ollama_host
# 尝试在线拉取
try:
models = await ProviderRegistry.fetch_models_from_api(
provider_type=provider.provider_type,
api_key=provider.api_key or '',
api_base=api_base,
**kwargs,
)
except Exception as e:
logger.warning(
f'在线拉取模型列表异常 [provider={provider.name}, type={provider.provider_type}]: {e}'
)
models = []
source = 'api'
if not models:
logger.warning(
f'在线拉取模型列表为空,fallback 到默认列表 '
f'[provider={provider.name}, type={provider.provider_type}, api_base={api_base or "(empty)"}]'
)
models = ProviderRegistry.get_default_models(provider.provider_type)
source = 'default'
return {
"source": source,
"models": models,
}
def _build_provider_response(provider: LLMProvider) -> dict:
"""构建提供商输出"""
return {
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"api_key_masked": provider.get_api_key_masked(),
"api_base": provider.api_base or "",
"api_version": provider.api_version or "",
"ollama_host": provider.ollama_host or "",
"description": provider.description or "",
"is_active": provider.is_active,
"quota_limit": provider.quota_limit or 0,
"quota_used": provider.quota_used or 0,
"sort": provider.sort or 0,
"sys_create_datetime": provider.sys_create_datetime,
"sys_update_datetime": provider.sys_update_datetime,
}
@@ -1,86 +0,0 @@
"""
语音识别 API
"""
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
router = APIRouter(prefix="/speech", tags=["AI-语音"])
@router.post("/transcribe", summary="语音转文字")
async def transcribe(
audio: UploadFile = File(..., description="音频文件"),
language: str = Query("zh", description="语言"),
provider: str = Query("dashscope", description="提供商"),
db: AsyncSession = Depends(get_db),
):
"""
语音转文字(ASR
将音频文件转换为文字,支持:
- dashscope: 阿里云百炼(默认,推荐)
- openai: OpenAI Whisper
支持的音频格式:wav, mp3, webm, pcm, opus
"""
from ai_platform.services.speech_service import SpeechService
service = SpeechService(db=db)
await service._resolve_dashscope_api_key()
audio_content = await audio.read()
result = service.transcribe(
audio_file=audio_content,
language=language,
provider=provider,
)
if result["success"]:
return {
"text": result["text"],
"duration": result.get("duration", 0),
}
else:
raise HTTPException(status_code=400, detail=result["error"])
@router.post("/tts", summary="文字转语音")
async def text_to_speech(
text: str = Query(..., description="要转换的文字"),
voice: str = Query("sambert-zhichu-v1", description="声音"),
provider: str = Query("dashscope", description="提供商"),
db: AsyncSession = Depends(get_db),
):
"""
文字转语音(TTS
将文字转换为语音,返回音频文件
DashScope 可用声音:
- sambert-zhichu-v1: 知厨(男声)
- sambert-zhimiao-emo-v1: 知妙(女声,带情感)
- sambert-zhiying-v1: 知莺(女声)
OpenAI 可用声音:
- alloy, echo, fable, onyx, nova, shimmer
"""
from ai_platform.services.speech_service import SpeechService
service = SpeechService(db=db)
await service._resolve_dashscope_api_key()
result = service.text_to_speech(
text=text,
voice=voice,
provider=provider,
)
if result["success"]:
return Response(
content=result["audio_data"],
media_type=result["content_type"],
headers={"Content-Disposition": 'attachment; filename="speech.wav"'},
)
else:
raise HTTPException(status_code=400, detail=result["error"])
@@ -1,731 +0,0 @@
"""
AI 工作流 API
"""
import json
import logging
from typing import Any, List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from ai_platform.models import AIWorkflow, AIWorkflowVersion, AIWorkflowRun
from core.application.model import Application
from ai_platform.schemas.workflow_schema import (
WorkflowCreate,
WorkflowUpdate,
WorkflowResponse,
WorkflowListResponse,
WorkflowRunInput,
WorkflowRunResponse,
WorkflowRunListResponse,
WorkflowImportCheckIn,
WorkflowImportCheckOut,
WorkflowImportIn,
NodeSchemaResponse,
)
from ai_platform.services.workflow_import_export import (
WorkflowImportExportException,
export_config as export_workflow_config,
check_import as check_workflow_import,
import_config as import_workflow_config,
)
from ai_platform.nodes.registry import NodeRegistry
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/workflow", tags=["AI-工作流"])
# ============ 节点 Schema API ============
@router.get("/nodes/schemas", response_model=List[NodeSchemaResponse], summary="获取节点 Schema 列表")
async def get_node_schemas():
"""获取所有已注册节点的 Schema"""
return NodeRegistry.get_all_schemas()
@router.get("/nodes/schemas/by-category", summary="按分类获取节点 Schema")
async def get_node_schemas_by_category():
"""按分类获取所有已注册节点的 Schema"""
return NodeRegistry.get_schemas_by_category()
# ============ 工作流 API ============
@router.get("/list", response_model=PaginatedResponse[WorkflowListResponse], summary="工作流列表")
async def list_workflows(
name: Optional[str] = Query(None, description="名称"),
status: Optional[str] = Query(None, description="状态"),
workflow_type: Optional[str] = Query(None, description="工作流类型"),
application_id: Optional[str] = Query(None, alias="applicationId", description="所属应用ID"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=1000, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取工作流列表(自动应用数据权限)"""
from ai_platform.services.workflow_service import AIWorkflowService
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
conditions = [AIWorkflow.is_deleted == False]
if application_id:
conditions.append(or_(
AIWorkflow.application_id == application_id,
and_(AIWorkflow.application_id.is_(None), AIWorkflow.is_global == True)
))
if name:
conditions.append(AIWorkflow.name.ilike(f"%{name}%"))
if status:
conditions.append(AIWorkflow.status == status)
if workflow_type:
conditions.append(AIWorkflow.workflow_type == workflow_type)
# 获取数据权限过滤条件并应用
from ai_platform.services.workflow_service import RESOURCE_TYPE
data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE)
scope_conditions = apply_data_scope_to_conditions(AIWorkflow, data_scope_filter)
conditions.extend(scope_conditions)
# 获取总数
query = select(AIWorkflow).where(and_(*conditions))
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(AIWorkflow.sort.desc(), AIWorkflow.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
workflows = result.scalars().all()
# 批量查询应用名称
app_ids = list({w.application_id for w in workflows if w.application_id})
app_name_map = {}
if app_ids:
app_result = await db.execute(
select(Application.id, Application.name).where(Application.id.in_(app_ids))
)
app_name_map = {row.id: row.name for row in app_result}
items = [_build_workflow_list_response(w, app_name_map.get(w.application_id, "")) for w in workflows]
return PaginatedResponse(items=items, total=total)
# 全局运行记录路由须注册在 /{workflow_id} 之前,避免 "runs" 被当作 workflow_id
@router.get("/runs", response_model=PaginatedResponse[WorkflowRunListResponse], summary="全局工作流运行记录")
async def list_all_workflow_runs(
workflow_id: Optional[str] = Query(None, alias="workflowId", description="工作流ID"),
status: Optional[str] = Query(None, description="运行状态"),
trigger_type: Optional[str] = Query(None, alias="triggerType", description="触发来源"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取全局工作流运行记录(可按工作流、状态、触发来源筛选)"""
conditions = [AIWorkflowRun.is_deleted == False]
if workflow_id:
conditions.append(AIWorkflowRun.workflow_id == workflow_id)
if status:
conditions.append(AIWorkflowRun.status == status)
if trigger_type:
conditions.append(AIWorkflowRun.trigger_type == trigger_type)
query = select(AIWorkflowRun).where(*conditions).order_by(
AIWorkflowRun.sys_create_datetime.desc()
)
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
runs = result.scalars().all()
workflow_ids = {r.workflow_id for r in runs if r.workflow_id}
workflow_name_map: dict[str, str] = {}
if workflow_ids:
wf_result = await db.execute(
select(AIWorkflow.id, AIWorkflow.name).where(AIWorkflow.id.in_(workflow_ids))
)
workflow_name_map = {row[0]: row[1] for row in wf_result.all()}
items = [
_build_run_list_item(r, workflow_name_map.get(r.workflow_id, ""))
for r in runs
]
return PaginatedResponse(items=items, total=total)
@router.get("/runs/{run_id}", response_model=WorkflowRunResponse, summary="运行记录详情")
async def get_workflow_run(run_id: str, db: AsyncSession = Depends(get_db)):
"""获取运行记录详情"""
result = await db.execute(
select(AIWorkflowRun).where(AIWorkflowRun.id == run_id, AIWorkflowRun.is_deleted == False)
)
run = result.scalar_one_or_none()
if not run:
raise HTTPException(status_code=404, detail="运行记录不存在")
workflow_result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == run.workflow_id)
)
workflow = workflow_result.scalar_one_or_none()
workflow_name = workflow.name if workflow else ""
return _build_run_detail(run, workflow_name)
@router.post("/runs/{run_id}/stop", response_model=ResponseModel, summary="停止运行")
async def stop_workflow_run(run_id: str, db: AsyncSession = Depends(get_db)):
"""停止工作流运行"""
result = await db.execute(
select(AIWorkflowRun).where(AIWorkflowRun.id == run_id, AIWorkflowRun.is_deleted == False)
)
run = result.scalar_one_or_none()
if not run:
raise HTTPException(status_code=404, detail="运行记录不存在")
if run.status not in ("pending", "running"):
raise HTTPException(status_code=400, detail="工作流已完成,无法停止")
run.status = "stopped"
await db.commit()
return ResponseModel(message="已停止")
class ResumeWorkflowInput(BaseModel):
"""恢复工作流输入"""
user_input: Any = Field(..., description="用户输入(可以是字符串、布尔值、对象等)")
@router.post("/runs/{run_id}/resume", summary="恢复工作流运行")
async def resume_workflow_run(
run_id: str,
data: ResumeWorkflowInput,
db: AsyncSession = Depends(get_db),
):
"""恢复等待中的工作流运行(SSE"""
import json
from ai_platform.services.workflow_service import AIWorkflowService
async def generate():
workflow_service = AIWorkflowService(db)
async for event in workflow_service.resume_workflow_stream_async(
run_id=run_id,
user_input=data.user_input,
):
yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
@router.post("/runs/{run_id}/resume/stream", summary="流式恢复工作流运行")
async def resume_workflow_run_stream(
run_id: str,
data: ResumeWorkflowInput,
db: AsyncSession = Depends(get_db),
):
"""流式恢复等待中的工作流运行(SSE"""
import json
from ai_platform.services.workflow_service import AIWorkflowService
async def generate():
workflow_service = AIWorkflowService(db)
async for event in workflow_service.resume_workflow_stream_async(
run_id=run_id,
user_input=data.user_input,
):
yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
@router.get("/code/{code}", response_model=WorkflowResponse, summary="根据编码获取工作流")
async def get_workflow_by_code(code: str, db: AsyncSession = Depends(get_db)):
"""根据编码获取工作流"""
result = await db.execute(
select(AIWorkflow).where(AIWorkflow.code == code, AIWorkflow.is_deleted == False)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="工作流不存在")
return _build_workflow_response(workflow)
@router.get("/{workflow_id}", response_model=WorkflowResponse, summary="工作流详情")
async def get_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)):
"""获取工作流详情"""
result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="工作流不存在")
return _build_workflow_response(workflow)
@router.post("", response_model=WorkflowResponse, summary="创建工作流")
async def create_workflow(data: WorkflowCreate, db: AsyncSession = Depends(get_db)):
"""创建工作流"""
# 检查编码是否重复
exists_result = await db.execute(
select(AIWorkflow).where(AIWorkflow.code == data.code, AIWorkflow.is_deleted == False)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"工作流编码 {data.code} 已存在")
workflow = AIWorkflow(**data.model_dump())
db.add(workflow)
await db.commit()
await db.refresh(workflow)
return _build_workflow_response(workflow)
@router.put("/{workflow_id}", response_model=WorkflowResponse, summary="更新工作流")
async def update_workflow(
workflow_id: str,
data: WorkflowUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新工作流"""
result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="工作流不存在")
# 如果更新了 code,检查编码是否重复(排除自身)
if data.code and data.code != workflow.code:
exists_result = await db.execute(
select(AIWorkflow).where(
AIWorkflow.code == data.code,
AIWorkflow.id != workflow_id,
AIWorkflow.is_deleted == False
)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"工作流编码 {data.code} 已存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(workflow, key, value)
# 更新草稿版本号
workflow.version = (workflow.version or 0) + 1
await db.commit()
await db.refresh(workflow)
return _build_workflow_response(workflow)
@router.delete("/{workflow_id}", response_model=ResponseModel, summary="删除工作流")
async def delete_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)):
"""删除工作流"""
result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="工作流不存在")
workflow.is_deleted = True
await db.commit()
return ResponseModel(message="删除成功")
@router.post("/{workflow_id}/copy", response_model=WorkflowResponse, summary="复制工作流")
async def copy_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)):
"""复制工作流"""
# 获取原工作流
result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="工作流不存在")
# 生成新的编码(原编码 + _copy + 时间戳)
import time
timestamp = int(time.time() * 1000)
new_code = f"{workflow.code}_copy_{timestamp}"
# 检查编码是否重复(理论上不会,但保险起见)
exists_result = await db.execute(
select(AIWorkflow).where(AIWorkflow.code == new_code, AIWorkflow.is_deleted == False)
)
if exists_result.scalar_one_or_none():
new_code = f"{workflow.code}_copy_{timestamp}_{int(time.time())}"
# 创建新工作流
new_workflow = AIWorkflow(
name=f"{workflow.name} (副本)",
code=new_code,
description=workflow.description,
workflow_type=workflow.workflow_type,
definition=workflow.definition, # 复制工作流定义
input_variables=workflow.input_variables,
output_variables=workflow.output_variables,
status="draft", # 新工作流默认为草稿状态
version=1,
published_version=None,
published_at=None,
published_definition=None,
)
db.add(new_workflow)
await db.commit()
await db.refresh(new_workflow)
return _build_workflow_response(new_workflow)
@router.get("/{workflow_id}/export", summary="导出工作流配置")
async def export_workflow(
workflow_id: str,
db: AsyncSession = Depends(get_db),
):
"""导出工作流配置为 JSON(草稿 definition"""
try:
config = await export_workflow_config(db, workflow_id)
content = json.dumps(config, ensure_ascii=False, indent=2)
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="{config["code"]}.json"'
},
)
except WorkflowImportExportException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/import/check", response_model=WorkflowImportCheckOut, summary="导入预检查")
async def check_import_workflow(
data: WorkflowImportCheckIn,
db: AsyncSession = Depends(get_db),
):
"""导入预检查:检查工作流编码是否冲突"""
try:
return await check_workflow_import(db, data.code)
except WorkflowImportExportException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/import", response_model=WorkflowResponse, summary="导入工作流配置")
async def import_workflow(
data: WorkflowImportIn,
db: AsyncSession = Depends(get_db),
):
"""导入工作流配置"""
try:
workflow = await import_workflow_config(db, data.model_dump())
await db.commit()
await db.refresh(workflow)
return _build_workflow_response(workflow)
except WorkflowImportExportException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{workflow_id}/publish", response_model=WorkflowResponse, summary="发布工作流")
async def publish_workflow(
workflow_id: str,
description: str = Query("", description="版本说明"),
db: AsyncSession = Depends(get_db),
):
"""发布工作流"""
result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="工作流不存在")
# 创建版本记录
new_version = (workflow.published_version or 0) + 1
version = AIWorkflowVersion(
workflow_id=workflow_id,
version=new_version,
definition=workflow.definition or {},
description=description,
published_at=datetime.now(),
)
db.add(version)
# 更新工作流
workflow.status = "published"
workflow.published_version = new_version
workflow.published_at = datetime.now()
workflow.published_definition = workflow.definition
await db.commit()
await db.refresh(workflow)
return _build_workflow_response(workflow)
@router.get("/{workflow_id}/versions", summary="获取版本历史")
async def list_workflow_versions(
workflow_id: str,
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取工作流版本历史"""
query = select(AIWorkflowVersion).where(
AIWorkflowVersion.workflow_id == workflow_id,
AIWorkflowVersion.is_deleted == False
).order_by(AIWorkflowVersion.version.desc())
# 获取总数
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
versions = result.scalars().all()
items = [
{
"id": v.id,
"version": v.version,
"description": v.description or "",
"published_at": v.published_at,
"run_count": v.run_count or 0,
"success_count": v.success_count or 0,
}
for v in versions
]
return PaginatedResponse(items=items, total=total)
@router.get("/{workflow_id}/runs", response_model=PaginatedResponse[WorkflowRunListResponse], summary="工作流运行记录")
async def list_workflow_runs(
workflow_id: str,
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取工作流运行记录"""
query = select(AIWorkflowRun).where(
AIWorkflowRun.workflow_id == workflow_id,
AIWorkflowRun.is_deleted == False
).order_by(AIWorkflowRun.sys_create_datetime.desc())
# 获取总数
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
runs = result.scalars().all()
# 获取工作流名称
workflow_result = await db.execute(
select(AIWorkflow).where(AIWorkflow.id == workflow_id)
)
workflow = workflow_result.scalar_one_or_none()
workflow_name = workflow.name if workflow else ""
items = [
_build_run_list_item(r, workflow_name)
for r in runs
]
return PaginatedResponse(items=items, total=total)
def _build_run_list_item(run: AIWorkflowRun, workflow_name: str = "") -> dict:
execution_log = run.execution_log or []
last_log = next(
(log for log in reversed(execution_log) if isinstance(log, dict)),
{},
)
return {
"id": run.id,
"workflow_id": run.workflow_id,
"workflow_name": workflow_name,
"status": run.status or "pending",
"trigger_type": run.trigger_type or "api",
"inputs": run.inputs or {},
"outputs": run.outputs or {},
"current_node_id": run.current_node_id or "",
"last_node_id": last_log.get("node_id", ""),
"last_node_label": last_log.get("node_label", ""),
"last_node_type": last_log.get("node_type", ""),
"total_steps": run.total_steps or 0,
"total_tokens": run.total_tokens or 0,
"elapsed_time": run.elapsed_time or 0,
"error_message": (run.error_message or "")[:200],
"started_at": run.started_at,
"completed_at": run.completed_at,
}
def _build_run_detail(run: AIWorkflowRun, workflow_name: str = "") -> dict:
return {
"id": run.id,
"workflow_id": run.workflow_id,
"workflow_name": workflow_name,
"status": run.status or "pending",
"trigger_type": run.trigger_type or "api",
"use_draft": bool(run.use_draft),
"workflow_version": run.workflow_version,
"definition_snapshot": run.definition_snapshot or {},
"inputs": run.inputs or {},
"outputs": run.outputs or {},
"execution_log": run.execution_log or [],
"current_node_id": run.current_node_id or "",
"waiting_config": run.waiting_config or {},
"error_message": run.error_message or "",
"total_tokens": run.total_tokens or 0,
"total_steps": run.total_steps or 0,
"elapsed_time": run.elapsed_time or 0,
"started_at": run.started_at,
"completed_at": run.completed_at,
"sys_create_datetime": run.sys_create_datetime,
}
class WorkflowRunInput(BaseModel):
"""工作流运行输入"""
inputs: dict = Field(default_factory=dict, description="输入变量")
use_draft: bool = Field(default=False, description="是否使用草稿版本")
@router.post("/{workflow_id}/run", summary="运行工作流")
async def run_workflow(
workflow_id: str,
data: WorkflowRunInput,
db: AsyncSession = Depends(get_db),
):
"""运行工作流(非流式)"""
from ai_platform.services.workflow_service import AIWorkflowService
workflow_service = AIWorkflowService(db)
run = await workflow_service.run_workflow(
workflow_id=workflow_id,
inputs=data.inputs,
use_draft=data.use_draft,
trigger_type='api',
)
return {
"id": run.id,
"workflow_id": run.workflow_id,
"status": run.status,
"outputs": run.outputs or {},
"execution_log": run.execution_log or [],
"total_tokens": run.total_tokens or 0,
"total_steps": run.total_steps or 0,
"elapsed_time": run.elapsed_time or 0,
}
@router.post("/{workflow_id}/run/stream", summary="流式运行工作流")
async def run_workflow_stream(
workflow_id: str,
data: WorkflowRunInput,
db: AsyncSession = Depends(get_db),
):
"""流式运行工作流(SSE"""
import json
from ai_platform.services.workflow_service import AIWorkflowService
async def generate():
workflow_service = AIWorkflowService(db)
trigger_type = 'editor_draft' if data.use_draft else 'editor_published'
async for event in workflow_service.run_workflow_stream_async(
workflow_id=workflow_id,
inputs=data.inputs,
use_draft=data.use_draft,
trigger_type=trigger_type,
):
yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
def _build_workflow_response(workflow: AIWorkflow) -> dict:
"""构建工作流输出"""
return {
"id": workflow.id,
"name": workflow.name,
"code": workflow.code,
"workflow_type": workflow.workflow_type or "general",
"description": workflow.description or "",
"status": workflow.status or "draft",
"version": workflow.version or 1,
"published_version": workflow.published_version,
"published_at": workflow.published_at,
"published_definition": workflow.published_definition,
"definition": workflow.definition or {},
"input_variables": workflow.input_variables or [],
"output_variables": workflow.output_variables or [],
"run_count": workflow.run_count or 0,
"success_count": workflow.success_count or 0,
"sort": workflow.sort or 0,
"sys_create_datetime": workflow.sys_create_datetime,
"sys_update_datetime": workflow.sys_update_datetime,
}
def _build_workflow_list_response(workflow: AIWorkflow, application_name: str = "") -> dict:
"""构建工作流列表输出"""
return {
"id": workflow.id,
"application_id": workflow.application_id,
"application_name": application_name,
"is_global": workflow.is_global or False,
"name": workflow.name,
"code": workflow.code,
"workflow_type": workflow.workflow_type or "general",
"description": workflow.description or "",
"status": workflow.status or "draft",
"version": workflow.version or 1,
"run_count": workflow.run_count or 0,
"success_count": workflow.success_count or 0,
"sys_create_datetime": workflow.sys_create_datetime,
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +0,0 @@
"""
AI 知识库模块
"""
File diff suppressed because it is too large Load Diff
@@ -1,45 +0,0 @@
"""
文档分块策略
"""
from .base import BaseChunker, ChunkResult
from .recursive import RecursiveChunker
from .markdown import MarkdownChunker
from .fixed import FixedChunker
from .qa_chunker import QAChunker
from .sentence import SentenceChunker
from .auto import AutoChunker
__all__ = [
'BaseChunker',
'ChunkResult',
'RecursiveChunker',
'MarkdownChunker',
'FixedChunker',
'QAChunker',
'SentenceChunker',
'AutoChunker',
'get_chunker',
]
def get_chunker(strategy: str, chunk_size: int = 500, chunk_overlap: int = 50, separator: str = None, **kwargs) -> BaseChunker:
"""
根据策略名称获取分块器实例
Args:
strategy: 分块策略名称(recursive/markdown/fixed/qa/sentence/auto
chunk_size: 分块大小
chunk_overlap: 分块重叠
separator: 自定义分隔符
**kwargs: 额外参数(如 QAChunker 的 llm_caller
"""
chunkers = {
'recursive': RecursiveChunker,
'markdown': MarkdownChunker,
'fixed': FixedChunker,
'qa': QAChunker,
'sentence': SentenceChunker,
'auto': AutoChunker,
}
chunker_cls = chunkers.get(strategy, RecursiveChunker)
return chunker_cls(chunk_size=chunk_size, chunk_overlap=chunk_overlap, separator=separator, **kwargs)
@@ -1,90 +0,0 @@
"""
自动分块策略
根据文件类型自动选择最佳分块器。
参考 Dify 的 auto 分块模式。
"""
import logging
from typing import Dict, Any, List, Optional
from .base import BaseChunker, ChunkResult
logger = logging.getLogger(__name__)
# 文件类型 → 推荐分块策略
FILE_TYPE_STRATEGY_MAP = {
# Markdown 文件使用 Markdown 分块器
'md': 'markdown',
'markdown': 'markdown',
# 代码文件使用按句子分块(按行/语句边界)
'py': 'sentence',
'js': 'sentence',
'ts': 'sentence',
'java': 'sentence',
'go': 'sentence',
'rs': 'sentence',
'c': 'sentence',
'cpp': 'sentence',
'h': 'sentence',
# 纯文本使用按句子分块
'txt': 'sentence',
# CSV/Excel 使用固定大小(表格数据按行分割更合理)
'csv': 'fixed',
'xlsx': 'fixed',
'xls': 'fixed',
# HTML 使用 Markdown 分块器(HTML 结构类似)
'html': 'markdown',
'htm': 'markdown',
# 其他文档类型使用递归分块
'pdf': 'recursive',
'docx': 'recursive',
'doc': 'recursive',
'pptx': 'recursive',
'ppt': 'recursive',
}
class AutoChunker(BaseChunker):
"""
自动分块器
根据文档的文件类型自动选择最佳分块策略。
metadata 中需要包含 'file_type' 字段。
"""
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""自动选择分块策略并执行"""
if not text or not text.strip():
return []
metadata = metadata or {}
file_type = metadata.get('file_type', '').lower().lstrip('.')
# 根据文件类型选择策略
strategy = FILE_TYPE_STRATEGY_MAP.get(file_type, 'recursive')
logger.info(f"AutoChunker: file_type={file_type} → strategy={strategy}")
# 动态创建对应的分块器
chunker = self._get_chunker(strategy)
return chunker.chunk(text, metadata)
def _get_chunker(self, strategy: str) -> BaseChunker:
"""获取对应策略的分块器实例"""
from .recursive import RecursiveChunker
from .markdown import MarkdownChunker
from .fixed import FixedChunker
from .sentence import SentenceChunker
chunkers = {
'recursive': RecursiveChunker,
'markdown': MarkdownChunker,
'fixed': FixedChunker,
'sentence': SentenceChunker,
}
cls = chunkers.get(strategy, RecursiveChunker)
return cls(
chunk_size=self.chunk_size,
chunk_overlap=self.chunk_overlap,
separator=self.separator,
)
@@ -1,92 +0,0 @@
"""
分块策略基类
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class ChunkResult:
"""分块结果"""
content: str
position: int = 0
char_count: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not self.char_count:
self.char_count = len(self.content)
class BaseChunker(ABC):
"""
分块策略基类
所有分块策略必须继承此类并实现 chunk 方法
"""
def __init__(
self,
chunk_size: int = 500,
chunk_overlap: int = 50,
separator: Optional[str] = None,
):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.separator = separator
@abstractmethod
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""
将文本分块
Args:
text: 原始文本
metadata: 文档元数据
Returns:
分块结果列表
"""
pass
def _clean_text(self, text: str) -> str:
"""清理文本:去除多余空白"""
import re
# 合并连续空行为单个空行
text = re.sub(r'\n{3,}', '\n\n', text)
# 去除行尾空白
text = '\n'.join(line.rstrip() for line in text.split('\n'))
return text.strip()
def _merge_small_chunks(self, chunks: List[str], min_size: int = 50) -> List[str]:
"""合并过小的分块"""
if not chunks:
return []
merged = []
buffer = ""
for chunk in chunks:
if not chunk.strip():
continue
if buffer and len(buffer) + len(chunk) <= self.chunk_size:
buffer = buffer + "\n" + chunk
elif buffer and len(buffer) < min_size:
buffer = buffer + "\n" + chunk
else:
if buffer:
merged.append(buffer)
buffer = chunk
if buffer:
# 最后一个 buffer 如果太小,合并到前一个
if merged and len(buffer) < min_size:
merged[-1] = merged[-1] + "\n" + buffer
else:
merged.append(buffer)
return merged
@@ -1,57 +0,0 @@
"""
固定大小分块策略
按固定字符数分割文本,最简单的分块方式
"""
import logging
from typing import Dict, Any, List, Optional
from .base import BaseChunker, ChunkResult
logger = logging.getLogger(__name__)
class FixedChunker(BaseChunker):
"""
固定大小分块器
按固定字符数分割文本,相邻分块之间有 overlap 重叠
"""
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""固定大小分块"""
if not text or not text.strip():
return []
text = self._clean_text(text)
metadata = metadata or {}
if len(text) <= self.chunk_size:
return [ChunkResult(
content=text,
position=0,
metadata={**metadata},
)]
chunks = []
start = 0
position = 0
step = self.chunk_size - self.chunk_overlap
while start < len(text):
end = min(start + self.chunk_size, len(text))
chunk_text = text[start:end].strip()
if chunk_text:
chunks.append(ChunkResult(
content=chunk_text,
position=position,
metadata={**metadata},
))
position += 1
start += step
if step <= 0:
break
return chunks
@@ -1,172 +0,0 @@
"""
Markdown 结构化分块策略
按 Markdown 标题层级分割文档,保留文档结构信息
"""
import re
import logging
from typing import Dict, Any, List, Optional
from .base import BaseChunker, ChunkResult
logger = logging.getLogger(__name__)
class MarkdownChunker(BaseChunker):
"""
Markdown 分块器
按标题层级分割 Markdown 文档,每个标题下的内容作为一个分块
如果单个标题下的内容超过 chunk_size,则使用递归分割
"""
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""按 Markdown 标题分块"""
if not text or not text.strip():
return []
text = self._clean_text(text)
metadata = metadata or {}
# 按标题分割
sections = self._split_by_headers(text)
# 处理每个 section
raw_chunks = []
for section in sections:
header = section.get('header', '')
content = section.get('content', '')
level = section.get('level', 0)
if not content.strip():
continue
# 组合标题和内容
full_text = f"{header}\n{content}" if header else content
if len(full_text) <= self.chunk_size:
raw_chunks.append({
'content': full_text.strip(),
'metadata': {
**metadata,
'header': header,
'header_level': level,
}
})
else:
# 内容超长,递归分割
sub_chunks = self._split_long_section(content, header)
for i, sub in enumerate(sub_chunks):
raw_chunks.append({
'content': sub.strip(),
'metadata': {
**metadata,
'header': header,
'header_level': level,
'sub_chunk': i,
}
})
# 合并过小的分块
merged = self._merge_small_section_chunks(raw_chunks)
# 构建结果
results = []
for i, item in enumerate(merged):
if item['content'].strip():
results.append(ChunkResult(
content=item['content'],
position=i,
metadata=item.get('metadata', {}),
))
return results
def _split_by_headers(self, text: str) -> List[Dict[str, Any]]:
"""按 Markdown 标题分割"""
# 匹配 Markdown 标题: # Title, ## Title, ### Title 等
header_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
sections = []
last_end = 0
last_header = ''
last_level = 0
for match in header_pattern.finditer(text):
# 保存前一个 section 的内容
if last_end > 0 or match.start() > 0:
content = text[last_end:match.start()]
if content.strip() or last_header:
sections.append({
'header': last_header,
'content': content.strip(),
'level': last_level,
})
last_header = match.group(0)
last_level = len(match.group(1))
last_end = match.end()
# 最后一个 section
remaining = text[last_end:]
if remaining.strip() or last_header:
sections.append({
'header': last_header,
'content': remaining.strip(),
'level': last_level,
})
# 如果没有找到任何标题,整个文本作为一个 section
if not sections:
sections.append({
'header': '',
'content': text.strip(),
'level': 0,
})
return sections
def _split_long_section(self, content: str, header: str = '') -> List[str]:
"""分割超长的 section 内容"""
from .recursive import RecursiveChunker
chunker = RecursiveChunker(
chunk_size=self.chunk_size,
chunk_overlap=self.chunk_overlap,
)
results = chunker.chunk(content)
chunks = []
for i, result in enumerate(results):
# 第一个分块带上标题
if i == 0 and header:
chunks.append(f"{header}\n{result.content}")
else:
chunks.append(result.content)
return chunks if chunks else [content]
def _merge_small_section_chunks(self, chunks: List[Dict], min_size: int = 80) -> List[Dict]:
"""合并过小的 section 分块"""
if not chunks:
return []
merged = []
buffer = None
for chunk in chunks:
if buffer is None:
buffer = chunk
elif len(buffer['content']) < min_size and len(buffer['content']) + len(chunk['content']) <= self.chunk_size:
buffer['content'] = buffer['content'] + "\n\n" + chunk['content']
else:
merged.append(buffer)
buffer = chunk
if buffer:
if merged and len(buffer['content']) < min_size:
merged[-1]['content'] = merged[-1]['content'] + "\n\n" + buffer['content']
else:
merged.append(buffer)
return merged
@@ -1,197 +0,0 @@
"""
Q&A 自动拆分分块策略
使用 LLM 将文档内容自动拆分为问答对。
每个分段的 content 存储 questionmetadata 中存储 answer。
检索时用 question 做向量匹配,返回 answer 作为上下文。
"""
import json
import logging
from typing import Dict, Any, List, Optional
from .base import BaseChunker, ChunkResult
logger = logging.getLogger(__name__)
# Q&A 拆分的系统提示词
QA_SYSTEM_PROMPT = """你是一个专业的知识库问答对生成助手。请根据给定的文本内容,生成高质量的问答对(Q&A pairs)。
要求:
1. 问题应该是用户可能会问的自然语言问题
2. 答案应该准确、完整,直接来源于原文
3. 每个问答对应该覆盖文本中的一个独立知识点
4. 问题要具体明确,避免过于宽泛
5. 答案要简洁但完整,包含必要的上下文
请以 JSON 数组格式输出,每个元素包含 question 和 answer 字段:
```json
[
{"question": "问题1", "answer": "答案1"},
{"question": "问题2", "answer": "答案2"}
]
```
只输出 JSON 数组,不要输出其他内容。"""
class QAChunker(BaseChunker):
"""
Q&A 自动拆分分块器
使用 LLM 将文本拆分为问答对。
需要在初始化时传入 LLM 调用函数。
"""
def __init__(
self,
chunk_size: int = 500,
chunk_overlap: int = 50,
separator: Optional[str] = None,
llm_caller: Optional[Any] = None,
):
super().__init__(chunk_size, chunk_overlap, separator)
self._llm_caller = llm_caller
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""
同步分块(Q&A 模式不支持同步调用,返回空列表)
请使用 chunk_async 方法。
"""
logger.warning("QAChunker.chunk() 不支持同步调用,请使用 chunk_async()")
return []
async def chunk_async(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""
异步分块:使用 LLM 将文本拆分为 Q&A 对
Args:
text: 原始文本
metadata: 文档元数据
Returns:
分块结果列表,每个 ChunkResult 的 content 为 question
metadata 中包含 answer 和 chunk_mode='qa'
"""
if not text or not text.strip():
return []
metadata = metadata or {}
text = self._clean_text(text)
# 如果文本太长,先按段落粗分再逐段生成 Q&A
max_input_size = self.chunk_size * 8 # LLM 输入上限
if len(text) > max_input_size:
segments = self._split_for_qa(text, max_input_size)
else:
segments = [text]
all_results = []
position = 0
for segment in segments:
qa_pairs = await self._generate_qa_pairs(segment)
for qa in qa_pairs:
question = qa.get('question', '').strip()
answer = qa.get('answer', '').strip()
if not question or not answer:
continue
all_results.append(ChunkResult(
content=question,
position=position,
metadata={
**metadata,
'answer': answer,
'chunk_mode': 'qa',
},
))
position += 1
logger.info(f"Q&A 拆分完成: {len(all_results)} 个问答对")
return all_results
async def _generate_qa_pairs(self, text: str) -> List[Dict[str, str]]:
"""调用 LLM 生成 Q&A 对"""
if not self._llm_caller:
logger.error("QAChunker: 未配置 LLM 调用函数")
return []
try:
user_prompt = f"请根据以下文本生成问答对:\n\n{text}"
response_text = await self._llm_caller(
system_prompt=QA_SYSTEM_PROMPT,
user_prompt=user_prompt,
)
if not response_text:
return []
# 解析 JSON 响应
return self._parse_qa_response(response_text)
except Exception as e:
logger.error(f"Q&A 生成失败: {e}")
return []
@staticmethod
def _parse_qa_response(response_text: str) -> List[Dict[str, str]]:
"""解析 LLM 返回的 Q&A JSON"""
try:
# 尝试直接解析
result = json.loads(response_text)
if isinstance(result, list):
return [
item for item in result
if isinstance(item, dict) and 'question' in item and 'answer' in item
]
except json.JSONDecodeError:
pass
# 尝试提取 JSON 代码块
import re
json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', response_text, re.DOTALL)
if json_match:
try:
result = json.loads(json_match.group(1))
if isinstance(result, list):
return [
item for item in result
if isinstance(item, dict) and 'question' in item and 'answer' in item
]
except json.JSONDecodeError:
pass
# 尝试找到 [ ... ] 部分
bracket_match = re.search(r'\[.*\]', response_text, re.DOTALL)
if bracket_match:
try:
result = json.loads(bracket_match.group(0))
if isinstance(result, list):
return [
item for item in result
if isinstance(item, dict) and 'question' in item and 'answer' in item
]
except json.JSONDecodeError:
pass
logger.warning(f"无法解析 Q&A 响应: {response_text[:200]}")
return []
def _split_for_qa(self, text: str, max_size: int) -> List[str]:
"""将长文本按段落分割为适合 LLM 处理的片段"""
paragraphs = text.split('\n\n')
segments = []
current = ""
for para in paragraphs:
if current and len(current) + len(para) + 2 > max_size:
segments.append(current.strip())
current = para
else:
current = current + "\n\n" + para if current else para
if current.strip():
segments.append(current.strip())
return segments
@@ -1,160 +0,0 @@
"""
递归字符分块策略
最常用的分块策略,按照分隔符层级递归分割文本
优先按段落 → 句子 → 字符的顺序分割
"""
import logging
from typing import Dict, Any, List, Optional
from .base import BaseChunker, ChunkResult
logger = logging.getLogger(__name__)
# 默认分隔符层级(从大到小)
DEFAULT_SEPARATORS = [
"\n\n", # 段落
"\n", # 换行
"", # 中文句号
"", # 中文感叹号
"", # 中文问号
"", # 中文分号
". ", # 英文句号
"! ", # 英文感叹号
"? ", # 英文问号
"; ", # 英文分号
"", # 中文逗号
", ", # 英文逗号
" ", # 空格
"", # 逐字符
]
class RecursiveChunker(BaseChunker):
"""
递归字符分块器
按分隔符层级递归分割文本,确保每个分块不超过 chunk_size,
相邻分块之间有 chunk_overlap 的重叠
"""
def __init__(
self,
chunk_size: int = 500,
chunk_overlap: int = 50,
separator: Optional[str] = None,
separators: Optional[List[str]] = None,
):
super().__init__(chunk_size, chunk_overlap, separator)
if separator:
self.separators = [separator] + DEFAULT_SEPARATORS
elif separators:
self.separators = separators
else:
self.separators = DEFAULT_SEPARATORS
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""递归分块"""
if not text or not text.strip():
return []
text = self._clean_text(text)
metadata = metadata or {}
# 递归分割
raw_chunks = self._recursive_split(text, self.separators)
# 合并过小的分块
raw_chunks = self._merge_small_chunks(raw_chunks)
# 添加重叠
chunks_with_overlap = self._add_overlap(raw_chunks)
# 构建结果
results = []
for i, content in enumerate(chunks_with_overlap):
if content.strip():
results.append(ChunkResult(
content=content.strip(),
position=i,
metadata={**metadata},
))
return results
def _recursive_split(self, text: str, separators: List[str]) -> List[str]:
"""递归分割文本"""
if len(text) <= self.chunk_size:
return [text] if text.strip() else []
# 找到合适的分隔符
separator = ""
for sep in separators:
if sep == "":
separator = sep
break
if sep in text:
separator = sep
break
# 按分隔符分割
if separator:
splits = text.split(separator)
else:
# 逐字符分割
splits = list(text)
# 合并分割结果,确保不超过 chunk_size
chunks = []
current = ""
for split in splits:
piece = split if not separator else split
test_piece = current + separator + piece if current else piece
if len(test_piece) <= self.chunk_size:
current = test_piece
else:
if current:
chunks.append(current)
# 如果单个片段超过 chunk_size,递归处理
if len(piece) > self.chunk_size:
remaining_separators = separators[separators.index(separator) + 1:] if separator in separators else separators[1:]
if remaining_separators:
sub_chunks = self._recursive_split(piece, remaining_separators)
chunks.extend(sub_chunks)
current = ""
else:
# 没有更小的分隔符了,强制截断
for j in range(0, len(piece), self.chunk_size):
chunks.append(piece[j:j + self.chunk_size])
current = ""
else:
current = piece
if current:
chunks.append(current)
return chunks
def _add_overlap(self, chunks: List[str]) -> List[str]:
"""为相邻分块添加重叠"""
if self.chunk_overlap <= 0 or len(chunks) <= 1:
return chunks
result = []
for i, chunk in enumerate(chunks):
if i == 0:
result.append(chunk)
else:
# 从前一个分块的末尾取 overlap 字符作为前缀
prev = chunks[i - 1]
overlap_text = prev[-self.chunk_overlap:] if len(prev) > self.chunk_overlap else prev
# 确保合并后不超过 chunk_size 太多
combined = overlap_text + "\n" + chunk
if len(combined) <= self.chunk_size * 1.2:
result.append(combined)
else:
result.append(chunk)
return result
@@ -1,105 +0,0 @@
"""
按句子分块策略
按句号/问号/感叹号等句子边界分割文本,
然后将小句子合并到不超过 chunk_size 的分块中。
参考 Dify 的 sentence 分块模式。
"""
import logging
import re
from typing import Dict, Any, List, Optional
from .base import BaseChunker, ChunkResult
logger = logging.getLogger(__name__)
# 句子分隔符正则(中英文句号/问号/感叹号/分号)
SENTENCE_PATTERN = re.compile(
r'(?<=[。!?;.!?;])\s*'
)
class SentenceChunker(BaseChunker):
"""
按句子分块器
先按句子边界分割文本,再将相邻句子合并为不超过 chunk_size 的分块。
保证每个分块都是完整句子的组合,不会在句子中间截断。
"""
def chunk(self, text: str, metadata: Dict[str, Any] = None) -> List[ChunkResult]:
"""按句子分块"""
if not text or not text.strip():
return []
text = self._clean_text(text)
metadata = metadata or {}
# 按句子边界分割
sentences = SENTENCE_PATTERN.split(text)
sentences = [s.strip() for s in sentences if s.strip()]
if not sentences:
return [ChunkResult(content=text, position=0, metadata={**metadata})]
# 合并句子为分块(不超过 chunk_size)
chunks = []
current = ""
position = 0
for sentence in sentences:
# 如果单个句子就超过 chunk_size,强制作为独立分块
if len(sentence) > self.chunk_size:
if current:
chunks.append(current)
current = ""
chunks.append(sentence)
continue
test = current + sentence if not current else current + " " + sentence
if len(test) <= self.chunk_size:
current = test
else:
if current:
chunks.append(current)
current = sentence
if current:
chunks.append(current)
# 合并过小的分块
chunks = self._merge_small_chunks(chunks)
# 添加重叠
if self.chunk_overlap > 0 and len(chunks) > 1:
chunks = self._add_sentence_overlap(chunks)
# 构建结果
results = []
for i, content in enumerate(chunks):
if content.strip():
results.append(ChunkResult(
content=content.strip(),
position=i,
metadata={**metadata},
))
return results
def _add_sentence_overlap(self, chunks: List[str]) -> List[str]:
"""为相邻分块添加句子级重叠"""
result = [chunks[0]]
for i in range(1, len(chunks)):
prev = chunks[i - 1]
# 从前一个分块取最后一个句子作为重叠
prev_sentences = SENTENCE_PATTERN.split(prev)
prev_sentences = [s.strip() for s in prev_sentences if s.strip()]
if prev_sentences:
overlap = prev_sentences[-1]
if len(overlap) <= self.chunk_overlap:
combined = overlap + " " + chunks[i]
if len(combined) <= self.chunk_size * 1.2:
result.append(combined)
continue
result.append(chunks[i])
return result
@@ -1,16 +0,0 @@
"""
知识库数据模型
"""
from .knowledge_base_model import KnowledgeBase
from .document_model import KnowledgeDocument
from .segment_model import KnowledgeSegment
from .annotation_model import KnowledgeAnnotation
from .retrieval_log_model import KnowledgeRetrievalLog
__all__ = [
'KnowledgeBase',
'KnowledgeDocument',
'KnowledgeSegment',
'KnowledgeAnnotation',
'KnowledgeRetrievalLog',
]
@@ -1,34 +0,0 @@
"""
知识库标注模型(Q&A 对)
手动添加的高优先级问答对,检索时优先匹配。
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, Index
from app.base_model import BaseModel
class KnowledgeAnnotation(BaseModel):
"""
知识库标注(Q&A 对)
用户手动添加的问答对,检索时优先匹配 question,返回 answer。
"""
__tablename__ = "ai_knowledge_annotation"
knowledge_base_id = Column(String(21), nullable=False, index=True, comment="所属知识库ID(逻辑外键关联ai_knowledge_base")
# Q&A 内容
question = Column(Text, nullable=False, comment="问题")
answer = Column(Text, nullable=False, comment="答案")
# 向量化状态
embedding_status = Column(String(20), default="pending", comment="向量化状态: pending/completed/failed")
# 状态
enabled = Column(Boolean, default=True, comment="是否启用")
hit_count = Column(Integer, default=0, comment="命中次数")
__table_args__ = (
Index('idx_annotation_kb_enabled', 'knowledge_base_id', 'enabled'),
)
@@ -1,39 +0,0 @@
"""
知识库文档模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, BigInteger
from app.base_model import BaseModel
class KnowledgeDocument(BaseModel):
"""
知识库文档
记录上传到知识库的文档信息及处理状态
"""
__tablename__ = "ai_knowledge_document"
knowledge_base_id = Column(String(21), nullable=False, index=True, comment="所属知识库ID(逻辑外键关联ai_knowledge_base")
file_id = Column(String(21), nullable=True, comment="关联文件ID(逻辑外键关联core_file_manager")
name = Column(String(255), nullable=False, comment="文档名称")
file_type = Column(String(20), nullable=True, comment="文件类型: pdf/docx/txt/md/xlsx/csv/html/pptx")
file_size = Column(BigInteger, default=0, comment="文件大小(字节)")
content_hash = Column(String(64), nullable=True, index=True, comment="内容MD5(用于去重)")
# 处理结果
segment_count = Column(Integer, default=0, comment="分段数量")
token_count = Column(Integer, default=0, comment="Token 总数")
char_count = Column(Integer, default=0, comment="字符总数")
# 处理状态
status = Column(String(20), default="pending", index=True, comment="状态: pending/indexing/completed/failed/disabled")
error_message = Column(Text, nullable=True, comment="错误信息")
indexing_started_at = Column(DateTime, nullable=True, comment="索引开始时间")
indexing_completed_at = Column(DateTime, nullable=True, comment="索引完成时间")
# 去重
duplicate_warning = Column(Text, nullable=True, comment="内容重复警告(跨知识库检测)")
# 是否启用
enabled = Column(Boolean, default=True, comment="是否启用(禁用后不参与检索)")
@@ -1,55 +0,0 @@
"""
知识库模型
"""
from sqlalchemy import Column, String, Text, Integer, Float, Boolean, JSON
from app.base_model import BaseModel
class KnowledgeBase(BaseModel):
"""
知识库
管理文档集合,配置分块策略和检索参数
"""
__tablename__ = "ai_knowledge_base"
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID(逻辑外键关联core_application")
is_global = Column(Boolean, default=False, comment="是否在子应用中可见")
name = Column(String(100), nullable=False, comment="知识库名称")
code = Column(String(100), nullable=False, unique=True, comment="知识库编码")
description = Column(Text, nullable=True, comment="描述")
icon = Column(String(50), default="", comment="图标")
# Embedding 配置
embedding_model_id = Column(String(21), nullable=True, comment="Embedding 模型ID(逻辑外键关联ai_llm_model")
embedding_dimensions = Column(Integer, default=1536, comment="向量维度")
# 分块策略
chunk_strategy = Column(String(20), default="recursive", comment="分块策略: recursive/semantic/markdown/fixed")
chunk_size = Column(Integer, default=500, comment="分块大小(字符数)")
chunk_overlap = Column(Integer, default=50, comment="分块重叠大小(字符数)")
separator = Column(String(50), nullable=True, comment="自定义分隔符")
# 检索配置
retrieval_mode = Column(String(20), default="hybrid", comment="检索模式: vector/fulltext/hybrid")
top_k = Column(Integer, default=5, comment="检索返回数量")
score_threshold = Column(Float, default=0.5, comment="相似度阈值(0-1")
rerank_enabled = Column(Boolean, default=False, comment="是否启用重排序")
rerank_model_id = Column(String(21), nullable=True, comment="重排序模型ID")
retrieval_weight = Column(Float, default=1.0, comment="检索权重(多知识库检索时的加权系数,0.1-10.0)")
# 预处理规则
process_rules = Column(JSON, nullable=True, comment="预处理规则(清洗配置)")
# 索引模式
indexing_technique = Column(String(20), default="high_quality", comment="索引模式: high_quality/economy")
# 统计
document_count = Column(Integer, default=0, comment="文档数量")
segment_count = Column(Integer, default=0, comment="分段数量")
total_token_count = Column(Integer, default=0, comment="总 Token 数")
total_char_count = Column(Integer, default=0, comment="总字符数")
# 状态
status = Column(String(20), default="active", comment="状态: active/disabled")
@@ -1,41 +0,0 @@
"""
知识库检索日志模型
记录每次检索的查询、结果、耗时等信息,用于分析检索质量。
"""
from sqlalchemy import Column, String, Text, Integer, Float, JSON, Index
from app.base_model import BaseModel
class KnowledgeRetrievalLog(BaseModel):
"""
知识库检索日志
记录每次检索请求的完整信息,用于检索质量分析和优化。
"""
__tablename__ = "ai_knowledge_retrieval_log"
# 检索请求
query = Column(Text, nullable=False, comment="查询文本")
knowledge_base_ids = Column(JSON, nullable=False, comment="检索的知识库ID列表")
retrieval_mode = Column(String(20), default="hybrid", comment="检索模式: vector/fulltext/hybrid")
top_k = Column(Integer, default=5, comment="请求的返回数量")
score_threshold = Column(Float, default=0.5, comment="相似度阈值")
# 检索结果
result_count = Column(Integer, default=0, comment="实际返回结果数")
results = Column(JSON, nullable=True, comment="检索结果摘要(segment_id/score/kb_id")
rerank_applied = Column(String(5), default="false", comment="是否应用了重排序")
# 性能
elapsed_time = Column(Integer, default=0, comment="耗时(毫秒)")
# 来源
source = Column(String(50), nullable=True, comment="调用来源: api/workflow/chat")
user_id = Column(String(21), nullable=True, comment="操作用户ID")
__table_args__ = (
Index('idx_retrieval_log_query_time', 'sys_create_datetime'),
Index('idx_retrieval_log_user', 'user_id'),
)
@@ -1,47 +0,0 @@
"""
知识库分段模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, JSON, Index
from app.base_model import BaseModel
class KnowledgeSegment(BaseModel):
"""
知识库分段(Chunk
文档经过分块后的最小检索单元
向量数据存储在 Qdrant 向量数据库中,此表只存业务数据
"""
__tablename__ = "ai_knowledge_segment"
knowledge_base_id = Column(String(21), nullable=False, index=True, comment="所属知识库ID(逻辑外键关联ai_knowledge_base")
document_id = Column(String(21), nullable=False, index=True, comment="所属文档ID(逻辑外键关联ai_knowledge_document")
# 内容
position = Column(Integer, default=0, comment="在文档中的位置序号")
content = Column(Text, nullable=False, comment="分段文本内容")
answer = Column(Text, nullable=True, comment="Q&A 模式的答案内容")
token_count = Column(Integer, default=0, comment="Token 数")
char_count = Column(Integer, default=0, comment="字符数")
word_count = Column(Integer, default=0, comment="词数")
# 元数据
page_number = Column(Integer, nullable=True, comment="来源页码(PDF/PPT")
keywords = Column(JSON, nullable=True, comment="关键词列表(用于全文检索增强)")
extra_metadata = Column(JSON, nullable=True, comment="元数据(标题/来源等)")
# 向量化状态(向量数据存在 Qdrant 中,这里只记录状态)
embedding_status = Column(String(20), default="pending", comment="向量化状态: pending/completed/failed")
# 父子分段(Small-to-Big
parent_segment_id = Column(String(21), nullable=True, index=True, comment="父分段ID(逻辑外键,用于 Small-to-Big 检索)")
# 状态
enabled = Column(Boolean, default=True, comment="是否启用(禁用后不参与检索)")
hit_count = Column(Integer, default=0, comment="命中次数")
__table_args__ = (
Index('idx_segment_kb_doc', 'knowledge_base_id', 'document_id'),
Index('idx_segment_kb_enabled', 'knowledge_base_id', 'enabled'),
)
@@ -1,6 +0,0 @@
"""
知识库 Schema
"""
from .knowledge_base_schema import *
from .document_schema import *
from .segment_schema import *
@@ -1,37 +0,0 @@
"""
知识库标注 Schema
"""
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
class AnnotationCreateInput(BaseModel):
"""创建标注"""
question: str = Field(..., min_length=1, description="问题")
answer: str = Field(..., min_length=1, description="答案")
class AnnotationUpdateInput(BaseModel):
"""更新标注"""
question: Optional[str] = Field(None, min_length=1, description="问题")
answer: Optional[str] = Field(None, min_length=1, description="答案")
enabled: Optional[bool] = Field(None, description="是否启用")
class AnnotationResponse(BaseModel):
"""标注输出"""
id: str
knowledge_base_id: str
question: str
answer: str
embedding_status: str = "pending"
enabled: bool = True
hit_count: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
@@ -1,62 +0,0 @@
"""
知识库文档 Schema
"""
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
class DocumentUploadInput(BaseModel):
"""文档上传输入(通过文件管理系统上传后传入 file_id)"""
file_id: str = Field(..., description="文件ID(来自文件管理系统)")
name: Optional[str] = Field(None, description="文档名称(不传则使用文件名)")
class DocumentBatchUploadInput(BaseModel):
"""批量文档上传"""
file_ids: List[str] = Field(..., min_length=1, description="文件ID列表")
class DocumentResponse(BaseModel):
"""文档输出"""
id: str
knowledge_base_id: str
file_id: Optional[str] = None
name: str
file_type: str = ""
file_size: int = 0
content_hash: str = ""
segment_count: int = 0
token_count: int = 0
char_count: int = 0
status: str = "pending"
error_message: str = ""
duplicate_warning: Optional[str] = None
enabled: bool = True
indexing_started_at: Optional[CSTDatetime] = None
indexing_completed_at: Optional[CSTDatetime] = None
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class DocumentListResponse(BaseModel):
"""文档列表输出"""
id: str
knowledge_base_id: str
file_id: Optional[str] = None
name: str
file_type: str = ""
file_size: int = 0
segment_count: int = 0
token_count: int = 0
status: str = "pending"
duplicate_warning: Optional[str] = None
enabled: bool = True
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
@@ -1,115 +0,0 @@
"""
知识库 Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
class KnowledgeBaseCreate(BaseModel):
"""创建知识库"""
model_config = ConfigDict(protected_namespaces=())
application_id: Optional[str] = Field(None, description="所属应用ID")
is_global: bool = Field(default=False, description="是否在子应用中可见")
name: str = Field(..., max_length=100, description="知识库名称")
code: str = Field(..., max_length=100, description="知识库编码")
description: Optional[str] = Field(None, description="描述")
icon: str = Field(default="", description="图标")
embedding_model_id: Optional[str] = Field(None, description="Embedding 模型ID")
embedding_dimensions: int = Field(default=1536, description="向量维度")
chunk_strategy: str = Field(default="recursive", description="分块策略")
chunk_size: int = Field(default=500, ge=100, le=4000, description="分块大小")
chunk_overlap: int = Field(default=50, ge=0, le=500, description="分块重叠")
separator: Optional[str] = Field(None, description="自定义分隔符")
retrieval_mode: str = Field(default="hybrid", description="检索模式")
top_k: int = Field(default=5, ge=1, le=20, description="检索数量")
score_threshold: float = Field(default=0.5, ge=0, le=1, description="相似度阈值")
rerank_enabled: bool = Field(default=False, description="是否启用重排序")
rerank_model_id: Optional[str] = Field(None, description="重排序模型ID")
retrieval_weight: float = Field(default=1.0, ge=0.1, le=10.0, description="检索权重")
process_rules: Optional[Dict[str, Any]] = Field(None, description="预处理规则")
indexing_technique: str = Field(default="high_quality", description="索引模式: high_quality/economy")
class KnowledgeBaseUpdate(BaseModel):
"""更新知识库"""
model_config = ConfigDict(protected_namespaces=())
name: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
embedding_model_id: Optional[str] = None
embedding_dimensions: Optional[int] = None
chunk_strategy: Optional[str] = None
chunk_size: Optional[int] = Field(None, ge=100, le=4000)
chunk_overlap: Optional[int] = Field(None, ge=0, le=500)
separator: Optional[str] = None
retrieval_mode: Optional[str] = None
top_k: Optional[int] = Field(None, ge=1, le=20)
score_threshold: Optional[float] = Field(None, ge=0, le=1)
rerank_enabled: Optional[bool] = None
rerank_model_id: Optional[str] = None
retrieval_weight: Optional[float] = Field(None, ge=0.1, le=10.0)
process_rules: Optional[Dict[str, Any]] = None
indexing_technique: Optional[str] = None
status: Optional[str] = None
is_global: Optional[bool] = None
class KnowledgeBaseResponse(BaseModel):
"""知识库详情输出"""
id: str
application_id: Optional[str] = None
is_global: bool = False
name: str
code: str
description: str = ""
icon: str = ""
embedding_model_id: Optional[str] = None
embedding_model_name: str = ""
embedding_dimensions: int = 1536
chunk_strategy: str = "recursive"
chunk_size: int = 500
chunk_overlap: int = 50
separator: Optional[str] = None
retrieval_mode: str = "hybrid"
top_k: int = 5
score_threshold: float = 0.5
rerank_enabled: bool = False
rerank_model_id: Optional[str] = None
retrieval_weight: float = 1.0
process_rules: Optional[Dict[str, Any]] = None
indexing_technique: str = "high_quality"
document_count: int = 0
segment_count: int = 0
total_token_count: int = 0
total_char_count: int = 0
status: str = "active"
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
class KnowledgeBaseListResponse(BaseModel):
"""知识库列表输出"""
id: str
application_id: Optional[str] = None
application_name: str = ""
is_global: bool = False
name: str
code: str
description: str = ""
icon: str = ""
embedding_model_name: str = ""
document_count: int = 0
segment_count: int = 0
status: str = "active"
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
@@ -1,28 +0,0 @@
"""
检索日志 Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
class RetrievalLogResponse(BaseModel):
"""检索日志输出"""
id: str
query: str
knowledge_base_ids: List[str] = []
retrieval_mode: str = "hybrid"
top_k: int = 5
score_threshold: float = 0.5
result_count: int = 0
results: Optional[List[Dict[str, Any]]] = None
rerank_applied: str = "false"
elapsed_time: int = 0
source: Optional[str] = None
user_id: Optional[str] = None
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
@@ -1,140 +0,0 @@
"""
知识库分段 Schema
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
class SegmentResponse(BaseModel):
"""分段输出"""
id: str
knowledge_base_id: str
document_id: str
document_name: str = ""
position: int = 0
content: str
answer: Optional[str] = None
token_count: int = 0
char_count: int = 0
word_count: int = 0
page_number: Optional[int] = None
keywords: Optional[List[str]] = None
metadata: Optional[Dict[str, Any]] = None
embedding_status: str = "pending"
enabled: bool = True
hit_count: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class SegmentListResponse(BaseModel):
"""分段列表输出"""
id: str
document_id: str
document_name: str = ""
position: int = 0
content: str
answer: Optional[str] = None
token_count: int = 0
char_count: int = 0
word_count: int = 0
page_number: Optional[int] = None
keywords: Optional[List[str]] = None
extra_metadata: Optional[Dict[str, Any]] = None
enabled: bool = True
hit_count: int = 0
embedding_status: str = "pending"
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class SegmentUpdateInput(BaseModel):
"""更新分段"""
content: Optional[str] = Field(None, description="分段内容")
keywords: Optional[List[str]] = Field(None, description="关键词")
enabled: Optional[bool] = Field(None, description="是否启用")
extra_metadata: Optional[Dict[str, Any]] = Field(None, description="元数据")
class SegmentCreateInput(BaseModel):
"""手动创建分段"""
content: str = Field(..., min_length=1, description="分段内容")
answer: Optional[str] = Field(None, description="Q&A 模式的答案")
keywords: Optional[List[str]] = Field(None, description="关键词")
class ChunkPreviewInput(BaseModel):
"""分块预览输入"""
file_id: str = Field(..., description="文件ID")
chunk_strategy: str = Field(default="recursive", description="分块策略")
chunk_size: int = Field(default=500, ge=100, le=4000, description="分块大小")
chunk_overlap: int = Field(default=50, ge=0, le=500, description="分块重叠")
separator: Optional[str] = Field(None, description="自定义分隔符")
process_rules: Optional[Dict[str, Any]] = Field(None, description="预处理规则")
class ChunkPreviewItem(BaseModel):
"""分块预览结果项"""
position: int = 0
content: str = ""
char_count: int = 0
token_count: int = 0
word_count: int = 0
answer: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class ChunkPreviewResponse(BaseModel):
"""分块预览响应"""
chunks: List[ChunkPreviewItem] = Field(default_factory=list)
total: int = 0
strategy: str = ""
chunk_size: int = 0
chunk_overlap: int = 0
class RetrievalInput(BaseModel):
"""检索输入"""
model_config = ConfigDict(protected_namespaces=())
query: str = Field(..., min_length=1, description="查询文本")
knowledge_base_ids: List[str] = Field(..., min_length=1, description="知识库ID列表")
top_k: int = Field(default=5, ge=1, le=20, description="返回数量")
score_threshold: float = Field(default=0.5, ge=0, le=1, description="相似度阈值")
retrieval_mode: Optional[str] = Field(None, description="检索模式(不传则使用知识库配置)")
rerank_enabled: Optional[bool] = Field(None, description="是否启用重排序(不传则使用知识库配置)")
rerank_model_id: Optional[str] = Field(None, description="重排序模型ID(不传则使用知识库配置)")
metadata_filter: Optional[Dict[str, Any]] = Field(None, description="元数据过滤条件")
class RetrievalResult(BaseModel):
"""检索结果"""
segment_id: str
document_id: str
document_name: str = ""
knowledge_base_id: str
knowledge_base_name: str = ""
content: str
score: float = 0.0
token_count: int = 0
page_number: Optional[int] = None
metadata: Optional[Dict[str, Any]] = None
keywords: Optional[List[str]] = None
match_source: Optional[str] = Field(None, description="命中来源: vector/fulltext/annotation")
parent_content: Optional[str] = Field(None, description="父分段内容(Small-to-Big 模式)")
class RetrievalResponse(BaseModel):
"""检索响应"""
results: List[RetrievalResult] = Field(default_factory=list)
total: int = 0
query: str = ""
elapsed_time: int = 0
retrieval_mode: str = ""
rerank_applied: bool = False
@@ -1,3 +0,0 @@
"""
知识库服务
"""
@@ -1,159 +0,0 @@
"""
文档预处理/清洗服务
参考 Dify 的 DatasetProcessRule,支持可配置的文本清洗规则。
在文本提取之后、分块之前执行。
"""
import logging
import re
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
# 默认预处理规则
DEFAULT_PROCESS_RULES: Dict[str, Any] = {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": False},
{"id": "remove_html_tags", "enabled": False},
{"id": "remove_consecutive_newlines", "enabled": True},
{"id": "remove_trailing_whitespace", "enabled": True},
],
}
class CleaningService:
"""
文本清洗服务
支持的清洗规则:
- remove_extra_spaces: 合并连续空格为单个空格
- remove_urls_emails: 移除 URL 和邮箱地址
- remove_html_tags: 移除 HTML 标签
- remove_consecutive_newlines: 合并连续空行(3+)为双空行
- remove_trailing_whitespace: 去除行尾空白
"""
# 规则处理器映射
RULE_PROCESSORS = {
"remove_extra_spaces": "_remove_extra_spaces",
"remove_urls_emails": "_remove_urls_emails",
"remove_html_tags": "_remove_html_tags",
"remove_consecutive_newlines": "_remove_consecutive_newlines",
"remove_trailing_whitespace": "_remove_trailing_whitespace",
}
@classmethod
def clean(cls, text: str, process_rules: Optional[Dict[str, Any]] = None) -> str:
"""
根据预处理规则清洗文本
Args:
text: 原始文本
process_rules: 预处理规则配置,为 None 则使用默认规则
Returns:
清洗后的文本
"""
if not text:
return text
rules = process_rules or DEFAULT_PROCESS_RULES
pre_rules = rules.get("pre_processing_rules", [])
original_length = len(text)
for rule in pre_rules:
rule_id = rule.get("id", "")
enabled = rule.get("enabled", False)
if not enabled:
continue
processor_name = cls.RULE_PROCESSORS.get(rule_id)
if not processor_name:
logger.warning(f"未知的预处理规则: {rule_id}")
continue
processor = getattr(cls, processor_name, None)
if processor:
text = processor(text)
cleaned_length = len(text)
if original_length != cleaned_length:
logger.info(
f"文本清洗完成: {original_length} -> {cleaned_length} 字符 "
f"(减少 {original_length - cleaned_length})"
)
return text.strip()
@staticmethod
def _remove_extra_spaces(text: str) -> str:
"""合并连续空格为单个空格(保留换行符)"""
# 只处理同一行内的连续空格,不影响换行
lines = text.split('\n')
cleaned_lines = []
for line in lines:
cleaned_lines.append(re.sub(r'[ \t]+', ' ', line))
return '\n'.join(cleaned_lines)
@staticmethod
def _remove_urls_emails(text: str) -> str:
"""移除 URL 和邮箱地址"""
# 移除 URL
text = re.sub(
r'https?://[^\s<>"{}|\\^`\[\]]+',
'',
text,
)
# 移除邮箱
text = re.sub(
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
'',
text,
)
return text
@staticmethod
def _remove_html_tags(text: str) -> str:
"""移除 HTML 标签,保留文本内容"""
# 移除 script 和 style 标签及其内容
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
# 移除所有 HTML 标签
text = re.sub(r'<[^>]+>', '', text)
# 解码常见 HTML 实体
text = text.replace('&nbsp;', ' ')
text = text.replace('&lt;', '<')
text = text.replace('&gt;', '>')
text = text.replace('&amp;', '&')
text = text.replace('&quot;', '"')
text = text.replace('&#39;', "'")
return text
@staticmethod
def _remove_consecutive_newlines(text: str) -> str:
"""合并连续空行(3个以上换行)为双换行"""
return re.sub(r'\n{3,}', '\n\n', text)
@staticmethod
def _remove_trailing_whitespace(text: str) -> str:
"""去除每行行尾空白"""
return '\n'.join(line.rstrip() for line in text.split('\n'))
@classmethod
def get_default_rules(cls) -> Dict[str, Any]:
"""获取默认预处理规则"""
return DEFAULT_PROCESS_RULES.copy()
@classmethod
def get_available_rules(cls) -> List[Dict[str, str]]:
"""获取所有可用的预处理规则"""
return [
{"id": "remove_extra_spaces", "label": "合并连续空格"},
{"id": "remove_urls_emails", "label": "移除 URL 和邮箱"},
{"id": "remove_html_tags", "label": "移除 HTML 标签"},
{"id": "remove_consecutive_newlines", "label": "合并连续空行"},
{"id": "remove_trailing_whitespace", "label": "去除行尾空白"},
]
@@ -1,425 +0,0 @@
"""
文档服务
文档上传、管理、状态控制
"""
import logging
from typing import Optional, List, Tuple
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.knowledge.models import KnowledgeBase, KnowledgeDocument, KnowledgeSegment
logger = logging.getLogger(__name__)
class DocumentService:
"""文档服务"""
def __init__(self, db: AsyncSession):
self._db = db
async def get_list(
self,
knowledge_base_id: str,
page: int = 1,
page_size: int = 20,
name: Optional[str] = None,
status: Optional[str] = None,
) -> Tuple[List[KnowledgeDocument], int]:
"""获取文档列表"""
query = select(KnowledgeDocument).where(
KnowledgeDocument.knowledge_base_id == knowledge_base_id,
KnowledgeDocument.is_deleted == False,
)
if name:
query = query.where(KnowledgeDocument.name.ilike(f"%{name}%"))
if status:
query = query.where(KnowledgeDocument.status == status)
count_result = await self._db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(KnowledgeDocument.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await self._db.execute(query)
items = result.scalars().all()
return items, total
async def get_by_id(self, doc_id: str) -> Optional[KnowledgeDocument]:
"""获取文档详情"""
result = await self._db.execute(
select(KnowledgeDocument).where(
KnowledgeDocument.id == doc_id,
KnowledgeDocument.is_deleted == False
)
)
return result.scalar_one_or_none()
async def add_document(
self,
knowledge_base_id: str,
file_id: str,
name: Optional[str] = None,
) -> KnowledgeDocument:
"""
添加文档到知识库
Args:
knowledge_base_id: 知识库 ID
file_id: 文件管理系统中的文件 ID
name: 文档名称(不传则从文件信息获取)
"""
# 验证知识库存在
kb_result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.is_deleted == False
)
)
kb = kb_result.scalar_one_or_none()
if not kb:
raise ValueError('知识库不存在')
# 获取文件信息
from core.file_manager.model import FileManager
file_result = await self._db.execute(
select(FileManager).where(
FileManager.id == file_id,
FileManager.is_deleted == False
)
)
file_info = file_result.scalar_one_or_none()
if not file_info:
raise ValueError('文件不存在')
# 检查是否已添加(通过 file_id 去重)
existing = await self._db.execute(
select(KnowledgeDocument).where(
KnowledgeDocument.knowledge_base_id == knowledge_base_id,
KnowledgeDocument.file_id == file_id,
KnowledgeDocument.is_deleted == False,
)
)
if existing.scalars().first():
raise ValueError('该文件已添加到知识库')
# 通过文件 MD5 检测内容重复(跨知识库)
duplicate_warning = None
if file_info.md5:
dup_result = await self._db.execute(
select(KnowledgeDocument).where(
KnowledgeDocument.content_hash == file_info.md5,
KnowledgeDocument.is_deleted == False,
KnowledgeDocument.knowledge_base_id != knowledge_base_id,
).limit(1)
)
dup_doc = dup_result.scalar_one_or_none()
if dup_doc:
duplicate_warning = f'该文件内容与其他知识库中的文档 "{dup_doc.name}" 重复'
logger.info(f'文档内容重复检测: file_id={file_id}, 重复文档={dup_doc.id}')
# 同知识库内容去重(严格阻止)
same_kb_dup = await self._db.execute(
select(KnowledgeDocument).where(
KnowledgeDocument.content_hash == file_info.md5,
KnowledgeDocument.knowledge_base_id == knowledge_base_id,
KnowledgeDocument.is_deleted == False,
).limit(1)
)
if same_kb_dup.scalar_one_or_none():
raise ValueError('该知识库中已存在相同内容的文档')
doc = KnowledgeDocument(
knowledge_base_id=knowledge_base_id,
file_id=file_id,
name=name or file_info.name,
file_type=file_info.file_ext or '',
file_size=file_info.size or 0,
content_hash=file_info.md5 or '',
status='pending',
duplicate_warning=duplicate_warning,
)
self._db.add(doc)
await self._db.commit()
await self._db.refresh(doc)
return doc
async def batch_add_documents(
self,
knowledge_base_id: str,
file_ids: List[str],
) -> List[KnowledgeDocument]:
"""批量添加文档"""
docs = []
for file_id in file_ids:
try:
doc = await self.add_document(knowledge_base_id, file_id)
docs.append(doc)
except ValueError as e:
logger.warning(f'添加文档失败 (file_id={file_id}): {e}')
continue
return docs
async def delete_document(self, doc_id: str) -> bool:
"""删除文档(软删除,同时删除分段 + 清理 Qdrant 向量)"""
doc = await self.get_by_id(doc_id)
if not doc:
return False
doc.is_deleted = True
# 软删除关联分段
await self._db.execute(
update(KnowledgeSegment).where(
KnowledgeSegment.document_id == doc_id
).values(is_deleted=True)
)
# 更新知识库统计
from ai_platform.knowledge.services.indexing_service import IndexingService
indexing_service = IndexingService(self._db)
await indexing_service._update_kb_stats(doc.knowledge_base_id)
await self._db.commit()
# 从 Qdrant 删除该文档的所有向量
try:
from ai_platform.knowledge.vector_store import get_vector_store
vector_store = get_vector_store()
await vector_store.delete_by_filter(
doc.knowledge_base_id,
filter_conditions={'document_id': doc_id},
)
except Exception as e:
logger.warning(f'从 Qdrant 删除文档向量失败: {e}')
return True
async def toggle_document(self, doc_id: str, enabled: bool) -> Optional[KnowledgeDocument]:
"""启用/禁用文档"""
doc = await self.get_by_id(doc_id)
if not doc:
return None
doc.enabled = enabled
# 同时启用/禁用关联分段
await self._db.execute(
update(KnowledgeSegment).where(
KnowledgeSegment.document_id == doc_id,
KnowledgeSegment.is_deleted == False,
).values(enabled=enabled)
)
await self._db.commit()
await self._db.refresh(doc)
return doc
async def get_segments(
self,
document_id: str,
page: int = 1,
page_size: int = 20,
keyword: Optional[str] = None,
) -> Tuple[List[KnowledgeSegment], int]:
"""获取文档的分段列表"""
query = select(KnowledgeSegment).where(
KnowledgeSegment.document_id == document_id,
KnowledgeSegment.is_deleted == False,
)
if keyword:
query = query.where(KnowledgeSegment.content.ilike(f"%{keyword}%"))
count_result = await self._db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(KnowledgeSegment.position.asc())
query = query.offset(offset).limit(page_size)
result = await self._db.execute(query)
items = result.scalars().all()
return items, total
async def get_kb_segments(
self,
knowledge_base_id: str,
page: int = 1,
page_size: int = 20,
keyword: Optional[str] = None,
enabled: Optional[bool] = None,
embedding_status: Optional[str] = None,
metadata_key: Optional[str] = None,
metadata_value: Optional[str] = None,
) -> Tuple[List[KnowledgeSegment], int]:
"""获取知识库的所有分段"""
from app.db_compat import json_extract, json_has_key
query = select(KnowledgeSegment).where(
KnowledgeSegment.knowledge_base_id == knowledge_base_id,
KnowledgeSegment.is_deleted == False,
)
if keyword:
query = query.where(KnowledgeSegment.content.ilike(f"%{keyword}%"))
if enabled is not None:
query = query.where(KnowledgeSegment.enabled == enabled)
if embedding_status:
query = query.where(KnowledgeSegment.embedding_status == embedding_status)
if metadata_key:
if metadata_value:
query = query.where(
json_extract(KnowledgeSegment.extra_metadata, metadata_key) == metadata_value
)
else:
query = query.where(
json_has_key(KnowledgeSegment.extra_metadata, metadata_key)
)
count_result = await self._db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(KnowledgeSegment.document_id, KnowledgeSegment.position.asc())
query = query.offset(offset).limit(page_size)
result = await self._db.execute(query)
items = result.scalars().all()
return items, total
async def update_segment(
self,
segment_id: str,
content: Optional[str] = None,
keywords: Optional[List[str]] = None,
enabled: Optional[bool] = None,
extra_metadata: Optional[dict] = None,
) -> Optional[KnowledgeSegment]:
"""更新分段"""
result = await self._db.execute(
select(KnowledgeSegment).where(
KnowledgeSegment.id == segment_id,
KnowledgeSegment.is_deleted == False
)
)
segment = result.scalar_one_or_none()
if not segment:
return None
need_reindex = False
if content is not None and content != segment.content:
segment.content = content
segment.char_count = len(content)
segment.embedding_status = 'pending'
need_reindex = True
if keywords is not None:
segment.keywords = keywords
if enabled is not None:
segment.enabled = enabled
if extra_metadata is not None:
segment.extra_metadata = extra_metadata
await self._db.commit()
# 如果内容变更,重新向量化
if need_reindex:
from ai_platform.knowledge.services.indexing_service import IndexingService
indexing_service = IndexingService(self._db)
await indexing_service.index_segment(segment.knowledge_base_id, segment_id)
await self._db.refresh(segment)
return segment
async def add_segment(
self,
knowledge_base_id: str,
document_id: str,
content: str,
keywords: Optional[List[str]] = None,
) -> KnowledgeSegment:
"""手动添加分段"""
# 获取当前最大 position
max_pos_result = await self._db.execute(
select(func.max(KnowledgeSegment.position)).where(
KnowledgeSegment.document_id == document_id,
KnowledgeSegment.is_deleted == False,
)
)
max_pos = max_pos_result.scalar() or 0
segment = KnowledgeSegment(
knowledge_base_id=knowledge_base_id,
document_id=document_id,
position=max_pos + 1,
content=content,
char_count=len(content),
keywords=keywords,
embedding_status='pending',
enabled=True,
)
self._db.add(segment)
await self._db.commit()
await self._db.refresh(segment)
# 向量化
from ai_platform.knowledge.services.indexing_service import IndexingService
indexing_service = IndexingService(self._db)
await indexing_service.index_segment(knowledge_base_id, segment.id)
# 更新统计
await indexing_service._update_kb_stats(knowledge_base_id)
await self._db.commit()
await self._db.refresh(segment)
return segment
async def delete_segment(self, segment_id: str) -> bool:
"""删除分段(软删除 + 清理 Qdrant 向量)"""
from sqlalchemy import update
# 先查询获取 kb_id
result = await self._db.execute(
select(KnowledgeSegment.knowledge_base_id).where(
KnowledgeSegment.id == segment_id,
KnowledgeSegment.is_deleted == False
)
)
row = result.first()
if not row:
return False
kb_id = str(row[0])
# 直接 SQL UPDATE 避免并发场景下的 StaleDataError
await self._db.execute(
update(KnowledgeSegment)
.where(KnowledgeSegment.id == segment_id)
.values(is_deleted=True)
)
await self._db.commit()
# 从 Qdrant 删除该分段的向量
try:
from ai_platform.knowledge.vector_store import get_vector_store
vector_store = get_vector_store()
await vector_store.delete(kb_id, [str(segment_id)])
except Exception as e:
logger.warning(f'从 Qdrant 删除分段向量失败: {e}')
# 更新统计
from ai_platform.knowledge.services.indexing_service import IndexingService
indexing_service = IndexingService(self._db)
await indexing_service._update_kb_stats(kb_id)
await self._db.commit()
return True
@@ -1,206 +0,0 @@
"""
Embedding 服务
通过 OpenAI 兼容的 Embeddings API 将文本转换为向量
支持所有兼容 OpenAI 接口的提供商(OpenAI、Qwen、Ollama 等)
"""
import logging
import math
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# 单次批量请求的默认最大文本数
DEFAULT_BATCH_SIZE = 50
# 不同提供商的批次大小限制
PROVIDER_BATCH_SIZE = {
'qwen': 10, # 阿里云 DashScope 限制 10
'dashscope': 10,
'siliconflow': 10, # 硅基流动限制较小
'ollama': 1, # Ollama 通常逐条处理
}
class EmbeddingService:
"""
Embedding 服务
通过模型 ID 获取对应的提供商,调用 OpenAI 兼容的 Embeddings API
"""
def __init__(self, db: AsyncSession):
self._db = db
self._client_cache = {}
async def _get_client_and_model(self, model_id: str):
"""
根据模型 ID 获取异步客户端和模型名称
Returns:
(async_client, model_name, max_tokens, provider_type)
"""
from ai_platform.models import LLMModel, LLMProvider
result = await self._db.execute(
select(LLMModel).where(
LLMModel.id == model_id,
LLMModel.is_active == True,
LLMModel.is_deleted == False
)
)
model = result.scalar_one_or_none()
if not model:
raise ValueError(f'Embedding 模型不存在或已禁用: {model_id}')
if model.model_type != 'embedding':
raise ValueError(f'模型 {model.display_name} 不是 Embedding 类型')
provider_result = await self._db.execute(
select(LLMProvider).where(
LLMProvider.id == model.provider_id,
LLMProvider.is_active == True,
LLMProvider.is_deleted == False
)
)
provider = provider_result.scalar_one_or_none()
if not provider:
raise ValueError('Embedding 模型对应的提供商不存在或已禁用')
cache_key = str(provider.id)
if cache_key not in self._client_cache:
import httpx
from openai import AsyncOpenAI
# 根据提供商类型确定 base_url
if provider.provider_type == 'ollama':
base_url = (provider.ollama_host or 'http://localhost:11434').rstrip('/') + '/v1'
else:
from ai_platform.providers.registry import ProviderRegistry
provider_class = ProviderRegistry.get(provider.provider_type)
default_base = getattr(provider_class, 'DEFAULT_API_BASE', 'https://api.openai.com/v1') if provider_class else 'https://api.openai.com/v1'
base_url = provider.api_base or default_base
self._client_cache[cache_key] = AsyncOpenAI(
api_key=provider.api_key or 'ollama',
base_url=base_url,
timeout=httpx.Timeout(120.0, connect=30.0),
max_retries=5,
)
return self._client_cache[cache_key], model.model_name, model.context_window or 8191, provider.provider_type
async def embed_text(
self,
model_id: str,
text: str,
dimensions: Optional[int] = None,
) -> List[float]:
"""
将单个文本转换为向量
Args:
model_id: Embedding 模型 ID
text: 文本内容
dimensions: 向量维度(可选,部分模型支持)
Returns:
向量列表 List[float]
"""
results = await self.embed_texts(model_id, [text], dimensions)
return results[0]
async def embed_texts(
self,
model_id: str,
texts: List[str],
dimensions: Optional[int] = None,
) -> List[List[float]]:
"""
批量将文本转换为向量
Args:
model_id: Embedding 模型 ID
texts: 文本列表
dimensions: 向量维度(可选)
Returns:
向量列表 List[List[float]]
"""
if not texts:
return []
client, model_name, max_tokens, provider_type = await self._get_client_and_model(model_id)
# 根据提供商类型确定批次大小
batch_size = PROVIDER_BATCH_SIZE.get(provider_type, DEFAULT_BATCH_SIZE)
# 预处理:截断超长文本
processed_texts = []
for text in texts:
if not text or not text.strip():
processed_texts.append(" ")
else:
# 粗略估算 token 数(中文约 1 字 = 1.5 token,英文约 4 字符 = 1 token
# 保守截断到 max_tokens * 2 个字符
max_chars = max_tokens * 2
if len(text) > max_chars:
processed_texts.append(text[:max_chars])
else:
processed_texts.append(text)
# 分批处理
all_embeddings = [None] * len(processed_texts)
total_batches = math.ceil(len(processed_texts) / batch_size)
for batch_idx in range(total_batches):
start = batch_idx * batch_size
end = min(start + batch_size, len(processed_texts))
batch_texts = processed_texts[start:end]
try:
kwargs = {
'model': model_name,
'input': batch_texts,
}
# 仅对明确支持 dimensions 参数的模型传递该参数
if dimensions:
model_lower = model_name.lower()
# OpenAI text-embedding-3 系列原生支持任意 dimensions
if 'text-embedding-3' in model_lower:
kwargs['dimensions'] = dimensions
# DashScope text-embedding-v3 只接受 [64,128,256,512,768,1024]
elif 'text-embedding-v3' in model_lower and dimensions in (64, 128, 256, 512, 768, 1024):
kwargs['dimensions'] = dimensions
response = await client.embeddings.create(**kwargs)
for item in response.data:
all_embeddings[start + item.index] = item.embedding
except Exception as e:
logger.exception(f'Embedding 批次 {batch_idx + 1}/{total_batches} 失败: {e}')
raise ValueError(f'Embedding 调用失败: {str(e)}')
# 检查是否所有向量都已生成
for i, emb in enumerate(all_embeddings):
if emb is None:
raise ValueError(f'{i} 个文本的向量未生成')
return all_embeddings
async def get_embedding_dimensions(self, model_id: str) -> int:
"""
获取模型的向量维度(通过嵌入一个测试文本来检测)
Args:
model_id: Embedding 模型 ID
Returns:
向量维度
"""
test_embedding = await self.embed_text(model_id, "test")
return len(test_embedding)
@@ -1,91 +0,0 @@
"""
索引进度推送服务
通过 Redis Pub/Sub 推送索引进度,前端通过 SSE 订阅。
"""
import json
import logging
from typing import Optional
logger = logging.getLogger(__name__)
# Redis 频道前缀
CHANNEL_PREFIX = "knowledge:indexing:progress:"
class IndexingProgressService:
"""索引进度推送服务"""
@staticmethod
def _channel(knowledge_base_id: str) -> str:
return f"{CHANNEL_PREFIX}{knowledge_base_id}"
@classmethod
async def publish(
cls,
knowledge_base_id: str,
document_id: str,
step: str,
progress: float,
message: str = "",
document_name: str = "",
error: Optional[str] = None,
):
"""
发布索引进度事件
Args:
knowledge_base_id: 知识库 ID
document_id: 文档 ID
step: 当前步骤 (extracting/cleaning/chunking/vectorizing/completed/failed)
progress: 进度 0.0 ~ 1.0
message: 进度描述
document_name: 文档名称
error: 错误信息(仅 failed 步骤)
"""
try:
from utils.redis import RedisClient
client = await RedisClient.get_client()
event = {
"document_id": document_id,
"document_name": document_name,
"step": step,
"progress": round(progress, 2),
"message": message,
}
if error:
event["error"] = error
await client.publish(
cls._channel(knowledge_base_id),
json.dumps(event, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"发布索引进度失败: {e}")
@classmethod
async def subscribe(cls, knowledge_base_id: str):
"""
订阅索引进度事件(异步生成器,用于 SSE)
Yields:
dict: 进度事件
"""
from utils.redis import RedisClient
client = await RedisClient.get_client()
pubsub = client.pubsub()
channel = cls._channel(knowledge_base_id)
await pubsub.subscribe(channel)
try:
async for message in pubsub.listen():
if message["type"] == "message":
try:
data = json.loads(message["data"])
yield data
except (json.JSONDecodeError, TypeError):
continue
finally:
await pubsub.unsubscribe(channel)
await pubsub.close()
@@ -1,540 +0,0 @@
"""
文档索引服务
负责文档处理管道:文本提取 → 分块 → 向量化 → 入库
分段数据存入业务数据库,向量数据存入 Qdrant 向量数据库
"""
import hashlib
import logging
import math
from datetime import datetime
from typing import List, Optional, Tuple
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.knowledge.models import KnowledgeBase, KnowledgeDocument, KnowledgeSegment
from ai_platform.knowledge.chunking import get_chunker
from ai_platform.knowledge.services.embedding_service import EmbeddingService
from ai_platform.knowledge.vector_store import get_vector_store, VectorPoint
logger = logging.getLogger(__name__)
# 向量化批次大小
EMBEDDING_BATCH_SIZE = 50
class IndexingService:
"""
文档索引服务
处理管道:
1. 从文件管理系统提取文本内容
2. 按知识库配置的策略分块
3. 调用 Embedding 模型向量化
4. 将分段写入业务数据库,向量写入 Qdrant
"""
def __init__(self, db: AsyncSession):
self._db = db
self._embedding_service = EmbeddingService(db)
self._vector_store = get_vector_store()
async def index_document(
self,
knowledge_base_id: str,
document_id: str,
) -> Tuple[int, int]:
"""
索引单个文档
Args:
knowledge_base_id: 知识库 ID
document_id: 文档 ID
Returns:
(segment_count, token_count) 分段数和 Token 数
"""
# 1. 获取知识库配置
kb_result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.is_deleted == False
)
)
kb = kb_result.scalar_one_or_none()
if not kb:
raise ValueError(f'知识库不存在: {knowledge_base_id}')
if not kb.embedding_model_id:
raise ValueError('知识库未配置 Embedding 模型')
# 2. 获取文档
doc_result = await self._db.execute(
select(KnowledgeDocument).where(
KnowledgeDocument.id == document_id,
KnowledgeDocument.is_deleted == False
)
)
doc = doc_result.scalar_one_or_none()
if not doc:
raise ValueError(f'文档不存在: {document_id}')
# 更新状态为 indexing
doc.status = 'indexing'
doc.indexing_started_at = datetime.now()
doc.error_message = None
await self._db.commit()
try:
from ai_platform.knowledge.services.indexing_progress_service import IndexingProgressService
# 3. 提取文本
await IndexingProgressService.publish(
knowledge_base_id, document_id, step='extracting', progress=0.1,
message='正在提取文本内容...', document_name=doc.name,
)
text_content = await self._extract_text(doc.file_id)
if not text_content or not text_content.strip():
raise ValueError('文档内容为空,无法索引')
# 3.5 预处理/清洗
await IndexingProgressService.publish(
knowledge_base_id, document_id, step='cleaning', progress=0.2,
message='正在预处理/清洗文本...', document_name=doc.name,
)
from ai_platform.knowledge.services.cleaning_service import CleaningService
text_content = CleaningService.clean(text_content, kb.process_rules)
# 计算内容哈希(用于去重)
content_hash = hashlib.md5(text_content.encode('utf-8')).hexdigest()
doc.content_hash = content_hash
# 4. 分块
await IndexingProgressService.publish(
knowledge_base_id, document_id, step='chunking', progress=0.3,
message='正在分块...', document_name=doc.name,
)
chunk_strategy = kb.chunk_strategy or 'recursive'
chunk_kwargs = {}
# Q&A 模式需要 LLM 调用函数
if chunk_strategy == 'qa':
chunk_kwargs['llm_caller'] = self._create_llm_caller(kb)
chunker = get_chunker(
strategy=chunk_strategy,
chunk_size=kb.chunk_size or 500,
chunk_overlap=kb.chunk_overlap or 50,
separator=kb.separator,
**chunk_kwargs,
)
doc_metadata = {
'document_id': document_id,
'document_name': doc.name,
'file_type': doc.file_type,
}
# Q&A 模式使用异步分块
if chunk_strategy == 'qa' and hasattr(chunker, 'chunk_async'):
chunks = await chunker.chunk_async(text_content, metadata=doc_metadata)
else:
chunks = chunker.chunk(text_content, metadata=doc_metadata)
if not chunks:
raise ValueError('文档分块结果为空')
# 5. 删除旧的分段(重新索引场景)
await self._delete_document_segments(document_id, knowledge_base_id)
# 判断索引模式
is_economy = (kb.indexing_technique == 'economy')
# 6. 确保 Qdrant collection 存在(经济模式跳过)
if not is_economy:
# 自动检测并修正 embedding 维度
try:
real_dim = await self._embedding_service.get_embedding_dimensions(kb.embedding_model_id)
if real_dim != kb.embedding_dimensions:
logger.info(f'修正 embedding 维度: {kb.embedding_dimensions}{real_dim}')
kb.embedding_dimensions = real_dim
await self._db.commit()
except Exception as e:
logger.warning(f'自动检测 embedding 维度失败: {e}')
vector_size = kb.embedding_dimensions or 1536
await self._vector_store.ensure_collection(knowledge_base_id, vector_size)
# 7. 入库(分批处理)
segment_count = 0
total_token_count = 0
total_char_count = 0
failed_embedding_count = 0
total_batches = math.ceil(len(chunks) / EMBEDDING_BATCH_SIZE)
for batch_idx in range(total_batches):
start = batch_idx * EMBEDDING_BATCH_SIZE
end = min(start + EMBEDDING_BATCH_SIZE, len(chunks))
batch_chunks = chunks[start:end]
# 高质量模式:批量向量化;经济模式:跳过
if is_economy:
embeddings = [None] * len(batch_chunks)
else:
batch_texts = [c.content for c in batch_chunks]
try:
embeddings = await self._embedding_service.embed_texts(
model_id=kb.embedding_model_id,
texts=batch_texts,
dimensions=kb.embedding_dimensions if kb.embedding_dimensions else None,
)
except Exception as e:
logger.error(f'向量化批次 {batch_idx + 1}/{total_batches} 失败: {e}')
embeddings = [None] * len(batch_texts)
failed_embedding_count += len(batch_texts)
# 创建分段记录(业务数据库)+ 收集向量点(Qdrant)
vector_points = []
for i, chunk in enumerate(batch_chunks):
embedding = embeddings[i] if i < len(embeddings) else None
char_count = len(chunk.content)
token_count = self._estimate_tokens(chunk.content)
word_count = self._count_words(chunk.content)
# 自动提取关键词(高质量和经济模式均提取,增强全文检索)
keywords = chunk.metadata.get('keywords')
if not keywords:
keywords = self._extract_keywords(chunk.content)
# 经济模式下 embedding_status 标记为 'skipped'
if is_economy:
emb_status = 'skipped'
else:
emb_status = 'completed' if embedding else 'failed'
segment = KnowledgeSegment(
knowledge_base_id=knowledge_base_id,
document_id=document_id,
position=start + i,
content=chunk.content,
answer=chunk.metadata.get('answer'),
token_count=token_count,
char_count=char_count,
word_count=word_count,
page_number=chunk.metadata.get('page_number'),
keywords=keywords,
extra_metadata=chunk.metadata,
embedding_status=emb_status,
enabled=True,
)
self._db.add(segment)
await self._db.flush()
# 收集向量点,稍后批量写入 Qdrant(经济模式跳过)
if embedding and not is_economy:
vector_points.append(VectorPoint(
id=str(segment.id),
vector=embedding,
payload={
'document_id': document_id,
'knowledge_base_id': knowledge_base_id,
'position': start + i,
},
))
segment_count += 1
total_token_count += token_count
total_char_count += char_count
# 提交业务数据库
await self._db.commit()
# 批量写入 Qdrant(经济模式跳过)
if vector_points:
await self._vector_store.upsert(knowledge_base_id, vector_points)
step_label = '关键词提取中' if is_economy else '向量化中'
batch_progress = 0.3 + 0.6 * (end / len(chunks))
await IndexingProgressService.publish(
knowledge_base_id, document_id, step='vectorizing',
progress=batch_progress,
message=f'{step_label} {end}/{len(chunks)}',
document_name=doc.name,
)
logger.info(f'文档 {doc.name} 索引进度: {end}/{len(chunks)}')
# 7. 更新文档状态
if not is_economy and failed_embedding_count > 0:
if failed_embedding_count >= segment_count:
doc.status = 'failed'
doc.error_message = f'所有 {segment_count} 个分段向量化失败'
else:
doc.status = 'completed'
doc.error_message = f'{failed_embedding_count}/{segment_count} 个分段向量化失败'
else:
doc.status = 'completed'
doc.segment_count = segment_count
doc.token_count = total_token_count
doc.char_count = total_char_count
doc.indexing_completed_at = datetime.now()
# 8. 更新知识库统计
await self._update_kb_stats(knowledge_base_id)
await self._db.commit()
await IndexingProgressService.publish(
knowledge_base_id, document_id, step='completed', progress=1.0,
message=f'索引完成: {segment_count} 个分段',
document_name=doc.name,
)
logger.info(f'文档 {doc.name} 索引完成: {segment_count} 个分段, {total_token_count} tokens')
return segment_count, total_token_count
except Exception as e:
logger.exception(f'文档索引失败: {e}')
doc.status = 'failed'
doc.error_message = str(e)[:500]
await self._db.commit()
await IndexingProgressService.publish(
knowledge_base_id, document_id, step='failed', progress=0.0,
message='索引失败', document_name=doc.name,
error=str(e)[:200],
)
raise
async def reindex_document(self, knowledge_base_id: str, document_id: str) -> Tuple[int, int]:
"""重新索引文档(删除旧分段后重新处理)"""
return await self.index_document(knowledge_base_id, document_id)
async def index_segment(
self,
knowledge_base_id: str,
segment_id: str,
) -> bool:
"""
为单个分段生成向量(用于手动添加或更新分段后)
"""
kb_result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.is_deleted == False
)
)
kb = kb_result.scalar_one_or_none()
if not kb or not kb.embedding_model_id:
return False
seg_result = await self._db.execute(
select(KnowledgeSegment).where(
KnowledgeSegment.id == segment_id,
KnowledgeSegment.is_deleted == False
)
)
segment = seg_result.scalar_one_or_none()
if not segment:
return False
try:
embedding = await self._embedding_service.embed_text(
model_id=kb.embedding_model_id,
text=segment.content,
dimensions=kb.embedding_dimensions if kb.embedding_dimensions else None,
)
# 确保 collection 存在
vector_size = kb.embedding_dimensions or 1536
await self._vector_store.ensure_collection(knowledge_base_id, vector_size)
# 写入 Qdrant
await self._vector_store.upsert(knowledge_base_id, [VectorPoint(
id=str(segment_id),
vector=embedding,
payload={
'document_id': str(segment.document_id),
'knowledge_base_id': knowledge_base_id,
'position': segment.position or 0,
},
)])
segment.embedding_status = 'completed'
await self._db.commit()
return True
except Exception as e:
logger.error(f'分段向量化失败: {e}')
segment.embedding_status = 'failed'
await self._db.commit()
return False
async def _extract_text(self, file_id: str) -> str:
"""从文件管理系统提取文本内容(启用 OCR 支持图片和扫描版 PDF)"""
from core.file_manager.service import FileManagerService
text_content = await FileManagerService.get_file_text_content(
self._db, file_id, enable_ocr=True
)
if not text_content:
raise ValueError('无法提取文件文本内容')
return text_content
async def _delete_document_segments(self, document_id: str, knowledge_base_id: str):
"""删除文档的所有分段(业务数据库 + Qdrant)"""
# 先从 Qdrant 删除该文档的所有向量
try:
await self._vector_store.delete_by_filter(
knowledge_base_id,
filter_conditions={'document_id': document_id},
)
except Exception as e:
logger.warning(f'从 Qdrant 删除文档向量失败: {e}')
# 再从业务数据库删除分段记录
await self._db.execute(
delete(KnowledgeSegment).where(
KnowledgeSegment.document_id == document_id
)
)
async def _update_kb_stats(self, knowledge_base_id: str):
"""更新知识库统计信息"""
# 文档数
doc_count_result = await self._db.execute(
select(func.count()).select_from(KnowledgeDocument).where(
KnowledgeDocument.knowledge_base_id == knowledge_base_id,
KnowledgeDocument.is_deleted == False,
)
)
doc_count = doc_count_result.scalar() or 0
# 分段数和 Token 数
seg_stats = await self._db.execute(
select(
func.count(),
func.coalesce(func.sum(KnowledgeSegment.token_count), 0),
func.coalesce(func.sum(KnowledgeSegment.char_count), 0),
).where(
KnowledgeSegment.knowledge_base_id == knowledge_base_id,
KnowledgeSegment.is_deleted == False,
)
)
row = seg_stats.one()
seg_count = row[0] or 0
total_tokens = row[1] or 0
total_chars = row[2] or 0
# 更新知识库
kb_result = await self._db.execute(
select(KnowledgeBase).where(KnowledgeBase.id == knowledge_base_id)
)
kb = kb_result.scalar_one_or_none()
if kb:
kb.document_count = doc_count
kb.segment_count = seg_count
kb.total_token_count = total_tokens
kb.total_char_count = total_chars
def _create_llm_caller(self, kb: KnowledgeBase):
"""
创建 Q&A 分块所需的 LLM 调用函数
使用知识库所属应用中配置的第一个 chat 类型模型。
"""
db = self._db
async def llm_caller(system_prompt: str, user_prompt: str) -> str:
from ai_platform.services.llm_service import LLMService
# 查找可用的 chat 模型
from ai_platform.models import LLMModel
model_result = await db.execute(
select(LLMModel).where(
LLMModel.model_type == 'chat',
LLMModel.is_active == True,
LLMModel.is_deleted == False,
).limit(1)
)
model = model_result.scalar_one_or_none()
if not model:
raise ValueError('未找到可用的 chat 模型,无法进行 Q&A 拆分')
llm_service = LLMService(db)
messages = []
if system_prompt:
messages.append({'role': 'system', 'content': system_prompt})
messages.append({'role': 'user', 'content': user_prompt})
response = await llm_service.chat_async(
model_id=str(model.id),
messages=messages,
temperature=0.3,
max_tokens=4096,
)
return response.content or ''
return llm_caller
@staticmethod
def _extract_keywords(text: str, max_keywords: int = 10) -> List[str]:
"""
从文本中提取关键词(经济模式使用)
使用简单的 TF 统计提取高频词,无需外部依赖。
"""
import re
if not text:
return []
# 中文分词(简单按标点和空格分割)
# 提取中文词组(2-4字)和英文单词
chinese_words = re.findall(r'[\u4e00-\u9fff]{2,4}', text)
english_words = [w.lower() for w in re.findall(r'[a-zA-Z]{3,}', text)]
all_words = chinese_words + english_words
# 停用词(简单列表)
stop_words = {
'', '', '', '', '', '', '', '', '', '', '', '',
'一个', '', '', '', '', '', '', '', '', '', '',
'没有', '', '', '自己', '', '', '', '', '我们', '他们',
'可以', '这个', '那个', '什么', '如果', '因为', '所以', '但是', '而且',
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can',
'had', 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'been',
'this', 'that', 'with', 'from', 'they', 'will', 'would', 'there',
}
# 词频统计
word_freq = {}
for word in all_words:
if word in stop_words or len(word) < 2:
continue
word_freq[word] = word_freq.get(word, 0) + 1
# 按频率排序取 top
sorted_words = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
return [w for w, _ in sorted_words[:max_keywords]]
@staticmethod
def _count_words(text: str) -> int:
"""计算词数(中文按字计算,英文按空格分词)"""
if not text:
return 0
import re
# 中文字符数
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
# 英文单词数
english_words = len(re.findall(r'[a-zA-Z]+', text))
return chinese_chars + english_words
@staticmethod
def _estimate_tokens(text: str) -> int:
"""粗略估算文本的 Token 数"""
if not text:
return 0
# 中文约 1 字 = 1.5 token,英文约 4 字符 = 1 token
# 简单混合估算
chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
other_chars = len(text) - chinese_chars
return int(chinese_chars * 1.5 + other_chars / 4)
@@ -1,267 +0,0 @@
"""
知识库服务
知识库 CRUD 操作
数据权限:
- 使用 get_list_with_data_scope() 自动应用数据权限
- 支持本人、本部门、本部门及下级、全部等数据范围
"""
import logging
from typing import Optional, List, Tuple
from sqlalchemy import select, func, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.knowledge.models import KnowledgeBase, KnowledgeDocument, KnowledgeSegment
from ai_platform.knowledge.schemas.knowledge_base_schema import KnowledgeBaseCreate, KnowledgeBaseUpdate
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
logger = logging.getLogger(__name__)
# 资源类型(用于数据权限配置)
RESOURCE_TYPE = "knowledge_base"
RESOURCE_DISPLAY_NAME = "知识库管理"
class KnowledgeService:
"""知识库服务"""
def __init__(self, db: AsyncSession):
self._db = db
async def get_list(
self,
page: int = 1,
page_size: int = 20,
name: Optional[str] = None,
status: Optional[str] = None,
application_id: Optional[str] = None,
) -> Tuple[List[KnowledgeBase], int]:
"""获取知识库列表"""
query = select(KnowledgeBase).where(KnowledgeBase.is_deleted == False)
if application_id:
query = query.where(or_(
KnowledgeBase.application_id == application_id,
and_(KnowledgeBase.application_id.is_(None), KnowledgeBase.is_global == True)
))
if name:
query = query.where(KnowledgeBase.name.ilike(f"%{name}%"))
if status:
query = query.where(KnowledgeBase.status == status)
# 总数
count_result = await self._db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(KnowledgeBase.sort.desc(), KnowledgeBase.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await self._db.execute(query)
items = result.scalars().all()
return items, total
async def get_list_with_data_scope(
self,
page: int = 1,
page_size: int = 20,
name: Optional[str] = None,
status: Optional[str] = None,
application_id: Optional[str] = None,
) -> Tuple[List[KnowledgeBase], int]:
"""
获取知识库列表(带数据权限过滤)
自动从上下文获取当前用户信息,应用数据权限过滤
"""
conditions = [KnowledgeBase.is_deleted == False]
if application_id:
conditions.append(or_(
KnowledgeBase.application_id == application_id,
and_(KnowledgeBase.application_id.is_(None), KnowledgeBase.is_global == True)
))
if name:
conditions.append(KnowledgeBase.name.ilike(f"%{name}%"))
if status:
conditions.append(KnowledgeBase.status == status)
# 获取数据权限过滤条件并应用
data_scope_filter = await get_data_scope_filter(self._db, RESOURCE_TYPE)
scope_conditions = apply_data_scope_to_conditions(KnowledgeBase, data_scope_filter)
conditions.extend(scope_conditions)
# 总数
query = select(KnowledgeBase).where(and_(*conditions))
count_result = await self._db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(KnowledgeBase.sort.desc(), KnowledgeBase.sys_create_datetime.desc())
query = query.offset(offset).limit(page_size)
result = await self._db.execute(query)
items = result.scalars().all()
return items, total
async def get_by_id(self, kb_id: str) -> Optional[KnowledgeBase]:
"""获取知识库详情"""
result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id == kb_id,
KnowledgeBase.is_deleted == False
)
)
return result.scalar_one_or_none()
async def get_by_code(self, code: str) -> Optional[KnowledgeBase]:
"""根据编码获取知识库"""
result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.code == code,
KnowledgeBase.is_deleted == False
)
)
return result.scalar_one_or_none()
async def create(self, data: KnowledgeBaseCreate) -> KnowledgeBase:
"""创建知识库"""
# 检查编码唯一性
existing = await self.get_by_code(data.code)
if existing:
raise ValueError(f'知识库编码 {data.code} 已存在')
kb_data = data.model_dump()
# 自动检测 embedding 模型的真实维度
if data.embedding_model_id:
try:
from ai_platform.knowledge.services.embedding_service import EmbeddingService
embedding_service = EmbeddingService(self._db)
real_dim = await embedding_service.get_embedding_dimensions(data.embedding_model_id)
kb_data['embedding_dimensions'] = real_dim
logger.info(f'自动检测 embedding 维度: {real_dim}')
except Exception as e:
logger.warning(f'自动检测 embedding 维度失败,使用默认值: {e}')
kb = KnowledgeBase(**kb_data)
# 自动填充创建人和部门
from utils.context import get_current_user_info_from_context
user_info = get_current_user_info_from_context()
if user_info:
if not kb.sys_creator_id:
kb.sys_creator_id = user_info.get('user_id')
if not kb.sys_dept_id and user_info.get('dept_id'):
kb.sys_dept_id = user_info.get('dept_id')
self._db.add(kb)
await self._db.commit()
await self._db.refresh(kb)
return kb
async def update(self, kb_id: str, data: KnowledgeBaseUpdate) -> Optional[KnowledgeBase]:
"""更新知识库"""
kb = await self.get_by_id(kb_id)
if not kb:
return None
update_data = data.model_dump(exclude_unset=True)
# 如果更换了 embedding 模型,自动重新检测维度
new_model_id = update_data.get('embedding_model_id')
if new_model_id and new_model_id != kb.embedding_model_id:
try:
from ai_platform.knowledge.services.embedding_service import EmbeddingService
embedding_service = EmbeddingService(self._db)
real_dim = await embedding_service.get_embedding_dimensions(new_model_id)
update_data['embedding_dimensions'] = real_dim
logger.info(f'更换模型后自动检测 embedding 维度: {real_dim}')
except Exception as e:
logger.warning(f'自动检测 embedding 维度失败: {e}')
for key, value in update_data.items():
setattr(kb, key, value)
await self._db.commit()
await self._db.refresh(kb)
return kb
async def delete(self, kb_id: str) -> bool:
"""删除知识库(软删除 + 清理 Qdrant collection"""
kb = await self.get_by_id(kb_id)
if not kb:
return False
kb.is_deleted = True
# 同时软删除所有文档和分段
doc_result = await self._db.execute(
select(KnowledgeDocument).where(
KnowledgeDocument.knowledge_base_id == kb_id,
KnowledgeDocument.is_deleted == False
)
)
docs = doc_result.scalars().all()
for doc in docs:
doc.is_deleted = True
# 软删除分段
from sqlalchemy import update
await self._db.execute(
update(KnowledgeSegment).where(
KnowledgeSegment.knowledge_base_id == kb_id
).values(is_deleted=True)
)
await self._db.commit()
# 删除 Qdrant 中对应的 collection
try:
from ai_platform.knowledge.vector_store import get_vector_store
vector_store = get_vector_store()
await vector_store.delete_collection(kb_id)
except Exception as e:
logger.warning(f'删除 Qdrant collection 失败: {e}')
return True
async def get_simple_list(self, application_id: Optional[str] = None) -> List[dict]:
"""获取知识库简单列表(用于下拉选择)"""
query = select(
KnowledgeBase.id,
KnowledgeBase.name,
KnowledgeBase.code,
KnowledgeBase.document_count,
KnowledgeBase.segment_count,
).where(
KnowledgeBase.is_deleted == False,
KnowledgeBase.status == 'active',
)
if application_id:
query = query.where(or_(
KnowledgeBase.application_id == application_id,
and_(KnowledgeBase.application_id.is_(None), KnowledgeBase.is_global == True)
))
query = query.order_by(KnowledgeBase.sort.desc(), KnowledgeBase.sys_create_datetime.desc())
result = await self._db.execute(query)
rows = result.all()
return [
{
'id': row.id,
'name': row.name,
'code': row.code,
'document_count': row.document_count or 0,
'segment_count': row.segment_count or 0,
}
for row in rows
]
@@ -1,182 +0,0 @@
"""
Rerank 重排序服务
通过 Rerank 模型对检索结果进行重新排序,提升检索质量。
支持 Jina/Cohere 风格的 Rerank API(大多数提供商兼容此接口)。
"""
import logging
from dataclasses import dataclass
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
@dataclass
class RerankResult:
"""重排序结果"""
index: int
relevance_score: float
class RerankService:
"""
Rerank 重排序服务
通过模型 ID 获取对应的提供商,调用 Rerank API 对文档进行重排序。
支持两种 API 风格:
- Jina/Cohere 风格:POST /v1/rerank
- OpenAI 兼容风格(部分提供商)
"""
def __init__(self, db: AsyncSession):
self._db = db
self._client_cache = {}
async def _get_client_config(self, model_id: str):
"""
根据模型 ID 获取 API 配置
Returns:
(base_url, api_key, model_name)
"""
from ai_platform.models import LLMModel, LLMProvider
result = await self._db.execute(
select(LLMModel).where(
LLMModel.id == model_id,
LLMModel.is_active == True,
LLMModel.is_deleted == False
)
)
model = result.scalar_one_or_none()
if not model:
raise ValueError(f'Rerank 模型不存在或已禁用: {model_id}')
if model.model_type != 'rerank':
raise ValueError(f'模型 {model.display_name} 不是 Rerank 类型')
provider_result = await self._db.execute(
select(LLMProvider).where(
LLMProvider.id == model.provider_id,
LLMProvider.is_active == True,
LLMProvider.is_deleted == False
)
)
provider = provider_result.scalar_one_or_none()
if not provider:
raise ValueError('Rerank 模型对应的提供商不存在或已禁用')
if provider.provider_type == 'ollama':
base_url = (provider.ollama_host or 'http://localhost:11434').rstrip('/') + '/v1'
else:
base_url = provider.api_base or 'https://api.openai.com/v1'
api_key = provider.api_key or 'ollama'
return base_url, api_key, model.model_name
async def rerank(
self,
model_id: str,
query: str,
documents: List[str],
top_n: Optional[int] = None,
) -> List[RerankResult]:
"""
对文档列表进行重排序
Args:
model_id: Rerank 模型 ID
query: 查询文本
documents: 待排序的文档列表
top_n: 返回前 N 个结果(默认返回全部)
Returns:
按相关性降序排列的 RerankResult 列表
"""
if not documents:
return []
if top_n is None:
top_n = len(documents)
base_url, api_key, model_name = await self._get_client_config(model_id)
try:
return await self._call_rerank_api(
base_url=base_url,
api_key=api_key,
model_name=model_name,
query=query,
documents=documents,
top_n=top_n,
)
except Exception as e:
logger.error(f'Rerank 调用失败: {e}')
raise ValueError(f'Rerank 调用失败: {str(e)}')
async def _call_rerank_api(
self,
base_url: str,
api_key: str,
model_name: str,
query: str,
documents: List[str],
top_n: int,
) -> List[RerankResult]:
"""
调用 Rerank APIJina/Cohere 兼容风格)
POST {base_url}/rerank
{
"model": "...",
"query": "...",
"documents": ["...", "..."],
"top_n": 5
}
Response:
{
"results": [
{"index": 0, "relevance_score": 0.95},
{"index": 2, "relevance_score": 0.87},
...
]
}
"""
import httpx
url = base_url.rstrip('/') + '/rerank'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
payload = {
'model': model_name,
'query': query,
'documents': documents,
'top_n': top_n,
}
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
# 解析结果(兼容 Jina/Cohere/通义千问 等格式)
raw_results = data.get('results', [])
results = []
for item in raw_results:
results.append(RerankResult(
index=item.get('index', 0),
relevance_score=item.get('relevance_score', 0.0),
))
# 按相关性降序排序
results.sort(key=lambda r: r.relevance_score, reverse=True)
return results
@@ -1,666 +0,0 @@
"""
检索服务
支持向量检索、全文检索、混合检索(RRF 融合)
向量检索通过 Qdrant 向量数据库实现,全文检索通过业务数据库 SQL 实现
"""
import logging
import time
from typing import List, Optional, Dict, Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.knowledge.models import KnowledgeBase, KnowledgeDocument, KnowledgeSegment
from ai_platform.knowledge.services.embedding_service import EmbeddingService
from ai_platform.knowledge.schemas.segment_schema import RetrievalResult
from ai_platform.knowledge.vector_store import get_vector_store
logger = logging.getLogger(__name__)
# RRF 融合常数
RRF_K = 60
class RetrievalService:
"""
检索服务
支持三种检索模式:
- vector: 纯向量检索(通过 Qdrant
- fulltext: 纯全文检索(通过业务数据库 LIKE + 关键词匹配)
- hybrid: 混合检索(向量 + 全文,RRF 融合排序)
"""
def __init__(self, db: AsyncSession):
self._db = db
self._embedding_service = EmbeddingService(db)
self._vector_store = get_vector_store()
async def retrieve(
self,
query: str,
knowledge_base_ids: List[str],
top_k: int = 5,
score_threshold: float = 0.5,
retrieval_mode: Optional[str] = None,
rerank_enabled: Optional[bool] = None,
rerank_model_id: Optional[str] = None,
metadata_filter: Optional[Dict[str, Any]] = None,
) -> List[RetrievalResult]:
"""
检索知识库
Args:
query: 查询文本
knowledge_base_ids: 知识库 ID 列表
top_k: 返回数量
score_threshold: 相似度阈值
retrieval_mode: 检索模式(不传则使用第一个知识库的配置)
rerank_enabled: 是否启用重排序(不传则使用知识库配置)
rerank_model_id: 重排序模型 ID(不传则使用知识库配置)
metadata_filter: 元数据过滤条件
Returns:
检索结果列表
"""
if not query or not knowledge_base_ids:
return []
start_time = time.time()
# 获取知识库配置
kb_map = await self._get_knowledge_bases(knowledge_base_ids)
if not kb_map:
return []
first_kb = list(kb_map.values())[0]
# 经济模式强制使用全文检索
is_economy = getattr(first_kb, 'indexing_technique', 'high_quality') == 'economy'
# 确定检索模式
if is_economy:
retrieval_mode = 'fulltext'
elif not retrieval_mode:
retrieval_mode = first_kb.retrieval_mode or 'hybrid'
# 确定 rerank 配置(参数优先,否则使用知识库配置;经济模式禁用 rerank)
if is_economy:
rerank_enabled = False
else:
if rerank_enabled is None:
rerank_enabled = first_kb.rerank_enabled or False
if not rerank_model_id:
rerank_model_id = first_kb.rerank_model_id
# 获取 embedding 模型(使用第一个知识库的配置)
embedding_model_id = first_kb.embedding_model_id
# 如果启用了 rerank,初始检索多取一些候选结果
candidate_multiplier = 3 if rerank_enabled and rerank_model_id else 2
candidate_top_k = top_k * candidate_multiplier
results = []
if retrieval_mode == 'vector':
results = await self._vector_search(
query, knowledge_base_ids, embedding_model_id,
top_k=candidate_top_k, score_threshold=score_threshold,
dimensions=first_kb.embedding_dimensions,
metadata_filter=metadata_filter,
)
for r in results:
r.match_source = 'vector'
elif retrieval_mode == 'fulltext':
results = await self._fulltext_search(
query, knowledge_base_ids, top_k=candidate_top_k,
metadata_filter=metadata_filter,
)
for r in results:
r.match_source = 'fulltext'
elif retrieval_mode == 'hybrid':
# 混合检索:向量 + 全文,RRF 融合
vector_results = await self._vector_search(
query, knowledge_base_ids, embedding_model_id,
top_k=candidate_top_k, score_threshold=score_threshold,
dimensions=first_kb.embedding_dimensions,
metadata_filter=metadata_filter,
)
for r in vector_results:
r.match_source = 'vector'
fulltext_results = await self._fulltext_search(
query, knowledge_base_ids, top_k=candidate_top_k,
metadata_filter=metadata_filter,
)
for r in fulltext_results:
r.match_source = 'fulltext'
results = self._rrf_merge(vector_results, fulltext_results)
# 多知识库权重加权
if len(kb_map) > 1:
for r in results:
kb = kb_map.get(r.knowledge_base_id)
weight = getattr(kb, 'retrieval_weight', 1.0) or 1.0 if kb else 1.0
if weight != 1.0:
r.score = round(r.score * weight, 4)
results.sort(key=lambda x: x.score, reverse=True)
# 过滤低分结果(rerank 前先粗筛)
if not rerank_enabled:
results = [r for r in results if r.score >= score_threshold]
# Rerank 重排序
if rerank_enabled and rerank_model_id and results:
results = await self._rerank_results(query, results, rerank_model_id, top_k)
# rerank 后再按阈值过滤
results = [r for r in results if r.score >= score_threshold]
# 截断到 top_k
results = results[:top_k]
# 内容级去重(多知识库检索时可能有重复内容)
results = self._deduplicate_results(results)
# 标注优先匹配:将匹配到的标注结果插入到最前面
annotation_results = await self._match_annotations(
query, knowledge_base_ids, embedding_model_id,
score_threshold=score_threshold,
dimensions=first_kb.embedding_dimensions,
)
if annotation_results:
for r in annotation_results:
r.match_source = 'annotation'
# 标注结果置顶,去重后合并
existing_ids = {r.segment_id for r in annotation_results}
results = annotation_results + [r for r in results if r.segment_id not in existing_ids]
results = results[:top_k]
# 填充知识库名称和文档名称
await self._fill_names(results, kb_map)
# 填充父分段内容(Small-to-Big 模式)
await self._fill_parent_content(results)
# 更新命中次数
segment_ids = [r.segment_id for r in results]
if segment_ids:
await self._update_hit_counts(segment_ids)
elapsed = int((time.time() - start_time) * 1000)
rerank_info = ', rerank=ON' if rerank_enabled else ''
logger.info(f'检索完成: {len(results)} 条结果, 耗时 {elapsed}ms, 模式={retrieval_mode}{rerank_info}')
return results
async def _rerank_results(
self,
query: str,
results: List[RetrievalResult],
rerank_model_id: str,
top_n: int,
) -> List[RetrievalResult]:
"""使用 Rerank 模型对检索结果重排序"""
from ai_platform.knowledge.services.rerank_service import RerankService
try:
rerank_service = RerankService(self._db)
documents = [r.content for r in results]
rerank_results = await rerank_service.rerank(
model_id=rerank_model_id,
query=query,
documents=documents,
top_n=top_n,
)
# 按 rerank 分数重新排列结果
reranked = []
for rr in rerank_results:
if 0 <= rr.index < len(results):
result = results[rr.index]
result.score = round(rr.relevance_score, 4)
reranked.append(result)
logger.info(f'Rerank 完成: {len(results)} -> {len(reranked)} 条结果')
return reranked
except Exception as e:
logger.warning(f'Rerank 失败,使用原始排序: {e}')
return results
async def _vector_search(
self,
query: str,
knowledge_base_ids: List[str],
embedding_model_id: str,
top_k: int = 10,
score_threshold: float = 0.0,
dimensions: Optional[int] = None,
metadata_filter: Optional[Dict[str, Any]] = None,
) -> List[RetrievalResult]:
"""向量检索(通过 Qdrant 余弦相似度)"""
if not embedding_model_id:
logger.warning('未配置 Embedding 模型,跳过向量检索')
return []
try:
query_embedding = await self._embedding_service.embed_text(
model_id=embedding_model_id,
text=query,
dimensions=dimensions,
)
except Exception as e:
logger.error(f'查询向量化失败: {e}')
return []
# 对每个知识库分别搜索(每个知识库对应一个 Qdrant collection
all_hits = []
for kb_id in knowledge_base_ids:
hits = await self._vector_store.search(
knowledge_base_id=kb_id,
query_vector=query_embedding,
top_k=top_k,
score_threshold=score_threshold,
)
all_hits.extend(hits)
if not all_hits:
return []
# 按分数排序
all_hits.sort(key=lambda h: h.score, reverse=True)
all_hits = all_hits[:top_k]
# 从业务数据库获取分段详情
segment_ids = [h.id for h in all_hits]
score_map = {h.id: h.score for h in all_hits}
seg_result = await self._db.execute(
select(KnowledgeSegment).where(
KnowledgeSegment.id.in_(segment_ids),
KnowledgeSegment.is_deleted == False,
KnowledgeSegment.enabled == True,
)
)
segments = {str(s.id): s for s in seg_result.scalars().all()}
results = []
for hit in all_hits:
seg = segments.get(hit.id)
if not seg:
continue
# Q&A 模式:question 用于匹配,返回 answer 作为 content
content = seg.answer if seg.answer else seg.content
meta = dict(seg.extra_metadata) if seg.extra_metadata else {}
if seg.answer:
meta['question'] = seg.content
meta['chunk_mode'] = 'qa'
results.append(RetrievalResult(
segment_id=str(seg.id),
document_id=str(seg.document_id),
knowledge_base_id=str(seg.knowledge_base_id),
content=content,
score=round(score_map.get(hit.id, 0.0), 4),
token_count=seg.token_count or 0,
page_number=seg.page_number,
metadata=meta,
keywords=seg.keywords,
))
return results
async def _fulltext_search(
self,
query: str,
knowledge_base_ids: List[str],
top_k: int = 10,
metadata_filter: Optional[Dict[str, Any]] = None,
) -> List[RetrievalResult]:
"""全文检索(基于 ORM LIKE 和关键词匹配,兼容所有数据库)"""
import re
keywords = re.split(r'[\s,,。.!?;;、]+', query)
keywords = [k.strip() for k in keywords if k.strip() and len(k.strip()) >= 2]
if not keywords:
keywords = [query.strip()]
# 使用 SQLAlchemy ORM 构建查询(兼容 PG / MySQL 等)
from sqlalchemy import or_
keyword_conditions = [
func.lower(KnowledgeSegment.content).contains(kw.lower())
for kw in keywords[:5]
]
conditions = [
KnowledgeSegment.knowledge_base_id.in_(knowledge_base_ids),
KnowledgeSegment.is_deleted == False,
KnowledgeSegment.enabled == True,
or_(*keyword_conditions),
]
# 元数据过滤
if metadata_filter:
conditions.extend(self._build_metadata_conditions(metadata_filter))
stmt = (
select(KnowledgeSegment)
.where(*conditions)
.order_by(KnowledgeSegment.char_count.asc())
.limit(top_k)
)
result = await self._db.execute(stmt)
segments = result.scalars().all()
# 计算简单的关键词匹配分数
results = []
for seg in segments:
content_lower = seg.content.lower()
match_count = sum(1 for kw in keywords if kw.lower() in content_lower)
score = match_count / len(keywords) if keywords else 0
# Q&A 模式:返回 answer 作为 content
content = seg.answer if seg.answer else seg.content
meta = dict(seg.extra_metadata) if seg.extra_metadata else {}
if seg.answer:
meta['question'] = seg.content
meta['chunk_mode'] = 'qa'
results.append(RetrievalResult(
segment_id=str(seg.id),
document_id=str(seg.document_id),
knowledge_base_id=str(seg.knowledge_base_id),
content=content,
score=round(score, 4),
token_count=seg.token_count or 0,
page_number=seg.page_number,
metadata=meta,
keywords=seg.keywords,
))
results.sort(key=lambda x: x.score, reverse=True)
return results
def _rrf_merge(
self,
vector_results: List[RetrievalResult],
fulltext_results: List[RetrievalResult],
) -> List[RetrievalResult]:
"""
RRF (Reciprocal Rank Fusion) 融合排序
RRF_score = sum(1 / (k + rank_i)) for each result list
"""
scores = {} # segment_id -> (rrf_score, result)
# 向量检索结果排名
for rank, result in enumerate(vector_results):
rrf_score = 1.0 / (RRF_K + rank + 1)
if result.segment_id in scores:
old_score, old_result = scores[result.segment_id]
scores[result.segment_id] = (old_score + rrf_score, old_result)
else:
scores[result.segment_id] = (rrf_score, result)
# 全文检索结果排名
for rank, result in enumerate(fulltext_results):
rrf_score = 1.0 / (RRF_K + rank + 1)
if result.segment_id in scores:
old_score, old_result = scores[result.segment_id]
scores[result.segment_id] = (old_score + rrf_score, old_result)
else:
scores[result.segment_id] = (rrf_score, result)
# 按 RRF 分数排序
sorted_items = sorted(scores.values(), key=lambda x: x[0], reverse=True)
if not sorted_items:
return []
# 归一化分数到 0-1
# RRF 单条结果的理论最大分数为 2/(k+1)(同时出现在两个列表的第一名)
# 使用理论最大值归一化,避免单条结果被归一化为 100%
theoretical_max = 2.0 / (RRF_K + 1)
results = []
for rrf_score, result in sorted_items:
normalized_score = min(rrf_score / theoretical_max, 1.0)
result.score = round(normalized_score, 4)
results.append(result)
return results
@staticmethod
def _deduplicate_results(results: List[RetrievalResult], similarity_threshold: float = 0.95) -> List[RetrievalResult]:
"""
内容级去重(多知识库检索时可能有重复内容)
使用内容前 200 字符的相似度判断是否重复,保留分数最高的。
"""
if len(results) <= 1:
return results
deduplicated = []
seen_contents = []
for r in results:
content_key = r.content[:200].strip().lower()
is_dup = False
for seen in seen_contents:
# 简单的字符重叠率判断
if content_key == seen:
is_dup = True
break
# 如果前 200 字符有 95% 以上重叠,视为重复
shorter = min(len(content_key), len(seen))
if shorter > 0:
common = sum(1 for a, b in zip(content_key, seen) if a == b)
if common / shorter >= similarity_threshold:
is_dup = True
break
if not is_dup:
deduplicated.append(r)
seen_contents.append(content_key)
return deduplicated
async def _fill_parent_content(self, results: List[RetrievalResult]):
"""填充父分段内容(Small-to-Big 模式)"""
if not results:
return
# 获取所有 segment_id,查询是否有 parent_segment_id
segment_ids = [r.segment_id for r in results if r.segment_id]
if not segment_ids:
return
seg_result = await self._db.execute(
select(KnowledgeSegment.id, KnowledgeSegment.parent_segment_id).where(
KnowledgeSegment.id.in_(segment_ids),
KnowledgeSegment.is_deleted == False,
)
)
parent_map = {}
for row in seg_result:
if row.parent_segment_id:
parent_map[str(row.id)] = row.parent_segment_id
if not parent_map:
return
# 批量获取父分段内容
parent_ids = list(set(parent_map.values()))
parent_result = await self._db.execute(
select(KnowledgeSegment.id, KnowledgeSegment.content).where(
KnowledgeSegment.id.in_(parent_ids),
KnowledgeSegment.is_deleted == False,
)
)
parent_content_map = {str(row.id): row.content for row in parent_result}
# 填充到结果中
for r in results:
parent_id = parent_map.get(r.segment_id)
if parent_id:
r.parent_content = parent_content_map.get(str(parent_id))
@staticmethod
def _build_metadata_conditions(metadata_filter: Dict[str, Any]) -> list:
"""构建元数据过滤条件(基于 JSON 字段,跨数据库兼容)"""
from app.db_compat import json_extract
conditions = []
for key, value in metadata_filter.items():
if value is not None:
# 使用跨数据库兼容的 json_extract 函数
try:
conditions.append(
json_extract(KnowledgeSegment.extra_metadata, key) == str(value)
)
except Exception:
pass
return conditions
async def _get_knowledge_bases(self, kb_ids: List[str]) -> Dict[str, KnowledgeBase]:
"""批量获取知识库"""
result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id.in_(kb_ids),
KnowledgeBase.is_deleted == False,
)
)
kbs = result.scalars().all()
return {str(kb.id): kb for kb in kbs}
async def _fill_names(self, results: List[RetrievalResult], kb_map: Dict[str, KnowledgeBase]):
"""填充知识库名称和文档名称"""
if not results:
return
# 获取文档名称
doc_ids = list({r.document_id for r in results})
doc_result = await self._db.execute(
select(KnowledgeDocument.id, KnowledgeDocument.name).where(
KnowledgeDocument.id.in_(doc_ids)
)
)
doc_name_map = {row.id: row.name for row in doc_result}
for result in results:
result.document_name = doc_name_map.get(result.document_id, '')
kb = kb_map.get(result.knowledge_base_id)
result.knowledge_base_name = kb.name if kb else ''
async def _update_hit_counts(self, segment_ids: List[str]):
"""更新分段命中次数(兼容所有数据库)"""
if not segment_ids:
return
try:
from sqlalchemy import update
await self._db.execute(
update(KnowledgeSegment)
.where(KnowledgeSegment.id.in_(segment_ids))
.values(hit_count=func.coalesce(KnowledgeSegment.hit_count, 0) + 1)
)
await self._db.commit()
except Exception as e:
logger.warning(f'更新命中次数失败: {e}')
async def _match_annotations(
self,
query: str,
knowledge_base_ids: List[str],
embedding_model_id: Optional[str],
score_threshold: float = 0.5,
dimensions: Optional[int] = None,
max_results: int = 3,
) -> List[RetrievalResult]:
"""
匹配标注(Q&A 对)
通过向量相似度匹配标注的 question,返回对应的 answer。
标注结果优先级高于普通分段。
"""
from ai_platform.knowledge.models import KnowledgeAnnotation
if not embedding_model_id:
return []
try:
# 向量化查询
query_embedding = await self._embedding_service.embed_text(
model_id=embedding_model_id,
text=query,
dimensions=dimensions if dimensions else None,
)
# 在 Qdrant 中搜索标注向量(payload.type == 'annotation'
all_hits = []
for kb_id in knowledge_base_ids:
try:
hits = await self._vector_store.search(
knowledge_base_id=kb_id,
query_vector=query_embedding,
top_k=max_results,
score_threshold=score_threshold,
filter_conditions={'type': 'annotation'},
)
all_hits.extend(hits)
except Exception as e:
logger.warning(f'标注向量搜索失败 (kb={kb_id}): {e}')
continue
if not all_hits:
return []
# 按分数排序取 top
all_hits.sort(key=lambda h: h.score, reverse=True)
all_hits = all_hits[:max_results]
# 从数据库获取标注详情
annotation_ids = [h.id for h in all_hits]
score_map = {h.id: h.score for h in all_hits}
ann_result = await self._db.execute(
select(KnowledgeAnnotation).where(
KnowledgeAnnotation.id.in_(annotation_ids),
KnowledgeAnnotation.is_deleted == False,
KnowledgeAnnotation.enabled == True,
)
)
annotations = {str(a.id): a for a in ann_result.scalars().all()}
results = []
for hit in all_hits:
ann = annotations.get(hit.id)
if not ann:
continue
# 标注结果:content 返回 answersegment_id 用 annotation id
results.append(RetrievalResult(
segment_id=str(ann.id),
document_id='',
document_name='[Q&A]',
knowledge_base_id=str(ann.knowledge_base_id),
content=ann.answer,
score=round(score_map.get(hit.id, 0.0), 4),
token_count=0,
metadata={'type': 'annotation', 'question': ann.question},
))
# 更新标注命中次数
if annotation_ids:
try:
from sqlalchemy import update
await self._db.execute(
update(KnowledgeAnnotation)
.where(KnowledgeAnnotation.id.in_(annotation_ids))
.values(hit_count=func.coalesce(KnowledgeAnnotation.hit_count, 0) + 1)
)
await self._db.commit()
except Exception:
pass
return results
except Exception as e:
logger.warning(f'标注匹配失败: {e}')
return []
@@ -1,45 +0,0 @@
"""
向量存储模块
提供可插拔的向量存储后端,支持 Qdrant 等专业向量数据库。
与业务数据库完全解耦,segment 表只存业务数据,向量数据存在向量数据库中。
"""
from ai_platform.knowledge.vector_store.base import BaseVectorStore, VectorPoint, VectorSearchResult
from ai_platform.knowledge.vector_store.qdrant_store import QdrantVectorStore
__all__ = [
'BaseVectorStore',
'VectorPoint',
'VectorSearchResult',
'QdrantVectorStore',
'get_vector_store',
]
# 单例缓存
_vector_store_instance: BaseVectorStore | None = None
def get_vector_store() -> BaseVectorStore:
"""
工厂函数:根据配置获取向量存储实例(单例)
"""
global _vector_store_instance
if _vector_store_instance is not None:
return _vector_store_instance
from app.config import settings
store_type = getattr(settings, 'VECTOR_STORE_TYPE', 'qdrant')
if store_type == 'qdrant':
_vector_store_instance = QdrantVectorStore(
host=getattr(settings, 'QDRANT_HOST', 'localhost'),
port=getattr(settings, 'QDRANT_PORT', 6333),
api_key=getattr(settings, 'QDRANT_API_KEY', None),
grpc_port=getattr(settings, 'QDRANT_GRPC_PORT', 6334),
prefer_grpc=getattr(settings, 'QDRANT_PREFER_GRPC', False),
)
else:
raise ValueError(f'不支持的向量存储类型: {store_type}')
return _vector_store_instance
@@ -1,138 +0,0 @@
"""
向量存储抽象基类
定义向量存储的统一接口,所有向量存储后端必须实现这些方法。
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class VectorPoint:
"""向量数据点"""
id: str
vector: List[float]
payload: Dict[str, Any] = field(default_factory=dict)
@dataclass
class VectorSearchResult:
"""向量搜索结果"""
id: str
score: float
payload: Dict[str, Any] = field(default_factory=dict)
class BaseVectorStore(ABC):
"""
向量存储抽象基类
每个知识库对应一个 collectioncollection 名称格式: kb_{knowledge_base_id}
"""
@staticmethod
def collection_name(knowledge_base_id: str) -> str:
"""生成 collection 名称"""
return f"kb_{knowledge_base_id.replace('-', '_')}"
@abstractmethod
async def ensure_collection(
self,
knowledge_base_id: str,
vector_size: int,
) -> None:
"""
确保 collection 存在,不存在则创建
Args:
knowledge_base_id: 知识库 ID
vector_size: 向量维度
"""
...
@abstractmethod
async def delete_collection(self, knowledge_base_id: str) -> None:
"""
删除 collection(删除知识库时调用)
Args:
knowledge_base_id: 知识库 ID
"""
...
@abstractmethod
async def upsert(
self,
knowledge_base_id: str,
points: List[VectorPoint],
) -> None:
"""
批量写入/更新向量
Args:
knowledge_base_id: 知识库 ID
points: 向量数据点列表
"""
...
@abstractmethod
async def delete(
self,
knowledge_base_id: str,
point_ids: List[str],
) -> None:
"""
批量删除向量
Args:
knowledge_base_id: 知识库 ID
point_ids: 要删除的向量 ID 列表
"""
...
@abstractmethod
async def search(
self,
knowledge_base_id: str,
query_vector: List[float],
top_k: int = 10,
score_threshold: float = 0.0,
filter_conditions: Optional[Dict[str, Any]] = None,
) -> List[VectorSearchResult]:
"""
向量相似度搜索
Args:
knowledge_base_id: 知识库 ID
query_vector: 查询向量
top_k: 返回数量
score_threshold: 最低相似度阈值
filter_conditions: 过滤条件(如 {"document_id": "xxx"}
Returns:
搜索结果列表,按相似度降序排列
"""
...
@abstractmethod
async def delete_by_filter(
self,
knowledge_base_id: str,
filter_conditions: Dict[str, Any],
) -> None:
"""
按条件删除向量(如删除某个文档的所有向量)
Args:
knowledge_base_id: 知识库 ID
filter_conditions: 过滤条件(如 {"document_id": "xxx"}
"""
...
async def health_check(self) -> bool:
"""健康检查"""
return True
@@ -1,260 +0,0 @@
"""
Qdrant 向量存储实现
使用 Qdrant 作为向量数据库后端,通过 qdrant-client 进行交互。
每个知识库对应一个 Qdrant collection。
"""
import logging
import uuid
from typing import Any, Dict, List, Optional
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
FilterSelector,
MatchValue,
PointIdsList,
PointStruct,
VectorParams,
)
from ai_platform.knowledge.vector_store.base import (
BaseVectorStore,
VectorPoint,
VectorSearchResult,
)
logger = logging.getLogger(__name__)
class QdrantVectorStore(BaseVectorStore):
"""
Qdrant 向量存储
特性:
- 高性能向量检索(HNSW 索引)
- 支持 payload 过滤
- 支持 REST 和 gRPC 协议
- 与业务数据库完全解耦
"""
def __init__(
self,
host: str = "localhost",
port: int = 6333,
api_key: Optional[str] = None,
grpc_port: int = 6334,
prefer_grpc: bool = False,
):
self._host = host
self._port = port
self._api_key = api_key
self._grpc_port = grpc_port
self._prefer_grpc = prefer_grpc
self._client: Optional[AsyncQdrantClient] = None
async def _get_client(self) -> AsyncQdrantClient:
"""获取或创建 Qdrant 客户端(懒初始化)"""
if self._client is None:
# 如果 host 已包含协议前缀,直接作为 url 使用;
# 否则拼接 http:// 避免 qdrant-client 对非 localhost 域名自动走 HTTPS
if self._host.startswith("http://") or self._host.startswith("https://"):
url = f"{self._host}:{self._port}"
else:
url = f"http://{self._host}:{self._port}"
self._client = AsyncQdrantClient(
url=url,
api_key=self._api_key,
grpc_port=self._grpc_port,
prefer_grpc=self._prefer_grpc,
timeout=30,
)
return self._client
async def ensure_collection(
self,
knowledge_base_id: str,
vector_size: int,
) -> None:
"""确保 collection 存在且维度匹配"""
client = await self._get_client()
name = self.collection_name(knowledge_base_id)
collections = await client.get_collections()
existing_names = {c.name for c in collections.collections}
if name in existing_names:
# 检查已有 collection 的维度是否匹配
info = await client.get_collection(collection_name=name)
existing_size = info.config.params.vectors.size
if existing_size != vector_size:
logger.warning(
f"Qdrant collection {name} 维度不匹配: "
f"已有={existing_size}, 期望={vector_size},删除重建"
)
await client.delete_collection(collection_name=name)
else:
return
await client.create_collection(
collection_name=name,
vectors_config=VectorParams(
size=vector_size,
distance=Distance.COSINE,
),
)
# 创建 payload 索引,加速过滤查询
await client.create_payload_index(
collection_name=name,
field_name="document_id",
field_schema="keyword",
)
logger.info(f"Qdrant collection 已创建: {name} (dim={vector_size})")
async def delete_collection(self, knowledge_base_id: str) -> None:
"""删除 collection"""
client = await self._get_client()
name = self.collection_name(knowledge_base_id)
try:
await client.delete_collection(collection_name=name)
logger.info(f"Qdrant collection 已删除: {name}")
except Exception as e:
logger.warning(f"删除 Qdrant collection 失败: {name}, {e}")
async def upsert(
self,
knowledge_base_id: str,
points: List[VectorPoint],
) -> None:
"""批量写入/更新向量"""
if not points:
return
client = await self._get_client()
name = self.collection_name(knowledge_base_id)
qdrant_points = [
PointStruct(
id=self._to_uuid(p.id),
vector=p.vector,
payload={**p.payload, 'segment_id': p.id},
)
for p in points
]
# Qdrant 单次 upsert 建议不超过 100 个点
batch_size = 100
for i in range(0, len(qdrant_points), batch_size):
batch = qdrant_points[i:i + batch_size]
await client.upsert(
collection_name=name,
points=batch,
)
async def delete(
self,
knowledge_base_id: str,
point_ids: List[str],
) -> None:
"""批量删除向量"""
if not point_ids:
return
client = await self._get_client()
name = self.collection_name(knowledge_base_id)
uuid_ids = [self._to_uuid(pid) for pid in point_ids]
await client.delete(
collection_name=name,
points_selector=PointIdsList(points=uuid_ids),
)
async def search(
self,
knowledge_base_id: str,
query_vector: List[float],
top_k: int = 10,
score_threshold: float = 0.0,
filter_conditions: Optional[Dict[str, Any]] = None,
) -> List[VectorSearchResult]:
"""向量相似度搜索"""
client = await self._get_client()
name = self.collection_name(knowledge_base_id)
# 构建过滤条件
query_filter = self._build_filter(filter_conditions) if filter_conditions else None
try:
results = await client.search(
collection_name=name,
query_vector=query_vector,
limit=top_k,
score_threshold=score_threshold,
query_filter=query_filter,
with_payload=True,
)
except Exception as e:
logger.error(f"Qdrant 搜索失败: {e}")
return []
return [
VectorSearchResult(
id=(hit.payload or {}).get('segment_id', str(hit.id)),
score=hit.score,
payload=hit.payload or {},
)
for hit in results
]
async def delete_by_filter(
self,
knowledge_base_id: str,
filter_conditions: Dict[str, Any],
) -> None:
"""按条件删除向量"""
client = await self._get_client()
name = self.collection_name(knowledge_base_id)
query_filter = self._build_filter(filter_conditions)
if query_filter:
await client.delete(
collection_name=name,
points_selector=FilterSelector(filter=query_filter),
)
async def health_check(self) -> bool:
"""健康检查"""
try:
client = await self._get_client()
await client.get_collections()
return True
except Exception as e:
logger.error(f"Qdrant 健康检查失败: {e}")
return False
@staticmethod
def _to_uuid(string_id: str) -> str:
"""将任意字符串 ID 确定性转换为 UUID5Qdrant 要求 point ID 为 UUID 或整数)"""
return str(uuid.uuid5(uuid.NAMESPACE_DNS, string_id))
@staticmethod
def _build_filter(conditions: Dict[str, Any]) -> Optional[Filter]:
"""构建 Qdrant 过滤条件"""
if not conditions:
return None
must = []
for key, value in conditions.items():
if isinstance(value, list):
# 列表值:任一匹配(OR 语义),用 should 包裹后作为一个 must 条件
should_conditions = [
FieldCondition(key=key, match=MatchValue(value=v))
for v in value
]
must.append(Filter(should=should_conditions))
else:
must.append(FieldCondition(key=key, match=MatchValue(value=value)))
return Filter(must=must) if must else None
@@ -1,29 +0,0 @@
"""
AI 平台数据模型
"""
from .provider import LLMProvider
from .model import LLMModel
from .app import AIApp
from .conversation import Conversation, Message
from .workflow import AIWorkflow, AIWorkflowVersion, AIWorkflowRun
from .prompt_template import PromptTemplate
from .agent import Agent, AgentConversation, AgentMessage
from ai_platform.knowledge.models import KnowledgeBase, KnowledgeDocument, KnowledgeSegment
__all__ = [
'LLMProvider',
'LLMModel',
'AIApp',
'Conversation',
'Message',
'AIWorkflow',
'AIWorkflowVersion',
'AIWorkflowRun',
'PromptTemplate',
'Agent',
'AgentConversation',
'AgentMessage',
'KnowledgeBase',
'KnowledgeDocument',
'KnowledgeSegment',
]
@@ -1,95 +0,0 @@
"""
智能体模型
"""
from sqlalchemy import Column, String, Text, Float, Integer, Boolean, JSON, Index
from app.base_model import BaseModel
class Agent(BaseModel):
"""
智能体定义
智能体是一个能够自主决策、调用工具、多轮推理的 AI 实体
支持两种模式:
- autonomous: 自主规划模式 - Agent 自动拆解任务并执行
- dialog_flow: 对话流模式 - 按预定义流程与用户交互
"""
__tablename__ = "ai_agent"
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID(逻辑外键关联core_application")
is_global = Column(Boolean, default=False, comment="是否在子应用中可见")
name = Column(String(100), nullable=False, comment="智能体名称")
code = Column(String(100), unique=True, nullable=False, comment="智能体编码")
description = Column(Text, default="", comment="智能体描述")
avatar = Column(String(500), default="", comment="头像 URL")
mode = Column(String(20), default="autonomous", comment="运行模式: autonomous/dialog_flow")
status = Column(String(20), default="draft", comment="状态: draft/published/disabled")
persona = Column(JSON, default=dict, comment="人设配置")
system_prompt = Column(Text, default="", comment="系统提示词")
model_id = Column(String(21), nullable=True, index=True, comment="默认模型ID(逻辑外键关联ai_llm_model")
temperature = Column(Float, default=0.7, comment="温度参数(0-2")
top_p = Column(Float, default=1.0, comment="top_p 参数")
max_tokens = Column(Integer, default=4096, comment="最大输出 Token")
max_iterations = Column(Integer, default=10, comment="最大推理轮数")
welcome_message = Column(Text, default="", comment="开场白")
suggested_questions = Column(JSON, default=list, comment="推荐问题列表")
workflow_id = Column(String(21), nullable=True, index=True, comment="关联工作流ID(逻辑外键关联ai_workflow")
enable_memory = Column(Boolean, default=False, comment="是否启用对话记忆")
memory_window = Column(Integer, default=10, comment="记忆窗口大小(最近 N 轮对话)")
enable_streaming = Column(Boolean, default=True, comment="是否启用流式输出(自主规划模式)")
knowledge_base_ids = Column(JSON, default=list, comment="关联的知识库ID列表")
knowledge_config = Column(JSON, default=dict, comment="知识库检索配置(top_k/score_threshold/retrieval_mode等)")
is_public = Column(Boolean, default=False, comment="是否公开")
conversation_count = Column(Integer, default=0, comment="对话数量")
message_count = Column(Integer, default=0, comment="消息数量")
total_tokens = Column(Integer, default=0, comment="总 Token 消耗")
class AgentConversation(BaseModel):
"""
智能体对话
"""
__tablename__ = "ai_agent_conversation"
agent_id = Column(String(21), nullable=False, index=True, comment="智能体ID(逻辑外键关联ai_agent)")
user_id = Column(String(21), nullable=True, index=True, comment="用户ID(逻辑外键关联core_user")
title = Column(String(200), default="", comment="对话标题")
summary = Column(Text, default="", comment="对话摘要")
workflow_run_id = Column(String(21), nullable=True, comment="工作流运行实例ID(逻辑外键关联ai_workflow_run")
waiting_node_id = Column(String(100), default="", comment="等待输入的节点 ID")
extra_data = Column(JSON, default=dict, comment="元数据")
message_count = Column(Integer, default=0, comment="消息数量")
total_tokens = Column(Integer, default=0, comment="总 Token 消耗")
__table_args__ = (
Index("ix_ai_agent_conversation_agent_user", "agent_id", "user_id"),
)
class AgentMessage(BaseModel):
"""
智能体消息
记录对话中的每条消息,包括用户消息、助手回复、工具调用等
"""
__tablename__ = "ai_agent_message"
conversation_id = Column(String(21), nullable=False, index=True, comment="对话ID(逻辑外键关联ai_agent_conversation")
role = Column(String(20), nullable=False, comment="角色: user/assistant/tool/system")
content = Column(Text, default="", comment="消息内容")
attachments = Column(JSON, default=list, comment="附件列表 [{id, type, name, url, mime_type, size}]")
status = Column(String(20), default="completed", comment="状态: pending/completed/failed")
reasoning_steps = Column(JSON, default=list, comment="推理步骤")
tool_calls = Column(JSON, default=list, comment="工具调用记录")
prompt_tokens = Column(Integer, default=0, comment="提示 Token")
completion_tokens = Column(Integer, default=0, comment="生成 Token")
total_tokens = Column(Integer, default=0, comment="总 Token")
elapsed_time = Column(Integer, default=0, comment="耗时(毫秒)")
error_message = Column(Text, default="", comment="错误信息")
feedback = Column(String(20), default="", comment="用户反馈(like/dislike")
feedback_content = Column(Text, default="", comment="反馈内容")
__table_args__ = (
Index("ix_ai_agent_message_conversation_role", "conversation_id", "role"),
)
-37
View File
@@ -1,37 +0,0 @@
"""
AI 应用模型
"""
from sqlalchemy import Column, String, Text, Float, Integer, Boolean, JSON
from app.base_model import BaseModel
class AIApp(BaseModel):
"""
AI 应用
支持的应用类型:
- chat: 聊天助手
- completion: 文本生成
- workflow: 工作流应用
- agent: Agent 应用(预留)
"""
__tablename__ = "ai_app"
name = Column(String(100), nullable=False, comment="应用名称")
code = Column(String(100), unique=True, nullable=False, comment="应用编码")
description = Column(Text, default="", comment="应用描述")
icon = Column(String(100), default="", comment="应用图标")
app_type = Column(String(20), default="chat", comment="应用类型: chat/completion/workflow/agent")
status = Column(String(20), default="draft", comment="状态: draft/published/disabled")
model_id = Column(String(21), nullable=True, index=True, comment="默认模型ID(逻辑外键关联ai_llm_model")
system_prompt = Column(Text, default="", comment="系统提示词")
temperature = Column(Float, default=0.7, comment="温度参数")
top_p = Column(Float, default=1.0, comment="top_p 参数")
max_tokens = Column(Integer, default=2048, comment="最大输出 Token")
workflow_definition = Column(JSON, default=dict, comment="工作流定义")
opening_statement = Column(Text, default="", comment="开场白")
suggested_questions = Column(JSON, default=list, comment="建议问题列表")
is_public = Column(Boolean, default=False, comment="是否公开")
conversation_count = Column(Integer, default=0, comment="对话数量")
message_count = Column(Integer, default=0, comment="消息数量")
@@ -1,50 +0,0 @@
"""
对话模型
"""
from sqlalchemy import Column, String, Text, Float, Integer, Boolean, Index
from app.base_model import BaseModel
class Conversation(BaseModel):
"""
对话
"""
__tablename__ = "ai_conversation"
app_id = Column(String(21), nullable=False, index=True, comment="所属应用ID(逻辑外键关联ai_app)")
user_id = Column(String(21), nullable=False, index=True, comment="用户ID(逻辑外键关联core_user")
title = Column(String(200), default="", comment="对话标题")
model_override_id = Column(String(21), nullable=True, comment="覆盖模型ID(逻辑外键关联ai_llm_model")
temperature_override = Column(Float, nullable=True, comment="覆盖温度参数")
message_count = Column(Integer, default=0, comment="消息数量")
total_tokens = Column(Integer, default=0, comment="总 Token 数")
is_pinned = Column(Boolean, default=False, comment="是否置顶")
__table_args__ = (
Index("ix_ai_conversation_user_app", "user_id", "app_id"),
)
class Message(BaseModel):
"""
消息
"""
__tablename__ = "ai_message"
conversation_id = Column(String(21), nullable=False, index=True, comment="所属对话ID(逻辑外键关联ai_conversation")
role = Column(String(20), nullable=False, comment="角色: system/user/assistant")
content = Column(Text, nullable=False, comment="消息内容")
status = Column(String(20), default="completed", comment="状态: pending/completed/failed/stopped")
prompt_tokens = Column(Integer, default=0, comment="提示 Token 数")
completion_tokens = Column(Integer, default=0, comment="补全 Token 数")
total_tokens = Column(Integer, default=0, comment="总 Token 数")
model_name = Column(String(100), default="", comment="使用的模型名称")
latency = Column(Integer, default=0, comment="响应耗时(毫秒)")
error_message = Column(Text, default="", comment="错误信息")
parent_message_id = Column(String(21), nullable=True, comment="父消息ID(逻辑外键关联自身)")
feedback = Column(String(20), default="", comment="用户反馈: like/dislike")
__table_args__ = (
Index("ix_ai_message_conversation_created", "conversation_id", "sys_create_datetime"),
)
@@ -1,30 +0,0 @@
"""
LLM 模型配置
"""
from sqlalchemy import Column, String, Integer, Float, Boolean, Numeric
from app.base_model import BaseModel
class LLMModel(BaseModel):
"""
LLM 模型配置
每个提供商可以配置多个模型
"""
__tablename__ = "ai_llm_model"
provider_id = Column(String(21), nullable=False, index=True, comment="所属提供商ID(逻辑外键关联ai_llm_provider")
model_name = Column(String(100), nullable=False, comment="模型名称(API 调用时使用)")
display_name = Column(String(100), nullable=False, comment="显示名称")
model_type = Column(String(20), default="chat", comment="模型类型: chat/completion/embedding/rerank")
max_tokens = Column(Integer, default=4096, comment="最大 Token 数")
context_window = Column(Integer, default=4096, comment="上下文窗口大小")
default_temperature = Column(Float, default=0.7, comment="默认温度参数")
default_top_p = Column(Float, default=1.0, comment="默认 top_p 参数")
input_price = Column(Numeric(10, 6), default=0, comment="输入价格(每 1K tokens")
output_price = Column(Numeric(10, 6), default=0, comment="输出价格(每 1K tokens")
is_active = Column(Boolean, default=True, comment="是否启用")
supports_vision = Column(Boolean, default=False, comment="是否支持视觉(图片输入)")
supports_function_call = Column(Boolean, default=False, comment="是否支持函数调用")
supports_streaming = Column(Boolean, default=True, comment="是否支持流式输出")
@@ -1,40 +0,0 @@
"""
Prompt 模板模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, JSON
from app.base_model import BaseModel
class PromptTemplate(BaseModel):
"""
Prompt 模板
用于管理和复用 Prompt
"""
__tablename__ = "ai_prompt_template"
name = Column(String(100), nullable=False, comment="模板名称")
code = Column(String(100), unique=True, nullable=False, comment="模板编码")
category = Column(String(20), default="system", comment="分类: system/user/assistant/few_shot")
description = Column(Text, default="", comment="描述")
content = Column(Text, nullable=False, comment="模板内容")
variables = Column(JSON, default=list, comment="变量定义列表")
tags = Column(JSON, default=list, comment="标签列表")
is_public = Column(Boolean, default=False, comment="是否公开")
usage_count = Column(Integer, default=0, comment="使用次数")
def render(self, variables: dict) -> str:
"""
渲染模板
Args:
variables: 变量字典
Returns:
渲染后的内容
"""
content = self.content or ""
for key, value in variables.items():
content = content.replace(f'{{{{{key}}}}}', str(value))
return content
@@ -1,42 +0,0 @@
"""
LLM 提供商模型
"""
from sqlalchemy import Column, String, Text, Boolean, Integer
from app.base_model import BaseModel
class LLMProvider(BaseModel):
"""
LLM 提供商配置
支持的提供商类型:
- openai: OpenAI (GPT-3.5, GPT-4, etc.)
- claude: Anthropic Claude
- qwen: 阿里通义千问
- ollama: 本地 Ollama
- azure_openai: Azure OpenAI
- zhipu: 智谱 AI
- moonshot: Moonshot (Kimi)
- deepseek: DeepSeek
"""
__tablename__ = "ai_llm_provider"
name = Column(String(100), nullable=False, comment="提供商名称")
provider_type = Column(String(50), nullable=False, comment="提供商类型")
api_key = Column(Text, default="", comment="API Key(加密存储)")
api_base = Column(String(500), default="", comment="API 地址(可选,用于自定义端点)")
api_version = Column(String(50), default="", comment="API 版本(Azure OpenAI 专用)")
ollama_host = Column(String(200), default="http://localhost:11434", comment="Ollama 服务地址")
is_active = Column(Boolean, default=True, comment="是否启用")
description = Column(Text, default="", comment="描述")
quota_limit = Column(Integer, default=0, comment="配额限制(0 表示无限制)")
quota_used = Column(Integer, default=0, comment="已使用配额")
def get_api_key_masked(self) -> str:
"""获取脱敏的 API Key"""
if not self.api_key:
return ''
if len(self.api_key) <= 8:
return '*' * len(self.api_key)
return self.api_key[:4] + '*' * (len(self.api_key) - 8) + self.api_key[-4:]
@@ -1,83 +0,0 @@
"""
AI 工作流模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, JSON, Index
from app.base_model import BaseModel
class AIWorkflow(BaseModel):
"""
AI 工作流定义
独立于 AIApp 的工作流定义,可以被多个应用引用
"""
__tablename__ = "ai_workflow"
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID(逻辑外键关联core_application")
is_global = Column(Boolean, default=False, comment="是否在子应用中可见")
name = Column(String(100), nullable=False, comment="工作流名称")
code = Column(String(100), unique=True, nullable=False, comment="工作流编码")
workflow_type = Column(String(30), default="general", comment="工作流类型: general/application/form/report/data_process/automation")
description = Column(Text, default="", comment="描述")
status = Column(String(20), default="draft", comment="状态: draft/published/disabled")
version = Column(Integer, default=1, comment="当前草稿版本号")
published_version = Column(Integer, nullable=True, comment="已发布的版本号")
published_at = Column(DateTime, nullable=True, comment="最后发布时间")
published_definition = Column(JSON, default=dict, comment="已发布版本的工作流定义")
definition = Column(JSON, default=dict, comment="工作流定义(草稿)")
input_variables = Column(JSON, default=list, comment="输入变量定义")
output_variables = Column(JSON, default=list, comment="输出变量定义")
run_count = Column(Integer, default=0, comment="运行次数")
success_count = Column(Integer, default=0, comment="成功次数")
class AIWorkflowVersion(BaseModel):
"""
AI 工作流版本历史
每次发布时创建一条版本记录
"""
__tablename__ = "ai_workflow_version"
workflow_id = Column(String(21), nullable=False, index=True, comment="工作流ID(逻辑外键关联ai_workflow")
version = Column(Integer, nullable=False, comment="版本号")
definition = Column(JSON, default=dict, comment="该版本的工作流定义")
description = Column(Text, default="", comment="版本说明")
published_by_id = Column(String(21), nullable=True, comment="发布人ID(逻辑外键关联core_user")
published_at = Column(DateTime, nullable=True, comment="发布时间")
run_count = Column(Integer, default=0, comment="运行次数")
success_count = Column(Integer, default=0, comment="成功次数")
class AIWorkflowRun(BaseModel):
"""
AI 工作流运行记录
"""
__tablename__ = "ai_workflow_run"
workflow_id = Column(String(21), nullable=False, index=True, comment="工作流ID(逻辑外键关联ai_workflow")
app_id = Column(String(21), nullable=True, index=True, comment="关联应用ID(逻辑外键关联ai_app)")
conversation_id = Column(String(21), nullable=True, comment="关联对话ID(逻辑外键关联ai_conversation")
user_id = Column(String(21), nullable=True, index=True, comment="执行用户ID(逻辑外键关联core_user")
status = Column(String(20), default="pending", comment="状态: pending/running/waiting/completed/failed/stopped")
trigger_type = Column(String(30), default="api", comment="触发来源: editor_draft/editor_published/agent/api/form_button")
use_draft = Column(Boolean, default=False, comment="是否使用草稿定义执行")
workflow_version = Column(Integer, nullable=True, comment="执行时发布版本号,草稿运行为空")
definition_snapshot = Column(JSON, default=dict, comment="运行开始时的工作流定义快照")
inputs = Column(JSON, default=dict, comment="输入数据")
outputs = Column(JSON, default=dict, comment="输出数据")
execution_log = Column(JSON, default=list, comment="执行日志")
current_node_id = Column(String(100), default="", comment="当前节点 ID")
waiting_config = Column(JSON, default=dict, comment="等待用户输入的配置")
error_message = Column(Text, default="", comment="错误信息")
total_tokens = Column(Integer, default=0, comment="总 Token 数")
total_steps = Column(Integer, default=0, comment="总步骤数")
elapsed_time = Column(Integer, default=0, comment="总耗时(毫秒)")
started_at = Column(DateTime, nullable=True, comment="开始时间")
completed_at = Column(DateTime, nullable=True, comment="完成时间")
__table_args__ = (
Index("ix_ai_workflow_run_workflow_status", "workflow_id", "status"),
Index("ix_ai_workflow_run_user_status", "user_id", "status"),
)
@@ -1,18 +0,0 @@
"""
AI 工作流节点系统
提供可扩展的节点架构,支持:
- 内置节点(LLM、条件、代码等)
- 自定义节点扩展
- 知识库节点(预留)
- 工具节点(预留)
"""
from .base import BaseNode, NodeContext, NodeResult
from .registry import NodeRegistry
__all__ = [
'BaseNode',
'NodeContext',
'NodeResult',
'NodeRegistry',
]
-394
View File
@@ -1,394 +0,0 @@
"""
节点基类
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class NodeContext:
"""
节点执行上下文
包含节点执行所需的所有信息
"""
# 工作流运行实例
workflow_run_id: str = ''
# 变量存储(所有节点共享)
variables: Dict[str, Any] = field(default_factory=dict)
# 用户输入
user_input: str = ''
# 当前用户
user_id: str = ''
# 对话历史(用于 LLM 节点)
conversation_history: List[Dict[str, str]] = field(default_factory=list)
# 节点配置
node_config: Dict[str, Any] = field(default_factory=dict)
# 上一个节点的输出
previous_output: Any = None
# 元数据
metadata: Dict[str, Any] = field(default_factory=dict)
# 数据库会话(用于需要数据库访问的节点)
db_session: Any = None
def get_variable(self, name: str, default: Any = None) -> Any:
"""获取变量"""
return self.variables.get(name, default)
def set_variable(self, name: str, value: Any) -> None:
"""设置变量"""
self.variables[name] = value
def resolve_template(self, template: str) -> str:
"""
解析模板中的变量引用
支持格式:
- {{variable_name}} - 直接变量引用
- {{variable_name[0]}} - 数组索引访问
- {{variable_name[-1]}} - 负数索引(最后一个)
- {{NodeID.key}} - 节点输出引用(如果存在)
- {{NodeID.key}}.property - 访问解析结果的嵌套属性
- {{NodeID.key[0].property}} - 数组索引 + 属性访问
"""
import re
import json
result = template
# 匹配 {{...}} 格式的变量引用,以及可选的后续属性访问 .property1.property2...
pattern = r'\{\{([^}]+)\}\}((?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)'
def get_nested_value(obj, path: str):
"""从对象中获取嵌套属性值,支持数组索引"""
if not path:
return obj
# 移除开头的点
if path.startswith('.'):
path = path[1:]
current = obj
# 使用正则分割路径,支持 .property 和 [index] 格式
# 例如: "items[0].name" -> ["items", "[0]", "name"]
parts = re.split(r'(?=\[)|\.', path)
for part in parts:
if not part:
continue
# 如果是字符串,尝试解析为 JSON
if isinstance(current, str):
try:
current = json.loads(current)
except (json.JSONDecodeError, TypeError):
return None
# 检查是否是数组索引 [n]
index_match = re.match(r'\[(-?\d+)\]', part)
if index_match:
index = int(index_match.group(1))
if isinstance(current, (list, tuple)):
try:
current = current[index]
except IndexError:
return None
else:
return None
# 从字典中获取属性
elif isinstance(current, dict):
if part in current:
current = current[part]
else:
return None
else:
return None
return current
def replace_var(match):
var_ref = match.group(1).strip()
extra_path = match.group(2) or '' # 额外的属性路径,如 .customer_name
value = None
index_path = '' # 变量名后的索引/属性路径
# 检查是否有数组索引 [n],如果有则分割
# 例如: "loop_results[0]" -> var_name="loop_results", index_path="[0]"
# 例如: "llm-123.llm_response" -> var_name="llm-123.llm_response", index_path=""
bracket_pos = var_ref.find('[')
if bracket_pos > 0:
var_name = var_ref[:bracket_pos]
index_path = var_ref[bracket_pos:]
else:
var_name = var_ref
# 尝试解析 NodeID.key.subkey... 格式(支持多层属性访问)
if '.' in var_name:
parts = var_name.split('.')
node_id = parts[0]
remaining_path = '.'.join(parts[1:]) # 剩余路径,如 "item.module_name"
# 先尝试从节点输出命名空间获取
node_outputs = self.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict):
# 尝试获取第一层 key
first_key = parts[1] if len(parts) > 1 else None
if first_key and first_key in node_outputs:
value = node_outputs[first_key]
# 如果还有更多层级,继续递归获取
if len(parts) > 2:
nested_path = '.'.join(parts[2:])
nested_value = get_nested_value(value, nested_path)
if nested_value is not None:
value = nested_value
# 回退:尝试直接从变量获取(node_id 作为变量名)
if value is None and node_id in self.variables:
node_data = self.variables[node_id]
if isinstance(node_data, dict):
first_key = parts[1] if len(parts) > 1 else None
if first_key and first_key in node_data:
value = node_data[first_key]
if len(parts) > 2:
nested_path = '.'.join(parts[2:])
nested_value = get_nested_value(value, nested_path)
if nested_value is not None:
value = nested_value
# 再回退:直接从顶层变量获取完整路径
if value is None and remaining_path in self.variables:
value = self.variables[remaining_path]
else:
# 直接变量引用
if var_name in self.variables:
value = self.variables[var_name]
# 如果找到了值,处理索引路径和额外的属性路径
if value is not None:
# 合并索引路径和额外路径
full_path = index_path + extra_path
if full_path:
nested_value = get_nested_value(value, full_path)
if nested_value is not None:
return str(nested_value)
# 嵌套属性未找到,返回原始值
return str(value)
return str(value)
# 未找到变量,保持原样
return match.group(0)
result = re.sub(pattern, replace_var, result)
return result
@dataclass
class NodeResult:
"""
节点执行结果
"""
# 是否成功
success: bool = True
# 输出数据
output: Any = None
# 输出变量(会合并到上下文的 variables 中)
output_variables: Dict[str, Any] = field(default_factory=dict)
# 错误信息
error: str = ''
# 下一个节点 ID(用于条件分支)
next_node_id: str = ''
# Token 使用(LLM 节点)
tokens_used: int = 0
# 耗时(毫秒)
elapsed_time: int = 0
# 元数据
metadata: Dict[str, Any] = field(default_factory=dict)
# ========== 对话流相关 ==========
# 是否等待用户输入(对话流模式)
waiting_for_input: bool = False
# 等待配置(描述需要什么类型的输入)
waiting_config: Dict[str, Any] = field(default_factory=dict)
# 事件列表(如发送消息)
events: List[Dict[str, Any]] = field(default_factory=list)
# ========== 设计预览相关 ==========
# 设计预览数据(用于工作流中显示设计结果并允许编辑)
preview: Optional[Dict[str, Any]] = None
class BaseNode(ABC):
"""
节点基类
所有节点必须继承此类并实现 execute 方法
"""
# 节点类型标识(必须唯一)
node_type: str = ''
# 节点显示名称
node_name: str = ''
# 节点分类
node_category: str = 'basic' # basic, llm, logic, data, tool, knowledge
# 节点图标
node_icon: str = ''
# 节点描述
node_description: str = ''
# 输入参数定义
inputs: List[Dict[str, Any]] = []
# 输出参数定义
outputs: List[Dict[str, Any]] = []
# 是否支持多个输出分支
supports_branches: bool = False
def __init__(self, config: Dict[str, Any] = None):
"""
初始化节点
Args:
config: 节点配置
"""
self.config = config or {}
@abstractmethod
def execute(self, context: NodeContext) -> NodeResult:
"""
执行节点
Args:
context: 执行上下文
Returns:
NodeResult
"""
pass
async def execute_async(self, context: NodeContext) -> NodeResult:
"""
异步执行节点(默认调用同步方法)
Args:
context: 执行上下文
Returns:
NodeResult
"""
return self.execute(context)
def validate_config(self) -> tuple:
"""
验证节点配置
Returns:
(is_valid, error_message)
"""
return True, ''
@classmethod
def get_schema(cls) -> Dict[str, Any]:
"""
获取节点 Schema(供前端渲染)
Returns:
节点 Schema
"""
return {
'type': cls.node_type,
'name': cls.node_name,
'category': cls.node_category,
'icon': cls.node_icon,
'description': cls.node_description,
'inputs': cls.inputs,
'outputs': cls.outputs,
'supports_branches': cls.supports_branches,
}
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {}
def resolve_require_confirmation(self, context: NodeContext, default: bool = True) -> bool:
"""
解析 require_confirmation 配置
支持三种模式:
1. 布尔值:直接使用 True/False
2. 字符串 'always'/'never':始终确认/从不确认
3. 变量引用:{{variable_name}} 格式,解析变量值作为布尔值
Args:
context: 节点执行上下文
default: 默认值
Returns:
是否需要确认
"""
value = self.config.get('require_confirmation', default)
# 布尔值直接返回
if isinstance(value, bool):
return value
# 字符串处理
if isinstance(value, str):
value_lower = value.lower().strip()
# 固定模式
if value_lower in ('always', 'true', '1', 'yes'):
return True
if value_lower in ('never', 'false', '0', 'no'):
return False
# 变量引用模式:{{variable_name}}
if '{{' in value and '}}' in value:
resolved = context.resolve_template(value)
# 解析后的值转换为布尔值
if isinstance(resolved, bool):
return resolved
if isinstance(resolved, str):
resolved_lower = resolved.lower().strip()
if resolved_lower in ('true', '1', 'yes'):
return True
if resolved_lower in ('false', '0', 'no'):
return False
# 非空字符串视为 True
return bool(resolved and resolved != value)
# 其他情况返回默认值
return default
@@ -1,47 +0,0 @@
"""
内置节点
"""
# Node modules are imported by ai_platform.nodes.registry so optional nodes can
# degrade independently when their runtime dependencies are unavailable.
__all__ = [
'StartNode',
'EndNode',
'LLMNode',
'ConditionNode',
'CodeNode',
'HttpNode',
'TemplateNode',
'VariableNode',
'ParallelNode',
'MergeNode',
'BaseDatabaseNode',
'DbInsertNode',
'DbUpdateNode',
'DbQueryNode',
'DbDeleteNode',
'DbSqlNode',
# 对话流节点
'QuestionNode',
'ChoiceNode',
'MessageNode',
'ConfirmNode',
'IntentNode',
# Snowflake Cortex 节点
'SnowflakeCortexLLMNode',
'SnowflakeCortexAnalystNode',
# 循环节点
'LoopNode',
# 表单节点
# 应用节点
# 仪表盘节点
# 系统总结节点
# 子流程节点
'SubflowNode',
# Text-to-SQL 节点
'TextToSqlNode',
# 表单数据节点
# 知识库节点
'KnowledgeRetrievalNode',
]
@@ -1,258 +0,0 @@
"""
应用创建节点
创建一个新的应用(子应用)
"""
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppCreateNode(BaseNode):
"""
应用创建节点
创建一个新的应用(低代码平台的顶层容器)
"""
node_type = 'app_create'
node_name = '创建应用'
node_category = 'application'
node_icon = 'app-window'
node_description = '创建一个新的应用(子应用)'
inputs = [
{
'name': 'name',
'type': 'string',
'description': '应用名称',
'required': True,
},
{
'name': 'code',
'type': 'string',
'description': '应用编码(唯一标识,用于URL路由,只能包含字母、数字、下划线和连字符,必须以字母开头)',
'required': True,
},
{
'name': 'description',
'type': 'string',
'description': '应用描述',
'required': False,
},
{
'name': 'icon',
'type': 'string',
'description': '应用图标',
'required': False,
},
{
'name': 'app_type',
'type': 'string',
'description': '应用类型: form-表单应用, workflow-流程应用, dashboard-数据应用, screen-大屏应用, mixed-混合应用',
'required': False,
},
]
outputs = [
{
'name': 'app_id',
'type': 'string',
'description': '创建的应用ID',
},
{
'name': 'app_code',
'type': 'string',
'description': '应用编码',
},
{
'name': 'app_name',
'type': 'string',
'description': '应用名称',
},
{
'name': 'success',
'type': 'boolean',
'description': '是否创建成功',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数并解析变量引用
name = context.resolve_template(self.config.get('name', ''))
code_base = context.resolve_template(self.config.get('code', ''))
description = context.resolve_template(self.config.get('description', ''))
icon = context.resolve_template(self.config.get('icon', ''))
app_type = context.resolve_template(self.config.get('app_type', 'mixed'))
# 配置选项
update_if_exists = self.config.get('update_if_exists', False)
logger.info(f'AppCreateNode - 开始创建应用: name={name}, code={code_base}')
# 验证必要参数
if not name:
return NodeResult(
success=False,
error='应用名称不能为空',
)
if not code_base:
return NodeResult(
success=False,
error='应用编码不能为空',
)
# 验证编码格式(字母开头,只能包含字母、数字、下划线和连字符)
import re
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', code_base):
return NodeResult(
success=False,
error='应用编码格式不正确,必须以字母开头,只能包含字母、数字、下划线和连字符',
)
# 导入服务和数据库会话
from app.database import AsyncSessionLocal
from core.application.service import ApplicationService
from core.application.schema import ApplicationCreate, ApplicationUpdate
async with AsyncSessionLocal() as db:
# 生成唯一的 code(如果已存在则自动添加后缀)
code = await self._get_unique_code(db, code_base, ApplicationService)
logger.info(f'AppCreateNode - 使用 code: {code} (原始: {code_base})')
# 检查应用编码是否已存在(理论上不应该存在,因为已经生成了唯一 code)
existing_app = await ApplicationService.get_by_code(db, code)
if existing_app:
if update_if_exists:
# 更新已存在的应用
update_data = ApplicationUpdate(
name=name,
description=description or None,
icon=icon or None,
app_type=app_type or None,
)
updated_app = await ApplicationService.update(
db,
record_id=existing_app.id,
data=update_data,
auto_commit=True
)
if updated_app:
logger.info(f'AppCreateNode - 应用已存在,更新成功: id={updated_app.id}')
return NodeResult(
success=True,
output=f'应用 {name} 更新成功',
output_variables={
'app_id': updated_app.id,
'app_code': updated_app.code,
'app_name': updated_app.name,
'success': True,
}
)
else:
return NodeResult(
success=False,
error=f'更新应用失败',
)
else:
# 应用已存在,返回已存在的应用信息
logger.info(f'AppCreateNode - 应用编码已存在: code={code}, id={existing_app.id}')
return NodeResult(
success=True,
output=f'应用编码 {code} 已存在',
output_variables={
'app_id': existing_app.id,
'app_code': existing_app.code,
'app_name': existing_app.name,
'success': False, # 标记为未创建新应用
}
)
# 创建新应用
create_data = ApplicationCreate(
name=name,
code=code,
description=description or '',
icon=icon or '',
app_type=app_type or 'mixed',
)
new_app = await ApplicationService.create(db, data=create_data, auto_commit=True)
if new_app:
logger.info(f'AppCreateNode - 应用创建成功: id={new_app.id}, code={new_app.code}')
return NodeResult(
success=True,
output=f'应用 {name} 创建成功',
output_variables={
'app_id': new_app.id,
'app_code': new_app.code,
'app_name': new_app.name,
'success': True,
}
)
else:
return NodeResult(
success=False,
error='创建应用失败',
)
except Exception as e:
logger.exception(f'AppCreateNode - 执行异常: {e}')
return NodeResult(
success=False,
error=f'创建应用异常: {str(e)}',
)
async def _get_unique_code(self, db, base_code: str, service) -> str:
"""
生成唯一的 code,如果已存在则自动添加数字后缀
Args:
db: 数据库会话
base_code: 基础 code
service: 服务类(需要有 get_by_code 方法)
Returns:
唯一的 code
"""
code = base_code
counter = 1
while True:
try:
existing = await service.get_by_code(db, code)
if not existing:
# code 不存在,可以使用
break
# code 已存在,添加后缀
code = f"{base_code}_{counter}"
counter += 1
except Exception:
# 查询出错,认为 code 可用
break
return code
@@ -1,205 +0,0 @@
"""
应用设计节点
接收LLM生成的应用设计方案,展示给用户确认或编辑
"""
import json
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppDesignNode(BaseNode):
"""
应用设计节点
接收LLM生成的应用设计方案(Markdown格式),展示给用户确认或编辑
用户可以查看设计方案,编辑后确认继续
"""
node_type = 'app_design'
node_name = '应用设计'
node_category = 'application'
node_icon = 'layout'
node_description = '展示应用设计方案,支持用户确认或编辑'
inputs = [
{
'name': 'design_content',
'type': 'string',
'description': '设计方案内容(Markdown格式)',
'required': True,
},
{
'name': 'design_title',
'type': 'string',
'description': '设计方案标题',
'required': False,
},
]
outputs = [
{
'name': 'design_content',
'type': 'string',
'description': '确认后的设计方案内容',
},
{
'name': 'confirmed',
'type': 'boolean',
'description': '是否已确认',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'AppDesignNode - 使用用户编辑的数据')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
design_content = context.resolve_template(self.config.get('design_content', ''))
design_title = context.resolve_template(self.config.get('design_title', '应用设计方案'))
logger.info(f'AppDesignNode - 输入参数: title={design_title}, content_length={len(design_content)}')
# 验证必填字段
if not design_content or not design_content.strip():
return NodeResult(
success=False,
error='设计方案内容不能为空',
)
design_content = design_content.strip()
design_title = design_title.strip() if design_title else '应用设计方案'
# 构建设计数据
design_data = {
'title': design_title,
'content': design_content,
}
logger.info(f'AppDesignNode - 输出设计方案: title={design_title}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(design_data, require_confirmation)
except Exception as e:
logger.exception(f'应用设计节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
# 检查是否包含设计内容字段
if 'content' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and 'content' in data:
return data
except (json.JSONDecodeError, TypeError):
# 如果不是JSON,可能直接是编辑后的内容
return {'content': user_input, 'title': '应用设计方案'}
return None
def _build_result(self, design_data: Dict[str, Any], require_confirmation: bool = False) -> NodeResult:
"""构建节点结果"""
title = design_data.get('title', '应用设计方案')
content = design_data.get('content', '')
# 构建预览数据
preview_data = {
'type': 'app_design',
'title': title,
'data': design_data,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=design_data,
output_variables={
'design_content': content,
'design_title': title,
'confirmed': not require_confirmation,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'app_design',
'title': title,
'message': '请确认或编辑应用设计方案',
'data': design_data,
} if require_confirmation else {},
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {
'type': 'object',
'properties': {
'design_content': {
'type': 'string',
'title': '设计方案内容',
'description': '设计方案内容(Markdown格式),支持变量引用如 {{llm_output}}',
'required': True,
'x-component': 'Textarea',
'x-component-props': {
'placeholder': '请输入设计方案内容或使用变量 {{llm_output}}',
'rows': 6,
},
},
'design_title': {
'type': 'string',
'title': '设计方案标题',
'description': '设计方案的标题,支持变量引用',
'required': False,
'default': '应用设计方案',
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入标题,默认为"应用设计方案"',
},
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['design_content'],
}
@@ -1,432 +0,0 @@
"""
应用设置节点
接收应用设置配置,展示给用户确认或编辑
包括:应用配置、Logo配置、内置主题和布局
"""
import json
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppSettingsNode(BaseNode):
"""
应用设置节点
接收应用设置配置,展示给用户确认或编辑
包括:应用配置、Logo配置、内置主题和布局
"""
node_type = 'app_settings'
node_name = '应用设置'
node_category = 'application'
node_icon = 'settings'
node_description = '配置应用设置,包括应用信息、Logo、主题和布局'
inputs = [
{
'name': 'app_name',
'type': 'string',
'description': '应用名称',
'required': False,
},
{
'name': 'home_path',
'type': 'string',
'description': '首页路径',
'required': False,
},
{
'name': 'logo_source',
'type': 'string',
'description': 'Logo图片URL或文件ID',
'required': False,
},
{
'name': 'theme_builtin_type',
'type': 'string',
'description': '内置主题类型',
'required': False,
},
{
'name': 'theme_color_primary',
'type': 'string',
'description': '主题主色调',
'required': False,
},
{
'name': 'app_layout',
'type': 'string',
'description': '应用布局类型',
'required': False,
},
]
outputs = [
{
'name': 'settings',
'type': 'object',
'description': '应用设置配置',
},
{
'name': 'confirmed',
'type': 'boolean',
'description': '是否已确认',
},
]
# 默认配置
DEFAULT_SETTINGS = {
'app': {
'name': '',
'locale': 'zh-CN',
'dynamicTitle': True,
'watermark': False,
'watermarkContent': '',
'enableCheckUpdates': True,
'defaultHomePath': '/analytics',
'enablePreferences': True,
},
'logo': {
'enable': True,
'source': '',
'fit': 'contain',
},
'theme': {
'colorPrimary': 'hsl(212 100% 45%)',
'builtinType': 'default',
'mode': 'light',
'radius': '0.5',
'semiDarkSidebar': False,
'semiDarkHeader': False,
},
'sidebar': {
'enable': True,
'width': 230,
'collapsed': False,
'collapsedShowTitle': False,
'autoActivateChild': False,
'expandOnHover': True,
'collapsedButton': True,
'fixedButton': True,
},
'header': {
'enable': True,
'mode': 'fixed',
'menuAlign': 'start',
},
'footer': {
'enable': True,
'fixed': True,
},
'copyright': {
'enable': True,
'companyName': '',
'companySiteLink': '',
'date': '',
'icp': '',
'icpLink': '',
},
'navigation': {
'styleType': 'rounded',
'split': True,
'accordion': True,
},
'tabbar': {
'enable': True,
'showIcon': True,
'showMore': True,
'showMaximize': True,
'persist': True,
'draggable': True,
'wheelable': True,
'styleType': 'chrome',
'maxCount': 0,
'middleClickToClose': True,
},
'breadcrumb': {
'enable': True,
'showIcon': True,
'showHome': True,
'styleType': 'normal',
'hideOnlyOne': False,
},
'transition': {
'progress': True,
'name': 'fade-slide',
'loading': True,
'enable': True,
},
'widget': {
'globalSearch': True,
'fullscreen': True,
'languageToggle': True,
'notification': True,
'themeToggle': True,
'sidebarToggle': True,
'lockScreen': True,
'refresh': True,
},
'shortcutKeys': {
'enable': True,
'globalSearch': True,
'globalLogout': True,
'globalLockScreen': True,
},
}
# 布局类型选项
LAYOUT_OPTIONS = [
{'label': '侧边导航', 'value': 'sidebar-nav'},
{'label': '侧边混合导航', 'value': 'sidebar-mixed-nav'},
{'label': '顶部导航', 'value': 'header-nav'},
{'label': '混合导航', 'value': 'mixed-nav'},
{'label': '全屏内容', 'value': 'full-content'},
]
# 内置主题选项
BUILTIN_THEME_OPTIONS = [
{'label': '默认', 'value': 'default'},
{'label': '紫罗兰', 'value': 'violet'},
{'label': '粉红', 'value': 'pink'},
{'label': '玫瑰', 'value': 'rose'},
{'label': '天蓝', 'value': 'sky'},
{'label': '青色', 'value': 'cyan'},
{'label': '绿色', 'value': 'green'},
{'label': '橙色', 'value': 'orange'},
{'label': '黄色', 'value': 'yellow'},
{'label': '锌色', 'value': 'zinc'},
{'label': '中性', 'value': 'neutral'},
{'label': '石板', 'value': 'slate'},
{'label': '灰色', 'value': 'gray'},
{'label': '深灰', 'value': 'deep-blue'},
{'label': '深绿', 'value': 'deep-green'},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'AppSettingsNode - 使用用户编辑的数据')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
app_name = context.resolve_template(self.config.get('app_name', ''))
home_path = context.resolve_template(self.config.get('home_path', ''))
logo_source = context.resolve_template(self.config.get('logo_source', ''))
theme_builtin_type = context.resolve_template(self.config.get('theme_builtin_type', 'default'))
theme_color_primary = context.resolve_template(self.config.get('theme_color_primary', ''))
app_layout = context.resolve_template(self.config.get('app_layout', 'sidebar-nav'))
logger.info(f'AppSettingsNode - 输入参数: app_name={app_name}, home_path={home_path}, layout={app_layout}')
# 构建设置数据(合并默认值和输入值)
settings = self._build_settings(
app_name=app_name,
home_path=home_path,
logo_source=logo_source,
theme_builtin_type=theme_builtin_type,
theme_color_primary=theme_color_primary,
app_layout=app_layout,
)
logger.info(f'AppSettingsNode - 输出设置配置')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(settings, require_confirmation)
except Exception as e:
logger.exception(f'应用设置节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
# 检查是否包含设置字段
if 'app' in user_input or 'theme' in user_input or 'logo' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('app' in data or 'theme' in data or 'logo' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_settings(
self,
app_name: str = '',
home_path: str = '',
logo_source: str = '',
theme_builtin_type: str = 'default',
theme_color_primary: str = '',
app_layout: str = 'sidebar-nav',
) -> Dict[str, Any]:
"""构建设置配置"""
import copy
settings = copy.deepcopy(self.DEFAULT_SETTINGS)
# 应用配置
if app_name:
settings['app']['name'] = app_name
# 首页路径配置
if home_path:
settings['app']['defaultHomePath'] = home_path
# 布局配置
if app_layout:
settings['app']['layout'] = app_layout
# Logo配置
if logo_source:
settings['logo']['source'] = logo_source
# 主题配置
if theme_builtin_type:
settings['theme']['builtinType'] = theme_builtin_type
if theme_color_primary:
settings['theme']['colorPrimary'] = theme_color_primary
return settings
def _build_result(self, settings: Dict[str, Any], require_confirmation: bool = False) -> NodeResult:
"""构建节点结果"""
# 构建预览数据
preview_data = {
'type': 'app_settings',
'title': '应用设置',
'data': settings,
'editable': True,
'layoutOptions': self.LAYOUT_OPTIONS,
'themeOptions': self.BUILTIN_THEME_OPTIONS,
} if require_confirmation else None
return NodeResult(
success=True,
output=settings,
output_variables={
'settings': settings,
'confirmed': not require_confirmation,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'app_settings',
'title': '应用设置',
'message': '请确认或编辑应用设置',
'data': settings,
'layoutOptions': self.LAYOUT_OPTIONS,
'themeOptions': self.BUILTIN_THEME_OPTIONS,
} if require_confirmation else {},
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {
'type': 'object',
'properties': {
'app_name': {
'type': 'string',
'title': '应用名称',
'description': '应用的显示名称,支持变量引用如 {{llm_output}}',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入应用名称',
},
},
'home_path': {
'type': 'string',
'title': '首页路径',
'description': '应用首页路径,如 /dashboard 或 /analytics',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '如 /dashboard',
},
},
'logo_source': {
'type': 'string',
'title': 'Logo图片',
'description': 'Logo图片URL或文件ID,支持变量引用',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入Logo图片URL',
},
},
'theme_builtin_type': {
'type': 'string',
'title': '内置主题',
'description': '选择内置主题类型',
'default': 'default',
'enum': ['default', 'violet', 'pink', 'rose', 'sky', 'cyan', 'green', 'orange', 'yellow', 'zinc', 'neutral', 'slate', 'gray', 'deep-blue', 'deep-green'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择内置主题',
},
},
'theme_color_primary': {
'type': 'string',
'title': '主题主色调',
'description': '主题主色调,如 hsl(212 100% 45%)',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '如 hsl(212 100% 45%)',
},
},
'app_layout': {
'type': 'string',
'title': '应用布局',
'description': '应用布局类型',
'default': 'sidebar-nav',
'enum': ['sidebar-nav', 'sidebar-mixed-nav', 'header-nav', 'mixed-nav', 'full-content'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择布局类型',
},
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设置完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': [],
}
@@ -1,302 +0,0 @@
"""
应用更新节点
执行应用配置的更新操作,将设置保存到数据库
"""
import json
import logging
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppUpdateNode(BaseNode):
"""
应用更新节点
接收应用设置配置,执行实际的更新操作
"""
node_type = 'app_update'
node_name = '应用更新'
node_category = 'application'
node_icon = 'save'
node_description = '执行应用配置的更新操作,将设置保存到数据库'
inputs = [
{
'name': 'settings',
'type': 'object',
'description': '应用设置配置(来自应用设置节点)',
'required': True,
},
{
'name': 'application_id',
'type': 'string',
'description': '应用ID,不传则更新主应用配置',
'required': False,
},
]
outputs = [
{
'name': 'success',
'type': 'boolean',
'description': '更新是否成功',
},
{
'name': 'config_id',
'type': 'string',
'description': '配置记录ID',
},
{
'name': 'message',
'type': 'string',
'description': '更新结果消息',
},
]
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数
settings = self._get_settings(context)
application_id = self._get_application_id(context)
if not settings:
return NodeResult(
success=False,
error='缺少应用设置配置',
)
logger.info(f'AppUpdateNode - 开始更新应用配置, application_id={application_id}')
# 获取数据库会话
db: AsyncSession = context.db_session
if not db:
return NodeResult(
success=False,
error='数据库会话不可用',
)
# 执行更新
config_id = await self._update_preferences(db, settings, application_id)
if config_id:
logger.info(f'AppUpdateNode - 应用配置更新成功, config_id={config_id}')
return NodeResult(
success=True,
output={
'success': True,
'config_id': config_id,
'message': '应用配置更新成功',
},
output_variables={
'update_success': True,
'config_id': config_id,
'update_message': '应用配置更新成功',
},
)
else:
return NodeResult(
success=False,
error='应用配置更新失败',
)
except Exception as e:
logger.exception(f'应用更新节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
def execute(self, context: NodeContext) -> NodeResult:
"""同步执行节点(不支持,需要数据库操作)"""
return NodeResult(
success=False,
error='应用更新节点需要异步执行',
)
def _get_settings(self, context: NodeContext) -> Optional[Dict[str, Any]]:
"""获取应用设置配置"""
import re
# 优先从配置中获取(支持变量引用)
settings_config = self.config.get('settings', '')
logger.info(f'AppUpdateNode - settings_config from config: {settings_config}')
if settings_config:
# 检查是否是变量引用格式 {{node_id.key}}
var_pattern = r'\{\{([^}]+)\}\}'
match = re.match(var_pattern, settings_config.strip())
if match:
# 是变量引用,直接从上下文获取对象
var_ref = match.group(1).strip()
logger.info(f'AppUpdateNode - 解析变量引用: {var_ref}')
# 解析 node_id.key 格式
# 注意:node_id 可能包含连字符,如 app_settings-1769009671187
# 所以只按最后一个点分割
if '.' in var_ref:
parts = var_ref.rsplit('.', 1) # 从右边分割,只分割一次
node_id = parts[0]
key = parts[1] if len(parts) > 1 else None
logger.info(f'AppUpdateNode - 查找节点输出: node_id={node_id}, key={key}')
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
logger.info(f'AppUpdateNode - node_outputs type: {type(node_outputs)}, keys: {node_outputs.keys() if isinstance(node_outputs, dict) else "N/A"}')
if isinstance(node_outputs, dict) and key and key in node_outputs:
value = node_outputs[key]
logger.info(f'AppUpdateNode - 找到值, type: {type(value)}')
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
logger.warning(f'AppUpdateNode - JSON解析失败: {value[:100]}')
pass
else:
logger.warning(f'AppUpdateNode - 未找到节点输出或key不存在')
else:
# 直接变量名
value = context.variables.get(var_ref)
logger.info(f'AppUpdateNode - 直接变量引用, type: {type(value)}')
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
pass
else:
# 不是变量引用,尝试解析为 JSON
try:
return json.loads(settings_config)
except (json.JSONDecodeError, TypeError):
pass
# 从上下文变量获取
settings = context.variables.get('settings')
logger.info(f'AppUpdateNode - 从上下文获取settings, type: {type(settings)}')
if isinstance(settings, dict):
return settings
if isinstance(settings, str):
try:
return json.loads(settings)
except (json.JSONDecodeError, TypeError):
pass
logger.warning(f'AppUpdateNode - 未能获取有效的settings配置')
return None
def _get_application_id(self, context: NodeContext) -> Optional[str]:
"""获取应用ID"""
# 优先从配置中获取
app_id = self.config.get('application_id', '')
if app_id:
resolved = context.resolve_template(app_id)
if resolved and resolved != 'main':
return resolved
# 从上下文变量获取
app_id = context.variables.get('application_id')
if app_id and app_id != 'main':
return app_id
return None
async def _update_preferences(
self,
db: AsyncSession,
settings: Dict[str, Any],
application_id: Optional[str] = None,
) -> Optional[str]:
"""更新偏好配置"""
from core.ui_config.model import UIConfig
# 配置键:使用与前端 API 一致的键名
config_key = 'frontend_preferences'
# 查找现有配置
stmt = select(UIConfig).where(
UIConfig.config_key == config_key,
UIConfig.is_deleted == False,
)
# 根据 application_id 过滤
if application_id:
stmt = stmt.where(UIConfig.application_id == application_id)
else:
stmt = stmt.where(UIConfig.application_id.is_(None))
result = await db.execute(stmt)
config = result.scalar_one_or_none()
# 配置值
config_value = json.dumps(settings, ensure_ascii=False)
if config:
# 更新现有配置
config.config_value = config_value
await db.commit()
await db.refresh(config)
return config.id
else:
# 创建新配置
new_config = UIConfig(
application_id=application_id,
config_key=config_key,
config_value=config_value,
config_type='preferences',
description='前端UI偏好配置' if not application_id else '子应用UI偏好配置',
status=True,
sort=0,
)
db.add(new_config)
await db.commit()
await db.refresh(new_config)
return new_config.id
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
"""
return {
'type': 'object',
'properties': {
'settings': {
'type': 'string',
'title': '应用设置',
'description': '应用设置配置,支持变量引用如 {{app_settings-1.settings}}',
'required': True,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入应用设置或使用变量引用',
},
},
'application_id': {
'type': 'string',
'title': '应用ID',
'description': '目标应用ID,不填则更新主应用配置。支持变量引用如 {{application_id}}',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '留空则更新主应用配置',
},
},
},
'required': ['settings'],
}
@@ -1,182 +0,0 @@
"""
代码执行节点
"""
import concurrent.futures
import logging
import time
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class CodeNode(BaseNode):
"""
代码执行节点
执行 Python 代码片段
"""
node_type = 'code'
node_name = '代码'
node_category = 'logic'
node_icon = 'code'
node_description = '执行 Python 代码片段'
inputs = [
{
'name': 'inputs',
'type': 'object',
'description': '输入变量',
},
]
outputs = [
{
'name': 'result',
'type': 'any',
'description': '执行结果',
},
]
# 安全的内置函数白名单
SAFE_BUILTINS = {
'abs', 'all', 'any', 'bool', 'dict', 'enumerate', 'filter',
'float', 'int', 'len', 'list', 'map', 'max', 'min', 'range',
'round', 'set', 'sorted', 'str', 'sum', 'tuple', 'zip',
'True', 'False', 'None',
}
def execute(self, context: NodeContext) -> NodeResult:
"""执行代码(线程池隔离,防止死循环卡死主流程)"""
timeout = self.config.get('timeout', 30)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
future = executor.submit(self._execute_sync, context)
try:
return future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
return NodeResult(
success=False,
error=f'代码执行超时({timeout}秒),请检查是否存在死循环',
elapsed_time=timeout * 1000,
)
def _execute_sync(self, context: NodeContext) -> NodeResult:
"""同步执行代码(在子线程中运行)"""
start_time = time.time()
try:
code = self.config.get('code', '')
input_variables = self.config.get('inputs', []) or self.config.get('input_variables', [])
output_variable = self.config.get('output_variable', 'result')
if not code:
return NodeResult(
success=False,
error='代码不能为空',
)
safe_globals = {
'__builtins__': {k: getattr(__builtins__, k) if hasattr(__builtins__, k) else __builtins__[k]
for k in self.SAFE_BUILTINS if hasattr(__builtins__, k) or k in __builtins__},
}
import json
import re
import math
safe_globals['json'] = json
safe_globals['re'] = re
safe_globals['math'] = math
local_vars = {}
for var_config in input_variables:
if isinstance(var_config, dict):
var_name = var_config.get('variable', '')
default_value = var_config.get('default_value', None)
if var_name:
value = context.get_variable(var_name)
if value is not None:
local_vars[var_name] = value
elif default_value:
local_vars[var_name] = context.resolve_template(str(default_value))
else:
local_vars[var_name] = None
elif isinstance(var_config, str):
local_vars[var_config] = context.get_variable(var_config)
local_vars['user_input'] = context.user_input
local_vars['variables'] = context.variables.copy()
exec(code, safe_globals, local_vars)
if 'main' in local_vars and callable(local_vars['main']):
inputs_dict = {
'user_input': context.user_input,
**context.variables,
**local_vars,
}
main_result = local_vars['main'](inputs_dict)
if isinstance(main_result, dict):
result = main_result.get('result', main_result)
else:
result = main_result
else:
result = local_vars.get('result', None)
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=True,
output=result,
output_variables={output_variable: result},
elapsed_time=elapsed_time,
)
except Exception as e:
logger.exception(f'代码节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=str(e),
elapsed_time=elapsed_time,
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'code': {
'type': 'string',
'title': '代码',
'description': 'Python 代码,结果存储在 result 变量中',
'format': 'code',
'default': '# 在这里编写代码\n# 可用变量: user_input, variables\n# 将结果赋值给 result\n\nresult = user_input.upper()',
},
'input_variables': {
'type': 'array',
'title': '输入变量',
'items': {'type': 'string'},
'description': '需要传入代码的变量名列表',
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'code_result',
},
'timeout': {
'type': 'integer',
'title': '超时时间(秒)',
'default': 30,
'minimum': 1,
'maximum': 300,
},
},
'required': ['code'],
}
@@ -1,248 +0,0 @@
"""
条件分支节点
"""
import logging
import operator
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class ConditionNode(BaseNode):
"""
条件分支节点
根据条件判断选择不同的分支
"""
node_type = 'condition'
node_name = '条件分支'
node_category = 'logic'
node_icon = 'git-branch'
node_description = '根据条件判断选择不同的执行分支'
supports_branches = True
inputs = [
{
'name': 'value',
'type': 'any',
'description': '要判断的值',
},
]
outputs = [
{
'name': 'branch',
'type': 'string',
'description': '选中的分支',
},
]
# 支持的操作符
OPERATORS = {
'eq': operator.eq, # 等于
'ne': operator.ne, # 不等于
'gt': operator.gt, # 大于
'gte': operator.ge, # 大于等于
'lt': operator.lt, # 小于
'lte': operator.le, # 小于等于
'contains': lambda a, b: b in str(a), # 包含
'not_contains': lambda a, b: b not in str(a), # 不包含
'starts_with': lambda a, b: str(a).startswith(str(b)), # 开头是
'ends_with': lambda a, b: str(a).endswith(str(b)), # 结尾是
'is_empty': lambda a, _: not a, # 为空
'is_not_empty': lambda a, _: bool(a), # 不为空
}
def execute(self, context: NodeContext) -> NodeResult:
"""执行条件判断"""
try:
branches = self.config.get('branches', [])
# 遍历所有 IF 分支
for branch in branches:
branch_id = branch.get('id')
conditions = branch.get('conditions', [])
# 评估该分支的所有条件
if self._evaluate_branch(conditions, context):
return NodeResult(
success=True,
output=branch_id,
next_node_id=branch_id,
metadata={'matched_branch': branch_id},
)
# 没有匹配的条件,走 ELSE 分支
return NodeResult(
success=True,
output='else',
next_node_id='else',
metadata={'matched_branch': 'else'},
)
except Exception as e:
logger.exception(f'条件节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
)
def _evaluate_branch(self, conditions: list, context: NodeContext) -> bool:
"""评估分支的一组条件 (AND 关系)"""
if not conditions:
return True # 无条件默认为真
for condition in conditions:
if not self._evaluate_condition(condition, context):
return False
return True
def _resolve_variable(self, variable: str, context: NodeContext) -> Any:
"""
解析变量引用
支持格式:
- {{variable_name}} - 直接变量引用
- {{NodeID.key}} - 节点输出引用
- 普通字符串 - 直接返回
"""
if not isinstance(variable, str):
return variable
# 检查是否是 {{...}} 格式
if not (variable.startswith('{{') and variable.endswith('}}')):
return variable
content = variable[2:-2].strip()
logger.info(f'解析变量: {variable}, content={content}, 上下文变量keys={list(context.variables.keys())}')
# 尝试解析 NodeID.key 格式
if '.' in content:
parts = content.split('.', 1)
node_id = parts[0]
var_key = parts[1]
logger.info(f'解析节点变量: node_id={node_id}, var_key={var_key}')
# 先尝试从节点输出命名空间获取
node_outputs = context.get_variable(f'_node_{node_id}')
logger.info(f'节点输出 _node_{node_id}: {node_outputs}')
if isinstance(node_outputs, dict) and var_key in node_outputs:
return node_outputs[var_key]
# 回退:尝试直接从变量获取
node_data = context.get_variable(node_id)
logger.info(f'直接变量 {node_id}: {node_data}')
if isinstance(node_data, dict) and var_key in node_data:
return node_data[var_key]
# 直接变量引用
value = context.get_variable(content)
if value is not None:
return value
return None
def _evaluate_condition(self, condition: Dict, context: NodeContext) -> bool:
"""评估单个条件"""
variable = condition.get('variable', '')
op_name = condition.get('operator', 'equals')
value = condition.get('value', '')
# 解析变量和值
left_val = self._resolve_variable(variable, context)
right_val = self._resolve_variable(value, context)
logger.info(f'条件评估: variable={variable}, left_val={left_val}, op={op_name}, right_val={right_val}')
# 映射操作符
op_mapping = {
'equals': 'eq',
'not_equals': 'ne',
}
op_key = op_mapping.get(op_name, op_name)
op_func = self.OPERATORS.get(op_key, operator.eq)
try:
# 特殊处理空值判断,不需要右值
if op_key in ['is_empty', 'is_not_empty']:
return op_func(left_val, None)
# 布尔值比较:将字符串 "true"/"false" 转换为布尔值
if isinstance(left_val, bool) and isinstance(right_val, str):
if right_val.lower() in ['true', '1', 'yes']:
right_val = True
elif right_val.lower() in ['false', '0', 'no']:
right_val = False
elif isinstance(right_val, bool) and isinstance(left_val, str):
if left_val.lower() in ['true', '1', 'yes']:
left_val = True
elif left_val.lower() in ['false', '0', 'no']:
left_val = False
# 尝试转换类型以进行比较 (如数字)
if isinstance(left_val, (int, float)) and isinstance(right_val, str):
try:
if '.' in right_val:
right_val = float(right_val)
else:
right_val = int(right_val)
except:
pass
# 字符串比较忽略类型差异
if op_key in ['contains', 'not_contains', 'starts_with', 'ends_with']:
return op_func(str(left_val), str(right_val))
return op_func(left_val, right_val)
except Exception as e:
logger.warning(f'条件评估失败: {e}')
return False
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'conditions': {
'type': 'array',
'title': '条件列表',
'items': {
'type': 'object',
'properties': {
'variable': {
'type': 'string',
'title': '变量名',
},
'operator': {
'type': 'string',
'title': '操作符',
'enum': list(cls.OPERATORS.keys()),
'default': 'eq',
},
'value': {
'type': 'string',
'title': '比较值',
},
'branch_id': {
'type': 'string',
'title': '分支 ID',
},
},
},
},
'default_branch': {
'type': 'string',
'title': '默认分支',
'description': '没有条件匹配时执行的分支',
},
},
}
@@ -1,232 +0,0 @@
"""
仪表盘基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
import logging
import re
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardBasicInfoNode(BaseNode):
"""
仪表盘基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
node_type = 'dashboard_basic_info'
node_name = '仪表盘基础信息'
node_category = 'dashboard'
node_icon = 'layout-dashboard'
node_description = '处理仪表盘基础信息,验证和补充名称、编码等'
inputs = [
{
'name': 'name',
'type': 'string',
'description': '仪表盘名称',
'required': True,
},
{
'name': 'code',
'type': 'string',
'description': '仪表盘编码',
'required': False,
},
{
'name': 'category',
'type': 'string',
'description': '分类(dashboard/portal/databoard/other',
'required': False,
},
{
'name': 'description',
'type': 'string',
'description': '仪表盘描述',
'required': False,
},
{
'name': 'sort',
'type': 'number',
'description': '排序',
'required': False,
},
]
outputs = [
{
'name': 'dashboard_basic_info',
'type': 'object',
'description': '处理后的仪表盘基础信息',
},
{
'name': 'dashboard_name',
'type': 'string',
'description': '仪表盘名称',
},
{
'name': 'dashboard_code',
'type': 'string',
'description': '仪表盘编码',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'DashboardBasicInfoNode - 使用用户编辑的数据: {edited_data}')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
name = context.resolve_template(self.config.get('name', ''))
code = context.resolve_template(self.config.get('code', ''))
category = context.resolve_template(self.config.get('category', 'dashboard'))
description = context.resolve_template(self.config.get('description', ''))
# 处理sort(可能是字符串或数字)
sort_value = self.config.get('sort', 0)
sort_str = context.resolve_template(str(sort_value))
try:
sort = int(sort_str) if sort_str else 0
except (ValueError, TypeError):
sort = 0
logger.info(f'DashboardBasicInfoNode - 输入参数: name={name}, code={code}, category={category}')
# 验证必填字段
if not name:
return NodeResult(
success=False,
error='仪表盘名称不能为空',
)
# 自动生成编码(如果未提供)
if not code:
code = self._generate_code(name)
# 验证编码格式
if not self._validate_code(code):
return NodeResult(
success=False,
error=f'仪表盘编码格式不正确: {code},只能包含字母、数字、下划线和连字符',
)
# 验证分类
valid_categories = ['dashboard', 'portal', 'databoard', 'other']
if category and category not in valid_categories:
category = 'dashboard'
# 构建基础信息
basic_info = {
'name': name,
'code': code,
'category': category or 'dashboard',
'description': description or '',
'sort': sort,
}
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(basic_info, require_confirmation)
except Exception as e:
logger.error(f'DashboardBasicInfoNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'处理仪表盘基础信息失败: {str(e)}',
)
def _parse_user_edit(self, user_input: str) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
try:
import json
if isinstance(user_input, str):
data = json.loads(user_input)
else:
data = user_input
# 验证必要字段
if isinstance(data, dict) and data.get('name'):
return data
return None
except Exception:
return None
def _build_result(self, basic_info: Dict[str, Any], require_confirmation: bool) -> NodeResult:
"""构建节点结果"""
output_data = {
'dashboard_basic_info': basic_info,
'dashboard_name': basic_info.get('name', ''),
'dashboard_code': basic_info.get('code', ''),
}
if require_confirmation:
return NodeResult(
success=True,
output=output_data,
output_variables=output_data, # 添加 output_variables
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'dashboard_basic_info',
'title': '仪表盘基础信息确认',
'data': basic_info,
'editable': True,
'fields': [
{'key': 'name', 'label': '仪表盘名称', 'type': 'text', 'required': True},
{'key': 'code', 'label': '仪表盘编码', 'type': 'text', 'required': True},
{'key': 'category', 'label': '分类', 'type': 'select', 'options': [
{'label': '仪表盘', 'value': 'dashboard'},
{'label': '门户', 'value': 'portal'},
{'label': '数据看板', 'value': 'databoard'},
{'label': '其他', 'value': 'other'},
]},
{'key': 'description', 'label': '描述', 'type': 'textarea'},
{'key': 'sort', 'label': '排序', 'type': 'number'},
],
},
)
return NodeResult(
success=True,
output=output_data,
output_variables=output_data, # 添加 output_variables
)
def _generate_code(self, name: str) -> str:
"""根据名称生成编码"""
try:
from pypinyin import lazy_pinyin
# 使用拼音生成编码
pinyin_list = lazy_pinyin(name)
code = '_'.join(pinyin_list)
# 清理非法字符
code = re.sub(r'[^a-zA-Z0-9_]', '', code)
return f'page_{code}'
except ImportError:
# 如果没有pypinyin,使用简单的处理
code = re.sub(r'[^a-zA-Z0-9_]', '', name)
return f'page_{code}' if code else f'page_{id(name)}'
def _validate_code(self, code: str) -> bool:
"""验证编码格式"""
if not code:
return False
# 只允许字母、数字、下划线和连字符
pattern = r'^[a-zA-Z][a-zA-Z0-9_-]*$'
return bool(re.match(pattern, code))
@@ -1,338 +0,0 @@
"""
仪表盘创建节点
将仪表盘配置保存到数据库
"""
import json
import logging
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardCreateNode(BaseNode):
"""
仪表盘创建节点
将仪表盘基础信息和页面配置保存到数据库
"""
node_type = 'dashboard_create'
node_name = '仪表盘创建'
node_category = 'dashboard'
node_icon = 'file-plus'
node_description = '将仪表盘配置保存到数据库'
inputs = [
{
'name': 'dashboard_basic_info',
'type': 'object',
'description': '仪表盘基础信息(来自基础信息节点)',
'required': True,
},
{
'name': 'page_config',
'type': 'object',
'description': '页面设计配置(来自设计节点)',
'required': True,
},
{
'name': 'application_id',
'type': 'string',
'description': '所属应用ID(可选)',
'required': False,
},
]
outputs = [
{
'name': 'dashboard_id',
'type': 'string',
'description': '创建的仪表盘ID',
},
{
'name': 'dashboard_code',
'type': 'string',
'description': '仪表盘编码',
},
{
'name': 'page_meta',
'type': 'object',
'description': '完整的页面元数据',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数
dashboard_basic_info_raw = self.config.get('dashboard_basic_info', '{}')
page_config_raw = self.config.get('page_config', '{}')
# 解析变量引用
dashboard_basic_info = self._resolve_variable_value(context, dashboard_basic_info_raw)
page_config = self._resolve_variable_value(context, page_config_raw)
application_id = context.resolve_template(self.config.get('application_id', ''))
# 配置选项
update_if_exists = self.config.get('update_if_exists', True)
logger.info(f'DashboardCreateNode - 开始创建仪表盘')
logger.info(f'DashboardCreateNode - 原始配置: dashboard_basic_info_raw={dashboard_basic_info_raw}, page_config_raw={page_config_raw}')
logger.info(f'DashboardCreateNode - 上下文变量: {list(context.variables.keys())}')
logger.info(f'DashboardCreateNode - dashboard_basic_info type: {type(dashboard_basic_info)}, value: {dashboard_basic_info}')
logger.info(f'DashboardCreateNode - page_config type: {type(page_config)}, value: {str(page_config)[:200]}')
# 确保 dashboard_basic_info 是字典
if isinstance(dashboard_basic_info, str):
try:
dashboard_basic_info = json.loads(dashboard_basic_info)
logger.info(f'DashboardCreateNode - 成功解析 dashboard_basic_info 字符串为字典')
except json.JSONDecodeError as e:
logger.error(f'DashboardCreateNode - 解析 dashboard_basic_info 失败: {e}')
return NodeResult(
success=False,
error=f'仪表盘基础信息格式错误: {str(e)}',
)
# 确保 page_config 是字典
if isinstance(page_config, str):
try:
page_config = json.loads(page_config)
logger.info(f'DashboardCreateNode - 成功解析 page_config 字符串为字典')
except json.JSONDecodeError as e:
logger.error(f'DashboardCreateNode - 解析 page_config 失败: {e}')
return NodeResult(
success=False,
error=f'页面配置格式错误: {str(e)}',
)
# 验证必要配置
if not dashboard_basic_info:
return NodeResult(
success=False,
error='仪表盘基础信息不能为空',
)
if not page_config:
return NodeResult(
success=False,
error='页面设计配置不能为空',
)
# 提取基础信息
name = dashboard_basic_info.get('name', '')
code_base = dashboard_basic_info.get('code', '')
category = dashboard_basic_info.get('category', 'dashboard')
description = dashboard_basic_info.get('description', '')
sort = dashboard_basic_info.get('sort', 0)
if not name or not code_base:
return NodeResult(
success=False,
error='仪表盘名称和编码不能为空',
)
# 保存到数据库
from app.database import AsyncSessionLocal
from online_dev.page_manager.model import PageMeta
from sqlalchemy import select
async with AsyncSessionLocal() as db:
# 生成唯一的 code(如果已存在则自动添加后缀)
code = await self._get_unique_code(db, code_base)
logger.info(f'DashboardCreateNode - 使用 code: {code} (原始: {code_base})')
# 检查是否已存在(理论上不应该存在,因为已经生成了唯一 code)
stmt = select(PageMeta).where(PageMeta.code == code, PageMeta.is_deleted == False)
result = await db.execute(stmt)
existing_page = result.scalar_one_or_none()
if existing_page:
if update_if_exists:
# 更新现有记录
existing_page.name = name
existing_page.category = category
existing_page.description = description
existing_page.sort = sort
existing_page.page_config = page_config
if application_id:
existing_page.application_id = application_id
existing_page.version = (existing_page.version or 1) + 1
await db.commit()
await db.refresh(existing_page)
page_meta = self._page_to_dict(existing_page)
logger.info(f'DashboardCreateNode - 更新仪表盘成功: {code}')
else:
return NodeResult(
success=False,
error=f'仪表盘编码已存在: {code}',
)
else:
# 创建新记录
new_page = PageMeta(
name=name,
code=code,
category=category,
description=description,
sort=sort,
status='draft',
version=1,
page_config=page_config,
application_id=application_id if application_id else None,
)
db.add(new_page)
await db.commit()
await db.refresh(new_page)
page_meta = self._page_to_dict(new_page)
logger.info(f'DashboardCreateNode - 创建仪表盘成功: {code}')
output_data = {
'dashboard_id': page_meta['id'],
'dashboard_code': page_meta['code'],
'page_meta': page_meta,
}
return NodeResult(
success=True,
output=output_data,
output_variables=output_data,
)
except Exception as e:
logger.error(f'DashboardCreateNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'创建仪表盘失败: {str(e)}',
)
def _resolve_variable_value(self, context: NodeContext, value: Any) -> Any:
"""解析变量引用,保持原始类型"""
import re
import ast
# 如果已经是字典,直接返回
if isinstance(value, dict):
return value
# 如果不是字符串,返回原值
if not isinstance(value, str):
return value
value = value.strip()
if not value or value == '{}':
return {}
# 检查是否是单个变量引用 {{node_id.variable_name}}
single_var_pattern = r'^\{\{([^}]+)\}\}$'
match = re.match(single_var_pattern, value)
if match:
var_ref = match.group(1).strip()
# 解析 node_id.variable_name 格式
if '.' in var_ref:
parts = var_ref.split('.', 1)
node_id = parts[0]
key = parts[1]
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict) and key in node_outputs:
return node_outputs[key]
# 直接从变量中获取
if key in context.variables:
return context.variables[key]
logger.warning(f'变量引用未找到: {var_ref}')
else:
# 直接变量引用
if var_ref in context.variables:
return context.variables[var_ref]
logger.warning(f'变量引用未找到: {var_ref}')
# 不是变量引用,尝试解析为 JSON 或 Python dict
# 尝试 JSON 解析
try:
return json.loads(value)
except json.JSONDecodeError:
pass
# 尝试 Python literal_eval(处理单引号格式)
try:
result = ast.literal_eval(value)
if isinstance(result, dict):
return result
return result
except (ValueError, SyntaxError) as e:
logger.warning(f'配置解析失败: {e}, 值: {value[:200]}')
return value
async def _get_unique_code(self, db, base_code: str) -> str:
"""
生成唯一的 code,如果已存在则自动添加数字后缀
Args:
db: 数据库会话
base_code: 基础 code
Returns:
唯一的 code
"""
from online_dev.page_manager import PageService
code = base_code
counter = 1
while True:
try:
existing = await PageService.get_by_code(db, code)
if not existing:
# code 不存在,可以使用
break
# code 已存在,添加后缀
code = f"{base_code}_{counter}"
counter += 1
except Exception:
# 查询出错,认为 code 可用
break
return code
def _page_to_dict(self, page: Any) -> Dict[str, Any]:
"""将PageMeta对象转换为字典"""
return {
'id': page.id,
'name': page.name,
'code': page.code,
'category': page.category,
'description': page.description,
'status': page.status,
'version': page.version,
'sort': page.sort,
'page_config': page.page_config,
'application_id': page.application_id,
}
@@ -1,483 +0,0 @@
"""
仪表盘设计节点
提供可视化设计界面,让用户设计仪表盘布局
"""
import ast
import json
import logging
import random
from typing import Any, Dict, List, Optional
from uuid import uuid4
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardDesignNode(BaseNode):
"""
仪表盘设计节点
提供可视化设计界面,让用户设计仪表盘布局和组件
"""
node_type = 'dashboard_design'
node_name = '仪表盘设计'
node_category = 'dashboard'
node_icon = 'layout-dashboard'
node_description = '可视化设计仪表盘布局和组件'
inputs = [
{
'name': 'dashboard_code',
'type': 'string',
'description': '仪表盘编码(来自基础信息节点)',
'required': False,
},
{
'name': 'design_suggestion',
'type': 'string',
'description': 'LLM生成的设计建议(可选)',
'required': False,
},
{
'name': 'initial_config',
'type': 'object',
'description': '初始页面配置(可选)',
'required': False,
},
]
outputs = [
{
'name': 'page_config',
'type': 'object',
'description': '页面设计配置(dashboard-design的JSON配置)',
},
{
'name': 'design_title',
'type': 'string',
'description': '设计方案标题',
},
{
'name': 'confirmed',
'type': 'boolean',
'description': '是否已确认',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户提交的设计配置
user_input = context.variables.get('__user_input__')
if user_input:
design_data = self._parse_user_design(user_input)
if design_data:
logger.info(f'DashboardDesignNode - 使用用户设计的配置')
context.variables.pop('__user_input__', None)
return self._build_result(design_data, confirmed=True)
# 获取输入参数
dashboard_code = context.resolve_template(self.config.get('dashboard_code', ''))
design_suggestion = context.resolve_template(self.config.get('design_suggestion', ''))
initial_config = self.config.get('initial_config', {})
# 如果initial_config是变量引用,解析它
if isinstance(initial_config, str) and initial_config.startswith('{{'):
initial_config = self._resolve_variable_value(context, initial_config) or {}
logger.info(f'DashboardDesignNode - dashboard_code={dashboard_code}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
design_title = self.config.get('design_title', '仪表盘设计')
# 如果没有初始配置,根据 design_suggestion 生成默认模板
if not initial_config or not initial_config.get('widgets'):
initial_config = self._generate_dashboard_template(design_suggestion, dashboard_code)
# 构建设计数据
design_data = {
'dashboard_code': dashboard_code,
'design_title': design_title,
'design_suggestion': design_suggestion,
'page_config': initial_config,
}
if require_confirmation:
return NodeResult(
success=True,
output={
'page_config': design_data.get('page_config', {}),
'design_title': design_title,
'confirmed': False,
},
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'dashboard_design',
'title': design_title,
'data': design_data,
'editable': True,
'fullscreen': True,
},
)
return self._build_result(design_data, confirmed=True)
except Exception as e:
logger.error(f'DashboardDesignNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'仪表盘设计节点执行失败: {str(e)}',
)
def _parse_user_design(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户提交的设计配置"""
try:
if isinstance(user_input, str):
data = json.loads(user_input)
else:
data = user_input
if isinstance(data, dict):
return data
return None
except Exception as e:
logger.error(f'解析用户设计配置失败: {e}')
return None
def _build_result(self, design_data: Dict[str, Any], confirmed: bool) -> NodeResult:
"""构建节点结果"""
page_config = design_data.get('page_config', {})
design_title = design_data.get('design_title', '仪表盘设计')
output_data = {
'page_config': page_config,
'design_title': design_title,
'confirmed': confirmed,
}
return NodeResult(
success=True,
output=output_data,
output_variables=output_data, # 添加 output_variables
)
def _resolve_variable_value(self, context: NodeContext, value: Any) -> Any:
"""解析变量引用"""
if isinstance(value, str):
resolved = context.resolve_template(value)
if resolved != value:
try:
return json.loads(resolved) if isinstance(resolved, str) else resolved
except (json.JSONDecodeError, TypeError):
return resolved
return value
def _generate_dashboard_template(self, design_suggestion: str, dashboard_code: str) -> Dict[str, Any]:
"""根据 LLM 建议生成仪表盘模板"""
try:
# 解析 design_suggestion(可能是字符串形式的字典)
modules = self._parse_design_suggestion(design_suggestion)
# 生成基础模板
template = {
"id": str(uuid4()),
"name": "我的仪表盘",
"columns": 12,
"rowHeight": 50,
"margin": [12, 12],
"widgets": []
}
# 添加欢迎卡片
welcome_id = str(uuid4())
template["widgets"].append({
"id": welcome_id,
"i": welcome_id,
"type": "welcome-card",
"x": 0,
"y": 0,
"w": 8,
"h": 2,
"minW": 4,
"minH": 2,
"title": "欢迎卡片",
"props": {
"title": "欢迎回来",
"subtitle": "今天是个好日子",
"showTime": True,
"showWeather": False
}
})
# 添加天气组件
weather_id = str(uuid4())
template["widgets"].append({
"id": weather_id,
"i": weather_id,
"type": "weather",
"x": 8,
"y": 0,
"w": 4,
"h": 2,
"minW": 2,
"minH": 2,
"title": "天气",
"props": {
"title": "今日天气",
"city": "北京",
"temperature": 25,
"weather": "",
"humidity": 45,
"wind": "东北风 3级",
"icon": "sunny"
}
})
# 添加日历
calendar_id = str(uuid4())
template["widgets"].append({
"id": calendar_id,
"i": calendar_id,
"type": "calendar",
"x": 8,
"y": 2,
"w": 4,
"h": 5,
"minW": 3,
"minH": 4,
"title": "日历",
"props": {
"title": "日历",
"showLunar": False
}
})
# 根据模块生成图表
y_offset = 2
if modules and len(modules) > 0:
# 生成折线图(基于第一个模块)
module1 = modules[0] if len(modules) > 0 else {"module_name": "数据", "moduld_fields": []}
line_chart = self._generate_line_chart(module1, 0, y_offset)
template["widgets"].append(line_chart)
y_offset += 5
# 生成柱状图(基于第二个模块)
module2 = modules[1] if len(modules) > 1 else module1
bar_chart = self._generate_bar_chart(module2, 0, y_offset)
template["widgets"].append(bar_chart)
# 生成饼图(基于第三个模块)
module3 = modules[2] if len(modules) > 2 else module1
pie_chart = self._generate_pie_chart(module3, 8, y_offset)
template["widgets"].append(pie_chart)
else:
# 没有模块信息,使用默认图表
template["widgets"].extend([
self._generate_line_chart({"module_name": "访问趋势", "moduld_fields": []}, 0, y_offset),
self._generate_bar_chart({"module_name": "销售统计", "moduld_fields": []}, 0, y_offset + 5),
self._generate_pie_chart({"module_name": "流量来源", "moduld_fields": []}, 8, y_offset + 5)
])
return template
except Exception as e:
logger.error(f'生成仪表盘模板失败: {e}')
# 返回空模板
return {
"id": str(uuid4()),
"name": "我的仪表盘",
"columns": 12,
"rowHeight": 50,
"margin": [12, 12],
"widgets": []
}
def _parse_design_suggestion(self, design_suggestion: str) -> List[Dict[str, Any]]:
"""解析 LLM 生成的设计建议"""
try:
if not design_suggestion:
return []
# 尝试解析为 JSON
if isinstance(design_suggestion, str):
try:
data = json.loads(design_suggestion)
except json.JSONDecodeError:
# 尝试使用 ast.literal_eval
try:
data = ast.literal_eval(design_suggestion)
except (ValueError, SyntaxError):
return []
else:
data = design_suggestion
# 提取模块信息
if isinstance(data, dict):
modules = data.get('app_modules', [])
if isinstance(modules, list):
return modules
return []
except Exception as e:
logger.error(f'解析设计建议失败: {e}')
return []
def _generate_line_chart(self, module: Dict[str, Any], x: int, y: int) -> Dict[str, Any]:
"""生成折线图"""
module_name = module.get('module_name', '访问趋势')
fields = module.get('moduld_fields', [])
# 生成模拟数据
months = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]
series_data = []
# 根据字段生成系列(最多2个)
for i, field in enumerate(fields[:2]):
data = [random.randint(500, 1500) for _ in range(12)]
series_data.append({
"name": field if isinstance(field, str) else f"系列{i+1}",
"data": data
})
# 如果没有字段,使用默认系列
if not series_data:
series_data = [
{"name": "访问量", "data": [820, 932, 901, 934, 1290, 1330, 1320, 1450, 1200, 1100, 1350, 1500]},
{"name": "下载量", "data": [620, 732, 701, 734, 1090, 1130, 1120, 1250, 1000, 900, 1150, 1300]}
]
line_id = str(uuid4())
return {
"id": line_id,
"i": line_id,
"type": "chart-line",
"x": x,
"y": y,
"w": 8,
"h": 5,
"minW": 4,
"minH": 3,
"title": "折线图",
"props": {
"title": module_name,
"smooth": True,
"showArea": False,
"showSymbol": True,
"symbolSize": 6,
"lineWidth": 2,
"showLegend": True,
"legendPosition": "top",
"colors": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"],
"xAxisData": months,
"seriesData": series_data
}
}
def _generate_bar_chart(self, module: Dict[str, Any], x: int, y: int) -> Dict[str, Any]:
"""生成柱状图"""
module_name = module.get('module_name', '销售统计')
fields = module.get('moduld_fields', [])
# 生成模拟数据
regions = ["华东", "华南", "华北", "华中", "西南", "西北", "东北"]
series_data = []
# 根据字段生成系列(最多2个)
for i, field in enumerate(fields[:2]):
data = [random.randint(200, 500) for _ in range(7)]
series_data.append({
"name": field if isinstance(field, str) else f"{2023+i}",
"data": data
})
# 如果没有字段,使用默认系列
if not series_data:
series_data = [
{"name": "2023", "data": [320, 302, 301, 334, 390, 330, 320]},
{"name": "2024", "data": [420, 382, 391, 434, 490, 430, 420]}
]
bar_id = str(uuid4())
return {
"id": bar_id,
"i": bar_id,
"type": "chart-bar",
"x": x,
"y": y,
"w": 8,
"h": 6,
"minW": 4,
"minH": 3,
"title": "柱状图",
"props": {
"title": module_name,
"horizontal": False,
"stack": False,
"barWidth": "auto",
"barRadius": 4,
"showBackground": False,
"showLegend": True,
"legendPosition": "top",
"colors": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"],
"xAxisData": regions,
"seriesData": series_data
}
}
def _generate_pie_chart(self, module: Dict[str, Any], x: int, y: int) -> Dict[str, Any]:
"""生成饼图"""
module_name = module.get('module_name', '流量来源')
fields = module.get('moduld_fields', [])
# 根据字段生成数据
series_data = []
if fields and len(fields) > 0:
# 使用字段名作为分类(最多5个)
for field in fields[:5]:
series_data.append({
"name": field if isinstance(field, str) else f"分类{len(series_data)+1}",
"value": random.randint(300, 1000)
})
# 如果没有字段,使用默认数据
if not series_data:
series_data = [
{"name": "搜索引擎", "value": 1048},
{"name": "直接访问", "value": 735},
{"name": "邮件营销", "value": 580},
{"name": "联盟广告", "value": 484},
{"name": "视频广告", "value": 300}
]
pie_id = str(uuid4())
return {
"id": pie_id,
"i": pie_id,
"type": "chart-pie",
"x": x,
"y": y,
"w": 4,
"h": 6,
"minW": 3,
"minH": 3,
"title": "饼图",
"props": {
"title": module_name,
"pieType": "rose",
"radius": ["0%", "70%"],
"showLabel": True,
"labelPosition": "outside",
"showLegend": True,
"legendPosition": "bottom",
"colors": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"],
"seriesData": series_data
}
}
@@ -1,311 +0,0 @@
"""
仪表盘发布节点
将仪表盘发布到菜单系统
"""
import logging
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardPublishNode(BaseNode):
"""
仪表盘发布节点
将仪表盘发布到菜单系统,创建菜单项
"""
node_type = 'dashboard_publish'
node_name = '仪表盘发布'
node_category = 'dashboard'
node_icon = 'upload'
node_description = '将仪表盘发布到菜单系统'
inputs = [
{
'name': 'dashboard_id',
'type': 'string',
'description': '仪表盘ID(来自创建节点)',
'required': True,
},
{
'name': 'menu_name',
'type': 'string',
'description': '菜单名称',
'required': True,
},
{
'name': 'menu_parent_id',
'type': 'string',
'description': '父菜单ID(可选)',
'required': False,
},
{
'name': 'menu_icon',
'type': 'string',
'description': '菜单图标',
'required': False,
},
{
'name': 'menu_order',
'type': 'number',
'description': '菜单排序',
'required': False,
},
{
'name': 'application_id',
'type': 'string',
'description': '所属应用ID',
'required': False,
},
]
outputs = [
{
'name': 'menu_id',
'type': 'string',
'description': '创建的菜单ID',
},
{
'name': 'route_path',
'type': 'string',
'description': '访问路径',
},
{
'name': 'publish_result',
'type': 'object',
'description': '发布结果',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
# 检查是否有用户输入(确认后的数据)
user_input = context.user_input
logger.info(f'DashboardPublishNode - user_input 类型: {type(user_input)}, 值: {user_input}')
logger.info(f'DashboardPublishNode - require_confirmation: {require_confirmation}')
# 解析 user_input(可能是字符串或字典)
user_input_dict = None
if user_input:
if isinstance(user_input, str):
try:
import json
user_input_dict = json.loads(user_input)
logger.info(f'DashboardPublishNode - 解析 JSON 字符串为字典: {user_input_dict}')
except json.JSONDecodeError as e:
logger.warning(f'DashboardPublishNode - JSON 解析失败: {e}')
elif isinstance(user_input, dict):
user_input_dict = user_input
# 检查 user_input 是否包含发布节点需要的字段(dashboard_id 和 menu_name
# 如果包含,说明是用户确认后的数据;否则是从上一个节点传递过来的数据
if user_input_dict and 'dashboard_id' in user_input_dict and 'menu_name' in user_input_dict:
# 用户已确认,使用确认后的数据
logger.info(f'DashboardPublishNode - 使用用户确认的发布配置')
return await self._execute_publish(context, user_input_dict)
# 获取输入参数
dashboard_id = context.resolve_template(self.config.get('dashboard_id', ''))
menu_name = context.resolve_template(self.config.get('menu_name', ''))
menu_parent_id = context.resolve_template(self.config.get('menu_parent_id', ''))
menu_icon = context.resolve_template(self.config.get('menu_icon', 'lucide:layout-dashboard'))
application_id = context.resolve_template(self.config.get('application_id', ''))
# 处理menu_order
menu_order_value = self.config.get('menu_order', 0)
menu_order_str = context.resolve_template(str(menu_order_value))
try:
menu_order = int(menu_order_str) if menu_order_str else 0
except (ValueError, TypeError):
menu_order = 0
logger.info(f'DashboardPublishNode - 开始发布仪表盘: {dashboard_id}')
# 验证必要参数
if not dashboard_id:
return NodeResult(
success=False,
error='仪表盘ID不能为空',
)
if not menu_name:
return NodeResult(
success=False,
error='菜单名称不能为空',
)
# 获取仪表盘的 code(用于显示路由路径)
dashboard_code = ''
try:
from app.database import AsyncSessionLocal
from online_dev.page_manager.model import PageMeta
from sqlalchemy import select
async with AsyncSessionLocal() as db:
stmt = select(PageMeta.code).where(PageMeta.id == dashboard_id, PageMeta.is_deleted == False)
result = await db.execute(stmt)
dashboard_code = result.scalar_one_or_none() or ''
except Exception as e:
logger.warning(f'获取仪表盘 code 失败: {e}')
# 构建发布数据
publish_data = {
'dashboard_id': dashboard_id,
'dashboard_code': dashboard_code,
'menu_name': menu_name,
'menu_parent_id': menu_parent_id,
'menu_icon': menu_icon,
'menu_order': menu_order,
'application_id': application_id,
}
# 如果需要确认,返回等待用户输入
if require_confirmation:
return NodeResult(
success=True,
output=publish_data,
output_variables=publish_data,
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'dashboard_publish',
'title': '仪表盘发布确认',
'data': publish_data,
'editable': True,
},
)
# 不需要确认,直接发布
return await self._execute_publish(context, publish_data)
except Exception as e:
logger.error(f'DashboardPublishNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'发布仪表盘失败: {str(e)}',
)
async def _execute_publish(self, context: NodeContext, publish_data: Dict[str, Any]) -> NodeResult:
"""执行发布操作"""
try:
dashboard_id = publish_data.get('dashboard_id', '')
menu_name = publish_data.get('menu_name', '')
menu_parent_id = publish_data.get('menu_parent_id', '')
menu_icon = publish_data.get('menu_icon', 'lucide:layout-dashboard')
menu_order = publish_data.get('menu_order', 0)
application_id = publish_data.get('application_id', '')
if not dashboard_id or not menu_name:
return NodeResult(
success=False,
error='仪表盘ID和菜单名称不能为空',
)
# 发布到数据库
from app.database import AsyncSessionLocal
from online_dev.page_manager.model import PageMeta
from core.menu.model import Menu
from sqlalchemy import select
async with AsyncSessionLocal() as db:
# 获取仪表盘信息
stmt = select(PageMeta).where(PageMeta.id == dashboard_id, PageMeta.is_deleted == False)
result = await db.execute(stmt)
page = result.scalar_one_or_none()
if not page:
return NodeResult(
success=False,
error=f'仪表盘不存在: {dashboard_id}',
)
# 生成路由路径
route_path = f'/page-render/{page.code}'
# 检查菜单是否已存在
menu_stmt = select(Menu).where(
Menu.path == route_path,
Menu.is_deleted == False
)
menu_result = await db.execute(menu_stmt)
existing_menu = menu_result.scalar_one_or_none()
if existing_menu:
# 更新现有菜单
existing_menu.title = menu_name
existing_menu.icon = menu_icon
existing_menu.order = menu_order
if menu_parent_id:
existing_menu.parent_id = menu_parent_id
menu_id = existing_menu.id
logger.info(f'DashboardPublishNode - 更新菜单: {menu_id}')
else:
# 创建新菜单
new_menu = Menu(
name=menu_name,
title=menu_name,
path=route_path,
component='_core/page-render/index',
icon=menu_icon,
order=menu_order,
type='online_page',
parent_id=menu_parent_id if menu_parent_id else None,
application_id=application_id if application_id else page.application_id,
)
db.add(new_menu)
await db.flush()
menu_id = new_menu.id
logger.info(f'DashboardPublishNode - 创建菜单: {menu_id}')
# 更新页面状态为已发布
page.status = 'published'
await db.commit()
output_data = {
'menu_id': menu_id,
'route_path': route_path,
'dashboard_id': dashboard_id,
'dashboard_code': page.code,
'dashboard_name': page.name,
'menu_name': menu_name,
'status': 'published',
}
return NodeResult(
success=True,
output=output_data,
output_variables=output_data,
)
except Exception as e:
logger.error(f'DashboardPublishNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'发布仪表盘失败: {str(e)}',
)
@@ -1,794 +0,0 @@
"""
数据库操作节点
"""
import json
import logging
import time
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional
from uuid import UUID
from sqlalchemy import text
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.db_execution import (
DbTarget,
build_sql_param_dict,
build_where_clause_platform,
build_where_clause_raw,
default_connection_write_warnings,
format_limit_clause,
format_select_sql,
merge_result_metadata,
normalize_return_fields,
quote_table_for_target,
resolve_db_target,
resolve_handler_schema_name,
)
logger = logging.getLogger(__name__)
def serialize_value(value: Any) -> Any:
"""将数据库值转换为可 JSON 序列化的格式"""
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, bytes):
return value.decode('utf-8', errors='replace')
if isinstance(value, (list, tuple)):
return [serialize_value(v) for v in value]
if isinstance(value, dict):
return {k: serialize_value(v) for k, v in value.items()}
return value
def serialize_row(row: Dict[str, Any]) -> Dict[str, Any]:
"""序列化数据库行"""
return {k: serialize_value(v) for k, v in row.items()}
def prepare_value_for_db(value: Any) -> Any:
"""将值转换为数据库可接受的格式(dict/list 转为 JSON 字符串)"""
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, UUID):
return str(value)
return value
ALLOWED_TABLES = []
PROTECTED_FIELDS = ['password', 'token', 'secret', 'api_key', 'private_key']
class BaseDatabaseNode(BaseNode):
"""
数据库操作节点基类
default 连接走平台 AsyncSession;第三方连接走 AsyncDatabaseManagerService。
"""
node_type = 'database'
node_name = '数据库操作'
node_category = 'data'
node_icon = 'database'
node_description = '对数据库进行增删改查操作'
inputs = [
{
'name': 'data',
'type': 'object',
'description': '要操作的数据',
},
]
outputs = [
{
'name': 'result',
'type': 'object',
'description': '操作结果',
},
{
'name': 'affected_rows',
'type': 'number',
'description': '影响的行数',
},
]
def _get_db_target(self) -> DbTarget:
return resolve_db_target(self.config.get('db_config'))
def _build_full_table_name(self, table: str, target: Optional[DbTarget] = None) -> str:
"""构建完整的表名(平台 PG 路径)"""
db_config = self.config.get('db_config', {})
schema = db_config.get('schema', '')
if schema:
return f'"{schema}"."{table}"'
return f'"{table}"'
def execute(self, context: NodeContext) -> NodeResult:
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
start_time = time.time()
try:
operation = self.config.get('operation', 'select').lower()
table = self.config.get('table', '')
output_variable = self.config.get('output_variable', 'db_result')
frontend_max_rows = int(self.config.get('frontend_max_rows', 100))
target = self._get_db_target()
if not table:
raise ValueError('未指定目标表')
if ALLOWED_TABLES and table not in ALLOWED_TABLES:
is_allowed = any(allowed == '*' or table == allowed for allowed in ALLOWED_TABLES)
if not is_allowed:
raise ValueError(f'{table} 不在允许操作的白名单中')
if operation == 'insert':
result = await self._execute_insert(table, context, target)
elif operation == 'update':
result = await self._execute_update(table, context, target)
elif operation == 'upsert':
result = await self._execute_upsert(table, context, target)
elif operation == 'select':
result = await self._execute_select(table, context, target)
elif operation == 'delete':
result = await self._execute_delete(table, context, target)
else:
raise ValueError(f'不支持的操作类型: {operation}')
elapsed_time = int((time.time() - start_time) * 1000)
full_data = result.get('data')
affected_rows = result.get('affected_rows', 0)
output_variables = {
output_variable: full_data,
f'{output_variable}_count': affected_rows,
}
frontend_output_variables = output_variables
frontend_output = full_data
if operation == 'select' and isinstance(full_data, list) and len(full_data) > frontend_max_rows:
truncated = full_data[:frontend_max_rows]
frontend_output = truncated
frontend_output_variables = {
output_variable: truncated,
f'{output_variable}_count': affected_rows,
f'{output_variable}_total': len(full_data),
}
warnings = default_connection_write_warnings(operation, target)
metadata = merge_result_metadata(
{'frontend_output_variables': frontend_output_variables},
warnings,
)
return NodeResult(
success=True,
output=frontend_output,
output_variables=output_variables,
metadata=metadata,
elapsed_time=elapsed_time,
)
except Exception as e:
logger.exception(f'数据库节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
elapsed_time=int((time.time() - start_time) * 1000),
)
async def _create_db_service(self, context: NodeContext, target: DbTarget):
from core.database_manager.service import AsyncDatabaseManagerService
try:
return await AsyncDatabaseManagerService.create(target.db_name, context.db_session)
except Exception as e:
raise ValueError(f'无法连接数据库 {target.db_name}: {e}') from e
def _resolve_field_mapping(self, context: NodeContext) -> Dict[str, Any]:
field_mapping = self.config.get('field_mapping', {})
resolved = {}
for field, value in field_mapping.items():
if field.lower() in PROTECTED_FIELDS:
logger.warning(f'跳过保护字段: {field}')
continue
if isinstance(value, str):
resolved_value = context.resolve_template(value)
try:
resolved[field] = json.loads(resolved_value)
except (json.JSONDecodeError, TypeError):
resolved[field] = resolved_value
else:
resolved[field] = value
return resolved
def _resolve_conditions(self, context: NodeContext) -> List[Dict[str, Any]]:
conditions = self.config.get('where_conditions', [])
resolved = []
for condition in conditions:
field = condition.get('field', '')
operator = condition.get('operator', '=')
value = condition.get('value', '')
if isinstance(value, str):
resolved_value = context.resolve_template(value)
try:
resolved_value = json.loads(resolved_value)
except (json.JSONDecodeError, TypeError):
pass
else:
resolved_value = value
resolved.append({
'field': field,
'operator': operator,
'value': resolved_value,
})
return resolved
async def _execute_insert(
self,
table: str,
context: NodeContext,
target: DbTarget,
) -> Dict[str, Any]:
data = self._resolve_field_mapping(context)
if not data:
raise ValueError('没有要插入的数据')
if 'id' not in data:
data['id'] = str(uuid.uuid4())
if target.is_external:
db_service = await self._create_db_service(context, target)
schema_name = await resolve_handler_schema_name(db_service, target)
payload = {k: prepare_value_for_db(v) for k, v in data.items()}
result = await db_service.insert_data(table, payload, schema_name)
if not result.get('success'):
raise ValueError(result.get('message', '插入失败'))
return {
'data': {'id': data['id'], **data},
'affected_rows': result.get('affected_rows', 1),
}
db = context.db_session
if not db:
raise ValueError('数据库会话不可用,请确保工作流已配置数据库连接')
full_table_name = self._build_full_table_name(table)
fields = list(data.keys())
params = {f: prepare_value_for_db(v) for f, v in data.items()}
placeholders = ', '.join([f':{f}' for f in fields])
field_names = ', '.join([f'"{f}"' for f in fields])
sql = f'INSERT INTO {full_table_name} ({field_names}) VALUES ({placeholders})'
await db.execute(text(sql), params)
return {
'data': {'id': data['id'], **data},
'affected_rows': 1,
}
async def _execute_update(
self,
table: str,
context: NodeContext,
target: DbTarget,
) -> Dict[str, Any]:
data = self._resolve_field_mapping(context)
conditions = self._resolve_conditions(context)
if not data:
raise ValueError('没有要更新的数据')
if not conditions:
raise ValueError('UPDATE 操作必须指定条件,防止误更新全表')
if target.is_external:
db_service = await self._create_db_service(context, target)
schema_name = await resolve_handler_schema_name(db_service, target)
where_raw = build_where_clause_raw(conditions, db_service.db_type)
payload = {k: prepare_value_for_db(v) for k, v in data.items()}
result = await db_service.update_data(table, payload, where_raw, schema_name)
if not result.get('success'):
raise ValueError(result.get('message', '更新失败'))
affected_rows = result.get('affected_rows', 0)
return {
'data': {'updated': True, 'affected_rows': affected_rows, **data},
'affected_rows': affected_rows,
}
db = context.db_session
if not db:
raise ValueError('数据库会话不可用')
set_clauses = []
params = {}
for field, value in data.items():
param_name = f's_{field}'
set_clauses.append(f'"{field}" = :{param_name}')
params[param_name] = prepare_value_for_db(value)
where_clause, where_params = build_where_clause_platform(conditions)
params.update(where_params)
full_table_name = self._build_full_table_name(table)
sql = f'UPDATE {full_table_name} SET {", ".join(set_clauses)} {where_clause}'
result = await db.execute(text(sql), params)
affected_rows = result.rowcount
return {
'data': {'updated': True, 'affected_rows': affected_rows, **data},
'affected_rows': affected_rows,
}
async def _execute_upsert(
self,
table: str,
context: NodeContext,
target: DbTarget,
) -> Dict[str, Any]:
data = self._resolve_field_mapping(context)
conditions = self._resolve_conditions(context)
if not data:
raise ValueError('没有要操作的数据')
if conditions:
if target.is_external:
db_service = await self._create_db_service(context, target)
schema_name = await resolve_handler_schema_name(db_service, target)
where_raw = build_where_clause_raw(conditions, db_service.db_type)
full_table = quote_table_for_target(table, target)
check_sql = f'SELECT 1 FROM {full_table}'
if where_raw:
check_sql += f' WHERE {where_raw}'
check_sql += format_limit_clause(db_service.db_type, 1)
check_result = await db_service.execute_sql(check_sql, is_query=True)
rows = check_result.get('rows') or check_result.get('data') or []
if rows:
return await self._execute_update(table, context, target)
else:
db = context.db_session
if not db:
raise ValueError('数据库会话不可用')
full_table_name = self._build_full_table_name(table)
where_clause, where_params = build_where_clause_platform(conditions)
check_sql = f'SELECT id FROM {full_table_name} {where_clause} LIMIT 1'
result = await db.execute(text(check_sql), where_params)
if result.fetchone():
return await self._execute_update(table, context, target)
return await self._execute_insert(table, context, target)
async def _execute_select(
self,
table: str,
context: NodeContext,
target: DbTarget,
) -> Dict[str, Any]:
conditions = self._resolve_conditions(context)
return_fields = self.config.get('return_fields', ['*'])
limit = self.config.get('limit', 100)
order_by = self.config.get('order_by', '')
if target.is_external:
db_service = await self._create_db_service(context, target)
sql = format_select_sql(
table,
target,
return_fields=return_fields,
conditions=conditions,
order_by=order_by,
limit=int(limit),
)
result_data = await db_service.execute_sql(sql, is_query=True)
if result_data.get('success') is False:
raise ValueError(result_data.get('message') or '查询失败')
rows = result_data.get('rows') or result_data.get('data') or []
result_data_list = []
for row in rows:
if isinstance(row, dict):
result_data_list.append(serialize_row(row))
else:
result_data_list.append(serialize_row(dict(row)))
return {
'data': result_data_list,
'affected_rows': len(result_data_list),
}
db = context.db_session
if not db:
raise ValueError('数据库会话不可用')
normalized_fields = normalize_return_fields(return_fields)
if normalized_fields == '*':
field_list = '*'
else:
field_list = ', '.join([f'"{f}"' for f in normalized_fields])
where_clause, params = build_where_clause_platform(conditions)
full_table_name = self._build_full_table_name(table)
sql = f'SELECT {field_list} FROM {full_table_name} {where_clause}'
if order_by:
sql += f' ORDER BY {order_by}'
sql += f' LIMIT {int(limit)}'
result = await db.execute(text(sql), params)
columns = result.keys()
rows = result.fetchall()
result_data = [serialize_row(dict(zip(columns, row))) for row in rows]
return {
'data': result_data,
'affected_rows': len(result_data),
}
async def _execute_delete(
self,
table: str,
context: NodeContext,
target: DbTarget,
) -> Dict[str, Any]:
conditions = self._resolve_conditions(context)
if not conditions:
raise ValueError('DELETE 操作必须指定条件,防止误删全表')
if target.is_external:
db_service = await self._create_db_service(context, target)
schema_name = await resolve_handler_schema_name(db_service, target)
where_raw = build_where_clause_raw(conditions, db_service.db_type)
result = await db_service.delete_data(table, where_raw, schema_name)
if not result.get('success'):
raise ValueError(result.get('message', '删除失败'))
affected_rows = result.get('affected_rows', 0)
return {
'data': {'deleted': True, 'affected_rows': affected_rows},
'affected_rows': affected_rows,
}
db = context.db_session
if not db:
raise ValueError('数据库会话不可用')
where_clause, params = build_where_clause_platform(conditions)
full_table_name = self._build_full_table_name(table)
sql = f'DELETE FROM {full_table_name} {where_clause}'
result = await db.execute(text(sql), params)
affected_rows = result.rowcount
return {
'data': {'deleted': True, 'affected_rows': affected_rows},
'affected_rows': affected_rows,
}
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
return {
'type': 'object',
'properties': {
'operation': {
'type': 'string',
'title': '操作类型',
'enum': ['insert', 'update', 'upsert', 'select', 'delete'],
'enumNames': ['插入', '更新', '插入或更新', '查询', '删除'],
'default': 'insert',
},
'table': {
'type': 'string',
'title': '目标表',
'description': '数据库表名',
},
'field_mapping': {
'type': 'object',
'title': '字段映射',
'description': '数据库字段与变量的映射关系',
'additionalProperties': {'type': 'string'},
},
'where_conditions': {
'type': 'array',
'title': '条件',
'description': '查询/更新/删除的条件',
'items': {
'type': 'object',
'properties': {
'field': {'type': 'string', 'title': '字段'},
'operator': {
'type': 'string',
'title': '操作符',
'enum': ['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'is_null', 'is_not_null'],
'default': '=',
},
'value': {'type': 'string', 'title': ''},
},
},
},
'return_fields': {
'type': 'array',
'title': '返回字段',
'description': '查询时返回的字段列表',
'items': {'type': 'string'},
'default': ['*'],
},
'limit': {
'type': 'integer',
'title': '限制条数',
'description': 'SQL 查询时的最大返回条数',
'default': 100,
},
'frontend_max_rows': {
'type': 'integer',
'title': '前端返回最大条数',
'description': '前端 SSE 事件中返回的最大数据条数(默认100),超过此值仅截断前端传输,后续节点仍可获取全量数据',
'default': 100,
'minimum': 1,
'maximum': 10000,
},
'order_by': {
'type': 'string',
'title': '排序',
'description': '排序字段,如 created_at DESC',
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'db_result',
},
},
'required': ['operation', 'table'],
}
@NodeRegistry.register
class DbInsertNode(BaseDatabaseNode):
node_type = 'db_insert'
node_name = 'DB 插入'
node_icon = 'database-zap'
node_description = '向数据库插入数据'
def __init__(self, config: Dict[str, Any] = None):
super().__init__(config)
if config:
self.config['operation'] = 'upsert' if config.get('upsert') else 'insert'
@NodeRegistry.register
class DbUpdateNode(BaseDatabaseNode):
node_type = 'db_update'
node_name = 'DB 更新'
node_icon = 'database-backup'
node_description = '更新数据库记录'
def __init__(self, config: Dict[str, Any] = None):
super().__init__(config)
if config:
self.config['operation'] = 'update'
@NodeRegistry.register
class DbQueryNode(BaseDatabaseNode):
node_type = 'db_query'
node_name = 'DB 查询'
node_icon = 'search'
node_description = '从数据库查询数据'
def __init__(self, config: Dict[str, Any] = None):
super().__init__(config)
if config:
self.config['operation'] = 'select'
@NodeRegistry.register
class DbDeleteNode(BaseDatabaseNode):
node_type = 'db_delete'
node_name = 'DB 删除'
node_icon = 'trash-2'
node_description = '从数据库删除数据'
def __init__(self, config: Dict[str, Any] = None):
super().__init__(config)
if config:
self.config['operation'] = 'delete'
@NodeRegistry.register
class DbSqlNode(BaseNode):
"""自定义 SQL 执行节点"""
node_type = 'db_sql'
node_name = 'SQL 执行'
node_category = 'data'
node_icon = 'database'
node_description = '执行自定义 SQL 语句'
inputs = [
{
'name': 'data',
'type': 'object',
'description': '输入数据',
},
]
outputs = [
{
'name': 'result',
'type': 'any',
'description': 'SQL 执行结果',
},
]
def execute(self, context: NodeContext) -> NodeResult:
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
start_time = time.time()
sql_type = self.config.get('sql_type', 'query')
sql = self.config.get('sql', '')
target = resolve_db_target(self.config.get('db_config'))
output_variable = self.config.get('output_variable', 'sql_result')
is_query = sql_type == 'query'
if not sql:
return NodeResult(
success=False,
error='SQL 语句不能为空',
elapsed_time=int((time.time() - start_time) * 1000),
)
try:
resolved_sql = context.resolve_template(sql)
param_dict = build_sql_param_dict(self.config.get('params'), context)
logger.info(
'执行 SQL [%s]: %s, params=%s',
target.db_name,
resolved_sql,
list(param_dict.keys()),
)
operation = 'query' if is_query else 'execute'
warnings = default_connection_write_warnings(operation, target)
if not target.is_external:
db = context.db_session
if not db:
raise ValueError('数据库会话不可用')
result = await db.execute(text(resolved_sql), param_dict)
if is_query:
rows = result.mappings().all()
output_result = [serialize_row(dict(row)) for row in rows]
row_count = len(output_result)
else:
row_count = max(result.rowcount or 0, 0)
output_result = row_count
else:
from core.database_manager.service import AsyncDatabaseManagerService
from utils.sql_param_compile import compile_sql_with_named_params
try:
db_service = await AsyncDatabaseManagerService.create(
target.db_name,
context.db_session,
)
except Exception as e:
raise ValueError(f'无法连接数据库 {target.db_name}: {e}') from e
executable_sql = compile_sql_with_named_params(
resolved_sql,
param_dict,
target.db_type,
)
result_data = await db_service.execute_sql(executable_sql, is_query=is_query)
if result_data.get('success') is False:
raise Exception(result_data.get('message') or 'SQL 执行失败')
if is_query:
rows = result_data.get('rows') or result_data.get('data') or []
output_result = []
for row in rows:
if isinstance(row, dict):
output_result.append(serialize_row(row))
else:
output_result.append(serialize_row(dict(row)))
row_count = len(output_result)
else:
output_result = result_data.get('affected_rows', 0)
row_count = output_result
elapsed_time = int((time.time() - start_time) * 1000)
metadata = merge_result_metadata({}, warnings)
return NodeResult(
success=True,
output=output_result,
output_variables={
output_variable: output_result,
f'{output_variable}_count': row_count,
},
metadata=metadata,
elapsed_time=elapsed_time,
)
except Exception as e:
logger.error('SQL 执行失败: %s', e)
return NodeResult(
success=False,
error=f'SQL 执行失败: {str(e)}',
elapsed_time=int((time.time() - start_time) * 1000),
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
return {
'type': 'object',
'properties': {
'sql_type': {
'type': 'string',
'title': '执行类型',
'enum': ['query', 'execute'],
'default': 'query',
'description': 'query: 查询返回结果, execute: 执行不返回结果',
},
'sql': {
'type': 'string',
'title': 'SQL 语句',
'description': '要执行的 SQL 语句,使用 :param_name 作为命名参数占位符',
},
'params': {
'type': 'array',
'title': '参数列表',
'description': 'SQL 命名参数,与 SQL 中 :param_name 对应',
'items': {
'type': 'object',
'properties': {
'name': {'type': 'string', 'title': '参数名'},
'type': {
'type': 'string',
'enum': ['string', 'integer', 'float', 'boolean', 'date', 'datetime'],
'default': 'string',
},
'value': {'type': 'string', 'title': '参数值'},
},
},
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'sql_result',
},
},
'required': ['sql'],
}
@@ -1,672 +0,0 @@
"""
对话流节点
用于对话流模式的智能体,支持与用户的交互式对话
"""
import logging
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class QuestionNode(BaseNode):
"""
问答节点
向用户提出问题,等待用户输入回答
支持输入验证和默认值
"""
node_type = 'question'
node_name = '问答节点'
node_category = 'dialog'
inputs = {
'question': {
'type': 'string',
'description': '要向用户提出的问题',
'required': True,
},
'variable_name': {
'type': 'string',
'description': '存储用户回答的变量名',
'required': True,
},
'input_type': {
'type': 'string',
'description': '输入类型:text/number/email/phone/date',
'default': 'text',
},
'placeholder': {
'type': 'string',
'description': '输入框占位文本',
'default': '',
},
'default_value': {
'type': 'string',
'description': '默认值',
'default': '',
},
'required': {
'type': 'boolean',
'description': '是否必填',
'default': True,
},
'validation_regex': {
'type': 'string',
'description': '验证正则表达式',
'default': '',
},
'validation_message': {
'type': 'string',
'description': '验证失败提示',
'default': '输入格式不正确',
},
'render_input': {
'type': 'boolean',
'description': '是否渲染输入框,默认不渲染(用户直接在聊天框输入)',
'default': False,
},
}
outputs = {
'answer': {
'type': 'string',
'description': '用户的回答',
},
'is_valid': {
'type': 'boolean',
'description': '回答是否有效',
},
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行问答节点
这个节点会暂停工作流执行,等待用户输入
"""
question = self.config.get('question', '')
variable_name = self.config.get('variable_name', 'user_input')
input_type = self.config.get('input_type', 'text')
placeholder = self.config.get('placeholder', '')
default_value = self.config.get('default_value', '')
required = self.config.get('required', True)
validation_regex = self.config.get('validation_regex', '')
validation_message = self.config.get('validation_message', '输入格式不正确')
render_input = self.config.get('render_input', False)
# 解析模板变量
question = context.resolve_template(question)
placeholder = context.resolve_template(placeholder)
default_value = context.resolve_template(default_value)
# 检查是否已有用户输入(续流时)
user_input = context.variables.get('__user_input__')
if user_input is not None:
# 用户已输入,验证并继续
import re
# 将非字符串输入转换为字符串
if not isinstance(user_input, str):
user_input = str(user_input)
is_valid = True
# 必填验证
if required and not user_input.strip():
is_valid = False
# 正则验证
if is_valid and validation_regex:
if not re.match(validation_regex, user_input):
is_valid = False
# 类型验证
if is_valid and input_type == 'number':
try:
user_input = float(user_input)
except ValueError:
is_valid = False
elif is_valid and input_type == 'email':
email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_regex, user_input):
is_valid = False
elif is_valid and input_type == 'phone':
phone_regex = r'^1[3-9]\d{9}$'
if not re.match(phone_regex, user_input):
is_valid = False
if is_valid:
return NodeResult(
success=True,
output={
'answer': user_input,
'is_valid': True,
},
output_variables={
variable_name: user_input,
},
)
else:
# 验证失败,重新等待输入
return NodeResult(
success=True,
output={
'answer': '',
'is_valid': False,
},
waiting_for_input=True,
waiting_config={
'type': 'question',
'question': question,
'input_type': input_type,
'placeholder': placeholder,
'default_value': default_value,
'required': required,
'error_message': validation_message,
'render_input': render_input,
},
)
else:
# 首次执行,等待用户输入
return NodeResult(
success=True,
output={
'answer': '',
'is_valid': False,
},
waiting_for_input=True,
waiting_config={
'type': 'question',
'question': question,
'input_type': input_type,
'placeholder': placeholder,
'default_value': default_value,
'required': required,
'render_input': render_input,
},
)
@NodeRegistry.register
class ChoiceNode(BaseNode):
"""
选项节点
向用户展示多个选项,用户选择后继续执行
支持单选和多选
"""
node_type = 'choice'
node_name = '选项节点'
node_category = 'dialog'
inputs = {
'question': {
'type': 'string',
'description': '问题或提示文本',
'required': True,
},
'variable_name': {
'type': 'string',
'description': '存储用户选择的变量名',
'required': True,
},
'options': {
'type': 'array',
'description': '选项列表',
'required': True,
'items': {
'type': 'object',
'properties': {
'value': {'type': 'string', 'description': '选项值'},
'label': {'type': 'string', 'description': '显示文本'},
'description': {'type': 'string', 'description': '选项描述'},
},
},
},
'multiple': {
'type': 'boolean',
'description': '是否多选',
'default': False,
},
'min_select': {
'type': 'integer',
'description': '最少选择数量',
'default': 1,
},
'max_select': {
'type': 'integer',
'description': '最多选择数量',
'default': 1,
},
}
outputs = {
'selected': {
'type': 'any',
'description': '用户选择的值(单选为字符串,多选为数组)',
},
'selected_labels': {
'type': 'any',
'description': '用户选择的显示文本',
},
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行选项节点
"""
question = self.config.get('question', '')
variable_name = self.config.get('variable_name', 'user_choice')
options = self.config.get('options', [])
multiple = self.config.get('multiple', False)
min_select = self.config.get('min_select', 1)
max_select = self.config.get('max_select', 1)
# 解析模板变量
question = context.resolve_template(question)
# 检查是否已有用户选择
user_selection = context.variables.get('__user_input__')
if user_selection is not None:
# 用户已选择
if multiple:
# 多选:user_selection 应该是数组
if isinstance(user_selection, str):
user_selection = [user_selection]
elif not isinstance(user_selection, list):
# 如果不是字符串也不是列表(比如布尔值),转换为字符串后放入列表
user_selection = [str(user_selection)]
# 验证所有选项值是否有效
valid_values = [opt.get('value') for opt in options]
invalid_selections = [val for val in user_selection if val not in valid_values]
if invalid_selections:
# 有无效选项,重新显示选择界面
return NodeResult(
success=True,
output={'selected': [], 'selected_labels': []},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
'error_message': '请从选项中选择',
},
)
# 验证选择数量
if len(user_selection) < min_select or len(user_selection) > max_select:
return NodeResult(
success=True,
output={'selected': [], 'selected_labels': []},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
'error_message': f'请选择 {min_select}-{max_select} 个选项',
},
)
# 获取选中的标签
selected_labels = []
for opt in options:
if opt.get('value') in user_selection:
selected_labels.append(opt.get('label', opt.get('value')))
return NodeResult(
success=True,
output={
'selected': user_selection,
'selected_labels': selected_labels,
},
output_variables={
variable_name: user_selection,
f'{variable_name}_labels': selected_labels,
},
)
else:
# 单选:验证选项值是否有效
valid_values = [opt.get('value') for opt in options]
if user_selection not in valid_values:
# 无效选项,重新显示选择界面
return NodeResult(
success=True,
output={'selected': None, 'selected_labels': None},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
'error_message': '请从选项中选择',
},
)
selected_label = ''
for opt in options:
if opt.get('value') == user_selection:
selected_label = opt.get('label', opt.get('value'))
break
return NodeResult(
success=True,
output={
'selected': user_selection,
'selected_labels': selected_label,
},
output_variables={
variable_name: user_selection,
f'{variable_name}_label': selected_label,
},
)
else:
# 首次执行,等待用户选择
return NodeResult(
success=True,
output={
'selected': None,
'selected_labels': None,
},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
},
)
@NodeRegistry.register
class MessageNode(BaseNode):
"""
消息节点
向用户发送消息,不等待回复
"""
node_type = 'message'
node_name = '消息节点'
node_category = 'dialog'
inputs = {
'content': {
'type': 'string',
'description': '消息内容',
'required': True,
},
'message_type': {
'type': 'string',
'description': '消息类型:text/markdown/html',
'default': 'text',
},
}
outputs = {
'sent': {
'type': 'boolean',
'description': '是否发送成功',
},
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行消息节点
"""
import logging
logger = logging.getLogger(__name__)
content = self.config.get('content', '')
message_type = self.config.get('message_type', 'text')
logger.info(f'[MessageNode] Original content: {content}')
logger.info(f'[MessageNode] Context variables: item={context.get_variable("item")}, index={context.get_variable("index")}')
# 解析模板变量
content = context.resolve_template(content)
logger.info(f'[MessageNode] Resolved content: {content}')
return NodeResult(
success=True,
output={
'sent': True,
'content': content, # 也在 output 中返回消息内容
},
# 发送消息事件
events=[{
'type': 'message',
'content': content,
'message_type': message_type,
}],
)
@NodeRegistry.register
class ConfirmNode(BaseNode):
"""
确认节点
向用户展示确认对话框,等待用户确认或取消
"""
node_type = 'confirm'
node_name = '确认节点'
node_category = 'dialog'
# 扩展的确认关键词列表(覆盖常见表达)
CONFIRM_KEYWORDS = {
# 英文
'true', 'yes', 'ok', 'okay', 'sure', 'confirm', 'confirmed', 'agree', 'accept', 'y',
# 中文
'', '是的', '', '对的', '', '好的', '', '行的', '可以', '没问题', '没有问题',
'同意', '确认', '确定', '', '嗯嗯', '好吧', '', '', '', '', '', '要的',
'继续', '执行', '进行', '开始', '去吧', '做吧', '干吧',
# 数字
'1',
}
# 扩展的取消关键词列表
CANCEL_KEYWORDS = {
# 英文
'false', 'no', 'cancel', 'reject', 'decline', 'deny', 'n', 'nope',
# 中文
'', '', '不是', '不行', '不可以', '不要', '不用', '取消', '拒绝', '算了',
'停止', '终止', '放弃', '', '别了', '不了', '不用了', '不需要',
# 数字
'0',
}
inputs = {
'title': {
'type': 'string',
'description': '确认框标题',
'default': '确认',
},
'content': {
'type': 'string',
'description': '确认内容',
'required': True,
},
'confirm_text': {
'type': 'string',
'description': '确认按钮文本',
'default': '确认',
},
'cancel_text': {
'type': 'string',
'description': '取消按钮文本',
'default': '取消',
},
'variable_name': {
'type': 'string',
'description': '存储结果的变量名',
'default': 'confirmed',
},
'use_llm_intent': {
'type': 'boolean',
'description': '使用 LLM 进行意图识别(当关键词匹配失败时)',
'default': False,
},
'llm_model_id': {
'type': 'string',
'description': '用于意图识别的 LLM 模型 ID',
'default': '',
},
}
outputs = {
'confirmed': {
'type': 'boolean',
'description': '用户是否确认',
},
}
def _match_keywords(self, user_input) -> tuple[bool, bool]:
"""
使用关键词匹配判断用户意图
Returns:
(matched, confirmed): matched 表示是否匹配到关键词,confirmed 表示是否确认
"""
# 布尔值特殊处理(必须在字符串处理之前)
if user_input is True or user_input == True:
return True, True
if user_input is False or user_input == False:
return True, False
# 确保是字符串
if not isinstance(user_input, str):
user_input = str(user_input)
# 标准化输入:去除空格、转小写
normalized = user_input.strip().lower()
# 精确匹配
if normalized in self.CONFIRM_KEYWORDS:
return True, True
if normalized in self.CANCEL_KEYWORDS:
return True, False
return False, False
def _llm_intent_recognition(self, user_input: str, context_content: str, model_id: str) -> bool:
"""
使用 LLM 进行意图识别
Returns:
confirmed: 用户是否确认
"""
try:
from ai_platform.services.llm_service import LLMService
system_prompt = """你是一个意图识别助手。用户正在回应一个确认请求,你需要判断用户的回复是"确认"还是"取消"
规则:
1. 如果用户表达同意、肯定、愿意继续的意思,返回 "confirm"
2. 如果用户表达拒绝、否定、不愿意继续的意思,返回 "cancel"
3. 如果无法判断,默认返回 "cancel"
只返回 "confirm""cancel",不要返回其他内容。"""
user_prompt = f"""确认请求内容:{context_content}
用户回复:{user_input}
请判断用户意图:"""
llm_service = LLMService()
response = llm_service.chat(
model_id=model_id,
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
temperature=0,
max_tokens=10,
)
result = response.content.strip().lower()
return result == 'confirm'
except Exception as e:
logger.warning(f'LLM 意图识别失败,回退到默认行为: {e}')
return False
def execute(self, context: NodeContext) -> NodeResult:
"""
执行确认节点
"""
title = self.config.get('title', '确认')
content = self.config.get('content', '')
confirm_text = self.config.get('confirm_text', '确认')
cancel_text = self.config.get('cancel_text', '取消')
variable_name = self.config.get('variable_name', 'confirmed')
use_llm_intent = self.config.get('use_llm_intent', False)
llm_model_id = self.config.get('llm_model_id', '')
# 解析模板变量
title = context.resolve_template(title)
content = context.resolve_template(content)
# 检查是否已有用户选择
user_input = context.variables.get('__user_input__')
if user_input is not None:
# 首先尝试关键词匹配
matched, confirmed = self._match_keywords(user_input)
if not matched and use_llm_intent and llm_model_id:
# 关键词未匹配,使用 LLM 意图识别
logger.info(f'关键词未匹配,使用 LLM 意图识别: {user_input}')
confirmed = self._llm_intent_recognition(str(user_input), content, llm_model_id)
elif not matched:
# 关键词未匹配且未启用 LLM,默认为取消
logger.info(f'关键词未匹配,默认取消: {user_input}')
confirmed = False
return NodeResult(
success=True,
output={
'confirmed': confirmed,
},
output_variables={
variable_name: confirmed,
},
)
else:
# 等待用户确认
return NodeResult(
success=True,
output={
'confirmed': False,
},
waiting_for_input=True,
waiting_config={
'type': 'confirm',
'title': title,
'content': content,
'confirm_text': confirm_text,
'cancel_text': cancel_text,
},
)
@@ -1,98 +0,0 @@
"""
结束节点
"""
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
@NodeRegistry.register
class EndNode(BaseNode):
"""
结束节点
工作流的出口节点,输出最终结果
"""
node_type = 'end'
node_name = '结束'
node_category = 'basic'
node_icon = 'stop-circle'
node_description = '工作流的结束节点,输出最终结果'
inputs = [
{
'name': 'output',
'type': 'any',
'description': '输出内容',
},
]
outputs = []
def execute(self, context: NodeContext) -> NodeResult:
"""执行结束节点"""
# 获取输出内容配置(前端保存的是字符串模板,如 "{{llm_response}}"
output_template = self.config.get('output', '')
if output_template and isinstance(output_template, str) and output_template.strip():
# 如果配置了输出模板,解析模板中的变量
output = context.resolve_template(output_template)
else:
# 没有配置输出模板,不输出任何内容
output = None
# 处理输出变量(用于结构化输出)
# 只有当 output 不为 None 时才处理结构化输出
outputs_config = self.config.get('outputs', [])
if output is not None and outputs_config and isinstance(outputs_config, list):
# 如果定义了输出变量,构建结构化输出
structured_output = {}
for out_var in outputs_config:
if isinstance(out_var, dict):
var_name = out_var.get('variable', '')
if var_name:
# 从上下文获取变量值
structured_output[var_name] = context.get_variable(var_name, None)
# 如果有结构化输出,合并到结果中
if structured_output:
if isinstance(output, dict):
output = {**output, **structured_output}
else:
output = {
'result': output,
**structured_output
}
return NodeResult(
success=True,
output=output,
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'output': {
'type': 'object',
'title': '输出配置',
'properties': {
'type': {
'type': 'string',
'title': '输出类型',
'enum': ['variable', 'template', 'previous'],
'default': 'previous',
},
'value': {
'type': 'string',
'title': '输出值',
'description': '变量名或模板',
},
},
},
},
}
@@ -1,333 +0,0 @@
"""
表单基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
import logging
import re
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormBasicInfoNode(BaseNode):
"""
表单基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
node_type = 'form_basic_info'
node_name = '表单基础信息'
node_category = 'form'
node_icon = 'file-text'
node_description = '处理表单基础信息,验证和补充表单名称、编码等'
inputs = [
{
'name': 'name',
'type': 'string',
'description': '表单名称',
'required': True,
},
{
'name': 'code',
'type': 'string',
'description': '表单编码',
'required': False,
},
{
'name': 'form_type',
'type': 'string',
'description': '表单类型(normal/workflow',
'required': False,
},
{
'name': 'description',
'type': 'string',
'description': '表单描述',
'required': False,
},
{
'name': 'sort',
'type': 'number',
'description': '排序',
'required': False,
},
]
outputs = [
{
'name': 'form_basic_info',
'type': 'object',
'description': '处理后的表单基础信息',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'FormBasicInfoNode - 使用用户编辑的数据: {edited_data}')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
name = context.resolve_template(self.config.get('name', ''))
code = context.resolve_template(self.config.get('code', ''))
form_type = context.resolve_template(self.config.get('form_type', 'normal'))
description = context.resolve_template(self.config.get('description', ''))
# 处理sort(可能是字符串或数字)
sort_value = self.config.get('sort', 0)
# 先转换为字符串,再调用resolve_template
sort_str = context.resolve_template(str(sort_value))
try:
sort = int(sort_str) if sort_str else 0
except (ValueError, TypeError):
sort = 0
logger.info(f'FormBasicInfoNode - 输入参数: name={name}, code={code}, form_type={form_type}')
# 验证必填字段
if not name or not name.strip():
return NodeResult(
success=False,
error='表单名称不能为空',
)
name = name.strip()
# 自动生成编码(如果未提供或配置了自动生成)
auto_generate = self.config.get('auto_generate_code', False)
if not code or not code.strip() or auto_generate:
code = self._generate_code(name)
logger.info(f'自动生成编码: {code}')
else:
code = code.strip()
# 验证编码格式
if not re.match(r'^[a-z][a-z0-9_]*$', code):
return NodeResult(
success=False,
error=f'表单编码格式不正确:{code},必须以字母开头,只能包含小写字母、数字和下划线',
)
# 验证表单类型
if form_type not in ['normal', 'workflow']:
logger.warning(f'表单类型 {form_type} 不合法,使用默认值 normal')
form_type = 'normal'
# 构建输出
basic_info = {
'name': name,
'code': code,
'form_type': form_type,
'description': description.strip() if description else name,
'sort': sort,
}
logger.info(f'FormBasicInfoNode - 输出: {basic_info}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(basic_info, require_confirmation)
except Exception as e:
logger.exception(f'表单基础信息节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
import json
if isinstance(user_input, dict):
# 检查是否包含表单基础信息字段
if 'name' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and 'name' in data:
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result(self, basic_info: Dict[str, Any], require_confirmation: bool = False) -> NodeResult:
"""构建节点结果"""
name = basic_info.get('name', '')
code = basic_info.get('code', '')
form_type = basic_info.get('form_type', 'normal')
description = basic_info.get('description', name)
sort = basic_info.get('sort', 0)
# 构建预览数据
preview_data = {
'type': 'form_basic_info',
'title': '表单基础信息',
'data': basic_info,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=basic_info,
output_variables={
'form_basic_info': basic_info,
'form_name': name,
'form_code': code,
'form_type': form_type,
'form_description': description,
'form_sort': sort,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'form_basic_info',
'title': '表单基础信息',
'message': '请确认或编辑表单基础信息',
'data': basic_info,
} if require_confirmation else {},
)
def _generate_code(self, name: str) -> str:
"""
根据表单名称自动生成编码
Args:
name: 表单名称
Returns:
生成的编码
"""
try:
from pypinyin import lazy_pinyin
# 将中文转拼音
pinyin_list = lazy_pinyin(name)
code = '_'.join(pinyin_list)
except ImportError:
logger.warning('pypinyin 未安装,使用简单的编码生成策略')
# 如果没有安装 pypinyin,使用简单策略
code = name.lower()
# 只保留字母、数字和下划线
code = re.sub(r'[^a-z0-9_]', '', code.lower())
# 确保以字母开头
if code and not code[0].isalpha():
code = 'form_' + code
# 如果为空,使用默认值
if not code:
code = 'form_default'
return code
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {
'type': 'object',
'properties': {
'name': {
'type': 'string',
'title': '表单名称',
'description': '表单的显示名称,支持变量引用如 {{llm_basic_name}}',
'required': True,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入表单名称或使用变量 {{llm_basic_name}}',
},
},
'code': {
'type': 'string',
'title': '表单编码',
'description': '表单的唯一编码,支持变量引用如 {{llm_basic_code}}',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '留空则自动生成,或使用变量 {{llm_basic_code}}',
},
},
'form_type': {
'type': 'string',
'title': '表单类型',
'description': '表单类型:normal(普通表单)或 workflow(工作流表单)',
'default': 'normal',
'enum': ['normal', 'workflow'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择表单类型',
'options': [
{'label': '普通表单', 'value': 'normal'},
{'label': '工作流表单', 'value': 'workflow'},
],
},
},
'description': {
'type': 'string',
'title': '表单描述',
'description': '表单的详细描述,支持变量引用',
'required': False,
'x-component': 'Textarea',
'x-component-props': {
'placeholder': '请输入表单描述',
'rows': 3,
},
},
'sort': {
'type': 'number',
'title': '排序',
'description': '表单的排序值,数字越小越靠前',
'default': 0,
'x-component': 'InputNumber',
'x-component-props': {
'placeholder': '请输入排序值',
'min': 0,
},
},
'auto_generate_code': {
'type': 'boolean',
'title': '自动生成编码',
'description': '是否自动根据表单名称生成编码(拼音转换)',
'default': False,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['name'],
}
@@ -1,499 +0,0 @@
"""
表单创建节点
将表单配置保存到数据库,创建完整的表单元数据
"""
import ast
import json
import logging
from typing import Any, Dict, List, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormCreateNode(BaseNode):
"""
表单创建节点
将表单基础信息、表单UI设计、列表设计等配置保存到数据库
"""
node_type = 'form_create'
node_name = '表单创建'
node_category = 'form'
node_icon = 'file-plus'
node_description = '将表单配置保存到数据库,创建完整的表单元数据'
inputs = [
{
'name': 'form_basic_info',
'type': 'object',
'description': '表单基础信息(来自表单基础信息节点)',
'required': True,
},
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置(来自数据库设计节点)',
'required': True,
},
{
'name': 'form_ui_design',
'type': 'object',
'description': '表单UI设计配置(来自表单UI设计节点)',
'required': True,
},
{
'name': 'list_config',
'type': 'object',
'description': '列表配置(来自列表UI设计节点)',
'required': True,
},
{
'name': 'application_id',
'type': 'string',
'description': '所属应用ID(可选)',
'required': False,
},
]
outputs = [
{
'name': 'form_id',
'type': 'string',
'description': '创建的表单ID',
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码',
},
{
'name': 'form_meta',
'type': 'object',
'description': '完整的表单元数据',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数(原始配置值)
form_basic_info_raw = self.config.get('form_basic_info', '{}')
database_design_raw = self.config.get('database_design', '{}')
form_ui_design_raw = self.config.get('form_ui_design', '{}')
list_config_raw = self.config.get('list_config', '{}')
# 解析变量引用,保持原始类型
form_basic_info = self._resolve_variable_value(context, form_basic_info_raw)
database_design = self._resolve_variable_value(context, database_design_raw)
form_ui_design = self._resolve_variable_value(context, form_ui_design_raw)
list_config = self._resolve_variable_value(context, list_config_raw)
application_id = context.resolve_template(self.config.get('application_id', ''))
logger.info(f'FormCreateNode - 解析结果: form_basic_info={bool(form_basic_info)}, database_design={bool(database_design)}, form_ui_design={bool(form_ui_design)}, list_config={bool(list_config)}')
# 配置选项
update_if_exists = self.config.get('update_if_exists', True)
logger.info(f'FormCreateNode - 开始创建表单')
# 验证必要配置
if not form_basic_info:
return NodeResult(
success=False,
error='表单基础信息配置不能为空',
)
if not database_design:
return NodeResult(
success=False,
error='数据库设计配置不能为空',
)
if not form_ui_design:
return NodeResult(
success=False,
error='表单UI设计配置不能为空',
)
if not list_config:
return NodeResult(
success=False,
error='列表配置不能为空',
)
# 提取基础信息
form_name = form_basic_info.get('name', '')
form_code_base = form_basic_info.get('code', '')
form_type = form_basic_info.get('form_type', 'normal')
description = form_basic_info.get('description', '')
sort = form_basic_info.get('sort', 0)
if not form_name or not form_code_base:
return NodeResult(
success=False,
error='表单名称和编码不能为空',
)
# 提取数据库配置
table_info = database_design.get('table', {})
db_config = database_design.get('dbConfig', 'default')
meta = table_info.get('meta', {})
main_table = table_info.get('tableName', '')
# 优先使用原始 schema 配置(可能包含变量),如果不存在则使用解析后的值
main_table_schema = meta.get('schemaRaw', meta.get('schema', ''))
main_table_database = meta.get('database', '')
if not main_table:
return NodeResult(
success=False,
error='主表名不能为空',
)
# 构建表配置(用于 form_config.tableConfigs
table_configs = self._build_table_configs(database_design)
# 构建 form_config
form_config = {
'items': form_ui_design.get('items', []),
'labelWidth': form_ui_design.get('labelWidth', 100),
'labelPosition': form_ui_design.get('labelPosition', 'right'),
'size': form_ui_design.get('size', 'default'),
'tableConfigs': table_configs,
}
# 调用 FormService 创建表单
from app.database import AsyncSessionLocal
from online_dev.form_manager.service import FormService, FormServiceException
async with AsyncSessionLocal() as db:
try:
# 生成唯一的 code(如果已存在则自动添加后缀)
form_code = await self._get_unique_code(db, form_code_base, FormService)
logger.info(f'FormCreateNode - 使用 code: {form_code} (原始: {form_code_base})')
# 构建表单数据
form_data = {
'application_id': application_id if application_id else None,
'name': form_name,
'code': form_code,
'form_type': form_type,
'description': description,
'sort': sort,
'db_config': db_config,
'main_table': main_table,
'main_table_schema': main_table_schema,
'main_table_database': main_table_database,
'form_config': form_config,
'list_config': list_config,
'sub_tables': [], # 暂不支持子表
}
logger.info(f'FormCreateNode - 表单数据: name={form_name}, code={form_code}, main_table={main_table}')
# 由于 _get_unique_code 已确保 code 唯一,直接创建表单
# 如果仍然出现重复(极端并发情况),捕获异常并处理
try:
form = await FormService.create(db, form_data)
logger.info(f'表单创建成功: {form_code}')
except FormServiceException as e:
# 如果是编码重复错误且允许更新,尝试更新
if '已存在' in str(e) and update_if_exists:
logger.warning(f'表单编码冲突,尝试更新: {form_code}')
try:
existing_form = await FormService.get_by_code(db, form_code)
form = await FormService.update(db, existing_form.id, form_data)
logger.info(f'表单更新成功: {form_code}')
except Exception as update_error:
return NodeResult(
success=False,
error=f'表单创建和更新均失败: {str(update_error)}',
)
else:
return NodeResult(
success=False,
error=f'表单创建失败: {str(e)}',
)
# 构建输出
form_meta = {
'id': form.id,
'name': form.name,
'code': form.code,
'form_type': form.form_type,
'description': form.description,
'status': form.status,
'version': form.version,
'db_config': form.db_config,
'main_table': form.main_table,
'main_table_schema': form.main_table_schema,
'main_table_database': form.main_table_database,
}
logger.info(f'FormCreateNode - 表单创建成功: id={form.id}, code={form.code}')
return NodeResult(
success=True,
output=form_meta,
output_variables={
'form_id': form.id,
'form_code': form.code,
'form_meta': form_meta,
},
)
except FormServiceException as e:
logger.error(f'表单服务异常: {e}')
return NodeResult(
success=False,
error=f'表单创建失败: {str(e)}',
)
except Exception as e:
logger.exception(f'表单创建异常: {e}')
return NodeResult(
success=False,
error=f'表单创建异常: {str(e)}',
)
except Exception as e:
logger.exception(f'表单创建节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
def _resolve_variable_value(self, context: NodeContext, raw_value: Any) -> Optional[Dict]:
"""
解析变量引用,保持原始类型
支持格式:
- {{node_id.variable_name}} - 节点变量引用
- 直接的字典值
- JSON 字符串
"""
import re
# 如果已经是字典,直接返回
if isinstance(raw_value, dict):
return raw_value if raw_value else None
# 如果不是字符串,尝试解析
if not isinstance(raw_value, str):
return None
raw_value = raw_value.strip()
if not raw_value or raw_value == '{}':
return None
# 检查是否是单个变量引用 {{node_id.variable_name}}
single_var_pattern = r'^\{\{([^}]+)\}\}$'
match = re.match(single_var_pattern, raw_value)
if match:
var_ref = match.group(1).strip()
# 解析 node_id.variable_name 格式
if '.' in var_ref:
parts = var_ref.split('.', 1)
node_id = parts[0]
key = parts[1]
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict) and key in node_outputs:
value = node_outputs[key]
if isinstance(value, dict):
return value if value else None
return self._parse_config(value)
# 变量未找到
logger.warning(f'变量引用未找到: {var_ref}')
else:
# 直接变量引用
if var_ref in context.variables:
value = context.variables[var_ref]
if isinstance(value, dict):
return value if value else None
return self._parse_config(value)
logger.warning(f'变量引用未找到: {var_ref}')
# 不是变量引用,尝试解析为 JSON 或 Python dict
return self._parse_config(raw_value)
def _parse_config(self, config_str: Any) -> Optional[Dict]:
"""解析配置"""
# 如果已经是字典,直接返回
if isinstance(config_str, dict):
return config_str if config_str else None
# 如果是列表,返回 None(不是有效的配置)
if isinstance(config_str, list):
return None
# 如果是字符串,尝试解析
if isinstance(config_str, str):
config_str = config_str.strip()
if not config_str or config_str == '{}' or config_str == 'None':
return None
# 检查是否是未解析的模板变量(仍然包含 {{ }})
if '{{' in config_str and '}}' in config_str:
logger.warning(f'配置值仍包含未解析的模板变量: {config_str[:100]}')
return None
# 尝试 JSON 解析
try:
result = json.loads(config_str)
if isinstance(result, dict):
return result if result else None
return None
except json.JSONDecodeError:
pass
# 尝试 Python literal_eval(处理单引号格式)
try:
result = ast.literal_eval(config_str)
if isinstance(result, dict):
return result if result else None
return None
except (ValueError, SyntaxError) as e:
logger.warning(f'配置解析失败: {e}, 值: {config_str[:200]}')
return None
return None
async def _get_unique_code(self, db, base_code: str, service) -> str:
"""
生成唯一的 code,如果已存在则自动添加数字后缀
Args:
db: 数据库会话
base_code: 基础 code
service: 服务类(需要有 get_by_code 方法)
Returns:
唯一的 code
"""
from sqlalchemy import select
from online_dev.form_manager.model import FormMeta
code = base_code
counter = 1
while True:
# 直接查询数据库,避免 get_by_code 的异常处理问题
stmt = select(FormMeta).where(
FormMeta.code == code,
FormMeta.is_deleted == False
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if not existing:
# code 不存在,可以使用
break
# code 已存在,添加后缀
code = f"{base_code}_{counter}"
counter += 1
# 防止无限循环
if counter > 100:
logger.warning(f'生成唯一 code 失败,已尝试 {counter}')
break
return code
def _build_table_configs(self, database_design: Dict) -> List[Dict]:
"""构建表配置列表"""
table_configs = []
table_info = database_design.get('table', {})
if not table_info:
return table_configs
meta = table_info.get('meta', {}).copy() if table_info.get('meta') else {}
# 确保 meta 中包含 dbName(从 database_design.dbConfig 获取,如果 meta 中没有的话)
if not meta.get('dbName') and database_design.get('dbConfig'):
meta['dbName'] = database_design.get('dbConfig')
# 主表配置
main_config = {
'type': 'main',
'tableName': table_info.get('tableName', ''),
'alias': table_info.get('alias', table_info.get('tableName', '')),
'fields': table_info.get('fields', []),
'meta': meta,
}
table_configs.append(main_config)
return table_configs
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'form_basic_info': {
'type': 'string',
'title': '表单基础信息',
'description': '来自表单基础信息节点的输出',
'x-component': 'SmartInput',
},
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '来自数据库设计节点的输出',
'x-component': 'SmartInput',
},
'form_ui_design': {
'type': 'string',
'title': '表单UI设计配置',
'description': '来自表单UI设计节点的输出',
'x-component': 'SmartInput',
},
'list_config': {
'type': 'string',
'title': '列表配置',
'description': '来自列表UI设计节点的输出',
'x-component': 'SmartInput',
},
'application_id': {
'type': 'string',
'title': '所属应用ID',
'description': '表单所属的应用ID(子应用模式下自动获取)',
'default': '{{application_id}}',
'x-component': 'SmartInput',
},
'update_if_exists': {
'type': 'boolean',
'title': '存在时更新',
'description': '表单编码已存在时是否更新',
'default': True,
'x-component': 'Switch',
},
},
'required': ['form_basic_info', 'database_design', 'form_ui_design', 'list_config'],
}
File diff suppressed because it is too large Load Diff
@@ -1,349 +0,0 @@
"""
数据库表创建节点
根据数据库设计配置实际创建Schema和表
"""
import logging
from typing import Any, Dict, List
from core.database_connection.resolver import ConnectionResolver
from core.database_manager.ddl_builder import (
build_create_schema_sql,
build_create_table_ddl,
normalize_db_type,
)
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_object_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormDatabaseCreateNode(BaseNode):
"""
数据库表创建节点
根据数据库设计配置实际创建Schema和表
"""
node_type = 'form_database_create'
node_name = '数据库表创建'
node_category = 'form'
node_icon = 'database-zap'
node_description = '根据设计配置实际创建数据库Schema和表'
inputs = [
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置',
'required': True,
},
{
'name': 'if_exists',
'type': 'string',
'description': '表已存在时的处理方式',
'required': False,
},
{
'name': 'create_schema_if_not_exists',
'type': 'boolean',
'description': '自动创建Schema',
'required': False,
},
]
outputs = [
{
'name': 'creation_result',
'type': 'object',
'description': '创建结果',
},
{
'name': 'all_tables_ready',
'type': 'boolean',
'description': '所有表是否就绪',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
return loop.run_until_complete(self.execute_async(context))
async def _resolve_db_type(self, db_name: str, meta_db_type: str, context: NodeContext) -> str:
configured = normalize_db_type(meta_db_type or 'postgresql')
try:
conn_info = await ConnectionResolver.resolve(db_name, context.db_session)
resolved = normalize_db_type(conn_info.db_type)
if configured != resolved:
logger.warning(
'FormDatabaseCreateNode - db_type 不一致: meta=%s, 连接 %s 解析为 %s,以连接为准',
configured,
db_name,
resolved,
)
return resolved
except Exception as exc:
logger.warning(
'FormDatabaseCreateNode - 无法解析连接 %s 的 db_type: %s,使用 meta.dbType=%s',
db_name,
exc,
configured,
)
return configured
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
database_design = resolve_object_config(
context, self.config.get('database_design', '')
)
if_exists = context.resolve_template(self.config.get('if_exists', 'skip'))
create_schema = self.config.get('create_schema_if_not_exists', True)
node_schema_raw = self.config.get('db_schema', '')
node_schema = context.resolve_template(node_schema_raw)
node_database_raw = self.config.get('db_database', '')
node_database = context.resolve_template(node_database_raw)
logger.info(
'FormDatabaseCreateNode - database: raw=%s resolved=%s schema: raw=%s resolved=%s',
node_database_raw,
node_database,
node_schema_raw,
node_schema,
)
if database_design is None:
return NodeResult(
success=False,
error='数据库设计配置不能为空或解析失败',
)
design_type = database_design.get('type', '')
db_config = database_design.get('dbConfig', 'default')
if design_type in ['main', 'sub']:
table_info = database_design.get('table', {})
if not table_info:
return NodeResult(
success=False,
error='数据库设计配置中缺少 table 信息',
)
all_tables = [table_info]
main_table = table_info
elif database_design.get('mainTable'):
main_table = database_design.get('mainTable', {})
sub_tables = database_design.get('subTables', [])
all_tables = [main_table] + sub_tables
else:
return NodeResult(
success=False,
error='数据库设计配置格式无效,需要包含 type+table 或 mainTable',
)
meta = main_table.get('meta', {})
db_name = meta.get('dbName', db_config)
database = node_database if node_database else meta.get('database', '')
meta_schema = meta.get('schema', '')
schema = node_schema if node_schema else meta_schema
db_type = await self._resolve_db_type(
db_name,
meta.get('dbType', 'postgresql'),
context,
)
logger.info(
'FormDatabaseCreateNode - dbName=%s dbType=%s database=%s schema=%s',
db_name,
db_type,
database,
schema,
)
created_tables: List[str] = []
skipped_tables: List[str] = []
errors: List[str] = []
sql_statements: List[str] = []
comment_statements: List[str] = []
if create_schema and schema and db_type in ('postgresql', 'sqlserver'):
schema_sql = build_create_schema_sql(schema, db_type)
if schema_sql:
sql_statements.append(schema_sql)
for table in all_tables:
table_name = table.get('tableName', '')
if not table_name:
errors.append('表配置缺少 tableName')
continue
try:
ddl_result = build_create_table_ddl(
table,
db_type=db_type,
if_exists=if_exists,
effective_schema=schema,
)
if ddl_result.skipped:
skipped_tables.append(table_name)
continue
if ddl_result.create_sql:
sql_statements.append(ddl_result.create_sql)
created_tables.append(table_name)
comment_statements.extend(ddl_result.comment_sqls)
except Exception as exc:
error_msg = f'生成表 {table_name} 的SQL失败: {str(exc)}'
errors.append(error_msg)
logger.error(error_msg)
if sql_statements:
try:
from core.database_manager.service import AsyncDatabaseManagerService
full_sql = '\n\n'.join(sql_statements)
logger.info('FormDatabaseCreateNode - 执行 DDL,共 %d', len(sql_statements))
db_service = await AsyncDatabaseManagerService.create(db_name)
result = await db_service.execute_ddl(full_sql, database, schema)
if not result.get('success', False):
error_msg = result.get('message', '执行DDL失败')
errors.append(
f'[{db_name}/{db_type}] {error_msg}; SQL开头: {full_sql[:200]}...'
)
logger.error('DDL执行失败: %s', error_msg)
elif comment_statements and if_exists != 'skip':
try:
comment_sql = '\n'.join(comment_statements)
comment_result = await db_service.execute_ddl(
comment_sql, database, schema,
)
if not comment_result.get('success', False):
logger.warning(
'字段注释执行失败: %s',
comment_result.get('message', '未知错误'),
)
except Exception as comment_error:
logger.warning('执行字段注释时发生异常: %s', comment_error)
except Exception as exc:
error_msg = f'执行DDL时发生异常: {str(exc)}'
errors.append(error_msg)
logger.exception(error_msg)
all_tables_ready = len(errors) == 0 and (len(created_tables) > 0 or len(skipped_tables) > 0)
creation_result = {
'success': all_tables_ready,
'created_tables': created_tables,
'skipped_tables': skipped_tables,
'errors': errors,
'sql_statements': sql_statements,
'total_tables': len(all_tables),
'db_name': db_name,
'db_type': db_type,
}
first_table_name = created_tables[0] if created_tables else (
skipped_tables[0] if skipped_tables else ''
)
output_vars = {
'creation_result': creation_result,
'all_tables_ready': all_tables_ready,
'created_count': len(created_tables),
'error_count': len(errors),
'schema_name': schema,
'table_name': first_table_name,
'db_name': db_name,
'db_type': db_type,
}
if errors:
return NodeResult(
success=False,
error=f'创建表时发生错误: {"; ".join(errors)}',
output=creation_result,
output_variables=output_vars,
)
return NodeResult(
success=True,
output=creation_result,
output_variables=output_vars,
)
except Exception as exc:
logger.exception('数据库表创建节点执行失败: %s', exc)
return NodeResult(
success=False,
error=f'节点执行失败: {str(exc)}',
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'db_schema': {
'type': 'string',
'title': '数据库 Schema(可选)',
'description': 'PostgreSQL/SQL Server schema 名称(如 public、app),支持变量如 {{schema_name}}。如果配置则优先使用此值,否则使用设计节点的 schema',
'default': '',
'x-component': 'SmartInput',
},
'db_database': {
'type': 'string',
'title': '目标数据库(可选)',
'description': 'PostgreSQL/MySQL/SQL Server 目标库名,支持变量。如果配置则优先使用此值,否则使用设计节点的 database',
'default': '',
'x-component': 'SmartInput',
},
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '从上一节点获取的数据库设计配置,支持变量引用如 {{database_design}}',
'required': True,
'x-component': 'Textarea',
'x-component-props': {
'placeholder': '请使用变量 {{database_design}}',
'rows': 4,
},
},
'if_exists': {
'type': 'string',
'title': '表已存在时',
'description': '表已存在时的处理方式',
'default': 'skip',
'enum': ['skip', 'error', 'replace'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择处理方式',
'options': [
{'label': '跳过(推荐)', 'value': 'skip'},
{'label': '报错', 'value': 'error'},
{'label': '删除重建(危险)', 'value': 'replace'},
],
},
},
'create_schema_if_not_exists': {
'type': 'boolean',
'title': '自动创建Schema',
'description': 'Schema不存在时自动创建(PostgreSQL/SQL Server',
'default': True,
'x-component': 'Switch',
},
},
'required': ['database_design'],
}
@@ -1,507 +0,0 @@
"""
数据库表结构设计节点
接收LLM提取的表结构,进行验证、标准化和补充系统字段
"""
import asyncio
import json
import logging
import uuid
from typing import Any, Dict, List, Optional
from core.database_connection.resolver import ConnectionResolver
from core.database_manager.ddl_builder import (
SYSTEM_FIELDS,
normalize_db_type,
process_canonical_field,
)
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_list_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormDatabaseDesignNode(BaseNode):
"""
数据库表结构设计节点
接收LLM提取的表结构,验证、标准化并补充系统字段
"""
node_type = 'form_database_design'
node_name = '数据库表结构设计'
node_category = 'form'
node_icon = 'database'
node_description = '验证和标准化数据库表结构,自动补充系统字段'
inputs = [
{
'name': 'design_mode',
'type': 'string',
'description': '设计模式:main(主表)或 sub(从表)',
'required': True,
},
{
'name': 'table_name',
'type': 'string',
'description': '主表名(主表模式)',
'required': False,
},
{
'name': 'fields',
'type': 'array',
'description': '主表字段列表(主表模式)',
'required': False,
},
{
'name': 'sub_table_name',
'type': 'string',
'description': '从表名(从表模式)',
'required': False,
},
{
'name': 'sub_fields',
'type': 'array',
'description': '从表字段列表(从表模式)',
'required': False,
},
{
'name': 'parent_table',
'type': 'string',
'description': '关联主表名(从表模式)',
'required': False,
},
{
'name': 'foreign_key',
'type': 'string',
'description': '外键字段名(从表模式)',
'required': False,
},
{
'name': 'db_config',
'type': 'string',
'description': '数据库配置名',
'required': False,
},
{
'name': 'auto_add_system_fields',
'type': 'boolean',
'description': '是否自动添加系统字段',
'required': False,
},
]
outputs = [
{
'name': 'database_design',
'type': 'object',
'description': '标准化的数据库设计配置',
},
{
'name': 'table_name',
'type': 'string',
'description': '表名',
},
{
'name': 'field_count',
'type': 'number',
'description': '字段数量',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info('FormDatabaseDesignNode - 使用用户编辑的数据')
context.variables.pop('__user_input__', None)
return self._build_result_from_edit(edited_data)
design_mode = self.config.get('design_mode', 'main')
db_config = context.resolve_template(self.config.get('db_config', 'default'))
db_schema_raw = self.config.get('db_schema', '')
db_schema = context.resolve_template(db_schema_raw)
db_database_raw = self.config.get('db_database', '')
db_database = context.resolve_template(db_database_raw)
config_db_type = self.config.get('db_type', 'postgresql')
db_type, db_type_resolved = await self._resolve_db_type(
db_config, config_db_type, context,
)
auto_add_system = self.config.get('auto_add_system_fields', True)
logger.info(
'FormDatabaseDesignNode - mode=%s database=%s schema=%s db_config=%s db_type=%s resolved=%s',
design_mode,
db_database,
db_schema,
db_config,
db_type,
db_type_resolved,
)
if design_mode == 'main':
table_name_str = context.resolve_template(self.config.get('table_name', ''))
fields = resolve_list_config(context, self.config.get('fields', '[]'))
if not table_name_str:
return NodeResult(success=False, error='主表名不能为空')
if fields is None:
return NodeResult(success=False, error='字段列表解析失败')
if not fields:
return NodeResult(success=False, error='字段列表不能为空')
table_config = {'tableName': table_name_str, 'fields': fields}
processed_table = self._process_table(
table_config,
'main',
auto_add_system,
db_config,
db_database,
db_database_raw,
db_schema,
db_schema_raw,
db_type,
db_type_resolved,
)
database_design = {
'type': 'main',
'table': processed_table,
'dbConfig': db_config,
}
field_count = len(processed_table['fields'])
require_confirmation = self.resolve_require_confirmation(context, default=True)
preview_data = {
'type': 'database_design',
'title': '数据库表结构设计',
'data': database_design,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=database_design,
output_variables={
'database_design': database_design,
'table_name': table_name_str,
'field_count': field_count,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'database_design',
'title': '数据库表结构设计',
'message': '请确认或编辑数据库表结构',
'data': database_design,
} if require_confirmation else {},
)
sub_table_name_str = context.resolve_template(self.config.get('sub_table_name', ''))
sub_fields = resolve_list_config(context, self.config.get('sub_fields', '[]'))
parent_table = context.resolve_template(self.config.get('parent_table', ''))
foreign_key = context.resolve_template(self.config.get('foreign_key', ''))
if not sub_table_name_str:
return NodeResult(success=False, error='从表名不能为空')
if not parent_table:
return NodeResult(success=False, error='关联主表不能为空')
if sub_fields is None:
return NodeResult(success=False, error='从表字段列表解析失败')
if not sub_fields:
return NodeResult(success=False, error='从表字段列表不能为空')
if not foreign_key:
foreign_key = f'{parent_table}_id'
table_config = {
'tableName': sub_table_name_str,
'fields': sub_fields,
'foreignKey': foreign_key,
'parentTable': parent_table,
}
processed_table = self._process_table(
table_config,
'sub',
auto_add_system,
db_config,
db_database,
db_database_raw,
db_schema,
db_schema_raw,
db_type,
db_type_resolved,
)
database_design = {
'type': 'sub',
'table': processed_table,
'parentTable': parent_table,
'foreignKey': foreign_key,
'dbConfig': db_config,
}
field_count = len(processed_table['fields'])
return NodeResult(
success=True,
output=database_design,
output_variables={
'database_design': database_design,
'table_name': sub_table_name_str,
'field_count': field_count,
},
)
except Exception as exc:
logger.exception('数据库表结构设计节点执行失败: %s', exc)
return NodeResult(success=False, error=f'节点执行失败: {str(exc)}')
async def _resolve_db_type(
self,
db_config: str,
config_db_type: str,
context: NodeContext,
) -> tuple[str, bool]:
configured = normalize_db_type(config_db_type or 'postgresql')
try:
conn_info = await ConnectionResolver.resolve(db_config, context.db_session)
resolved = normalize_db_type(conn_info.db_type)
if configured != resolved:
logger.warning(
'FormDatabaseDesignNode - db_type 配置=%s 与连接 %s 解析=%s 不一致,以连接为准',
configured,
db_config,
resolved,
)
return resolved, True
except Exception as exc:
logger.warning(
'FormDatabaseDesignNode - 无法解析连接 %s: %s,使用配置 db_type=%s',
db_config,
exc,
configured,
)
return configured, False
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
if isinstance(user_input, dict):
if 'table' in user_input or 'type' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('table' in data or 'type' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result_from_edit(self, edited_data: Dict[str, Any]) -> NodeResult:
table_info = edited_data.get('table', {})
table_name = table_info.get('tableName', '')
field_count = len(table_info.get('fields', []))
return NodeResult(
success=True,
output=edited_data,
output_variables={
'database_design': edited_data,
'table_name': table_name,
'field_count': field_count,
},
)
def _process_table(
self,
table: dict,
table_type: str,
auto_add_system: bool,
db_config: str,
db_database: str = '',
db_database_raw: str = '',
db_schema: str = '',
db_schema_raw: str = '',
db_type: str = 'postgresql',
db_type_resolved: bool = False,
) -> dict:
table_name = table.get('tableName', '')
if not table_name:
raise ValueError(f'{table_type} 表必须包含 tableName 字段')
alias = table.get('alias', table_name)
fields = table.get('fields', [])
if not isinstance(fields, list):
raise ValueError(f'{table_name} 的 fields 必须是数组')
processed_fields = []
existing_field_names = set()
for fld in fields:
if not isinstance(fld, dict):
logger.warning('跳过无效的字段配置: %s', fld)
continue
field_name = fld.get('name', '')
if not field_name:
logger.warning('跳过没有名称的字段: %s', fld)
continue
if field_name in existing_field_names:
logger.warning('跳过重复字段: %s', field_name)
continue
existing_field_names.add(field_name)
processed_fields.append(process_canonical_field(fld))
if auto_add_system:
for sys_field in SYSTEM_FIELDS:
if sys_field['name'] not in existing_field_names:
processed_fields.append(sys_field.copy())
final_schema = db_schema or table.get('schema', '')
final_database = db_database or table.get('database', '')
result = {
'id': str(uuid.uuid4()).replace('-', ''),
'type': table_type,
'tableName': table_name,
'alias': alias,
'fields': processed_fields,
'meta': {
'dbName': db_config,
'database': final_database,
'databaseRaw': db_database_raw,
'schema': final_schema,
'schemaRaw': db_schema_raw,
'dbType': normalize_db_type(db_type),
'dbTypeResolved': db_type_resolved,
},
}
if table_type == 'sub':
result['foreignKey'] = table.get('foreignKey', '')
result['parentTable'] = table.get('parentTable', '')
result['relatedField'] = table.get('relatedField', 'id')
result['relationType'] = table.get('relationType', 'one-to-many')
return result
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
return {
'type': 'object',
'properties': {
'design_mode': {
'type': 'string',
'title': '设计模式',
'description': '选择设计主表或从表',
'default': 'main',
'enum': ['main', 'sub'],
'x-component': 'RadioGroup',
},
'table_name': {
'type': 'string',
'title': '表名',
'description': '主表名(string类型)',
'x-component': 'SmartInput',
},
'fields': {
'type': 'string',
'title': '字段列表',
'description': '主表字段配置(array类型)',
'x-component': 'Textarea',
},
'sub_table_name': {
'type': 'string',
'title': '从表名',
'description': '从表名(string类型)',
'x-component': 'SmartInput',
},
'sub_fields': {
'type': 'string',
'title': '从表字段列表',
'description': '从表字段配置(array类型)',
'x-component': 'Textarea',
},
'parent_table': {
'type': 'string',
'title': '关联主表',
'description': '从表关联的主表名(string类型)',
'x-component': 'SmartInput',
},
'foreign_key': {
'type': 'string',
'title': '外键字段',
'description': '外键字段名(string类型),不填则自动生成',
'x-component': 'SmartInput',
},
'db_config': {
'type': 'string',
'title': '数据库配置',
'description': '数据库配置名称',
'default': 'default',
'x-component': 'Input',
},
'db_database': {
'type': 'string',
'title': '目标数据库',
'description': 'PostgreSQL/MySQL/SQL Server 的目标库名,为空则使用连接默认库',
'default': '',
'x-component': 'SmartInput',
},
'db_schema': {
'type': 'string',
'title': '数据库 Schema',
'description': 'PostgreSQL schema 名称(如 public、app),为空则使用默认',
'default': '',
'x-component': 'SmartInput',
},
'db_type': {
'type': 'string',
'title': '数据库类型',
'description': '由数据库连接自动解析,一般无需手动修改',
'default': 'postgresql',
'enum': ['postgresql', 'mysql', 'oracle', 'sqlserver'],
'x-component': 'Select',
},
'auto_add_system_fields': {
'type': 'boolean',
'title': '自动添加系统字段',
'description': '是否自动添加系统字段(id, sys_create_datetime等)',
'default': True,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['design_mode'],
}
@@ -1,748 +0,0 @@
"""
列表UI设计节点
根据表单UI设计配置自动生成列表配置
"""
import json
import logging
import re
import uuid
from typing import Any, Dict, List, Optional, Tuple
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_object_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormListDesignNode(BaseNode):
"""
列表UI设计节点
根据表单UI设计配置自动生成列表配置,包括:
- 查询字段配置
- 列表列配置
- 表格属性配置
- 按钮配置
"""
node_type = 'form_list_design'
node_name = '列表UI设计'
node_category = 'form'
node_icon = 'table'
node_description = '根据表单UI设计自动生成列表配置'
# ========== 查询字段推断规则 ==========
# 默认添加为查询字段的字段模式
QUERY_INCLUDE_PATTERNS = [
r'^(name|title|code|no|status|type|category)$',
r'(creator|modifier)_id$',
]
# name 相关字段模式(优先级最高,作为第一个查询字段)
NAME_FIELD_PATTERNS = [
r'^name$',
r'_name$',
r'^title$',
]
# 排除的查询字段模式
QUERY_EXCLUDE_PATTERNS = [
r'^id$',
r'^is_deleted$',
r'^sort$',
r'(remark|note|description|content|address)$',
r'(password|pwd|secret)$',
]
# ========== 列表列推断规则 ==========
# 默认显示的列模式
COLUMN_INCLUDE_PATTERNS = [
r'^(name|title|code|no|status|type|category)$',
r'(amount|price|count|quantity|total)$',
r'sys_create_datetime$',
]
# 排除的列模式
COLUMN_EXCLUDE_PATTERNS = [
r'^id$',
r'^is_deleted$',
r'^sort$',
r'(password|pwd|secret)$',
r'^sys_(creator|modifier|dept)_id$',
]
# 固定在左侧的列模式
FIXED_LEFT_PATTERNS = [
r'^(name|title|code)$',
]
inputs = [
{
'name': 'form_ui_design',
'type': 'object',
'description': '表单UI设计配置(来自表单UI设计节点)',
'required': True,
},
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置(可选,用于补充字段信息)',
'required': False,
},
]
outputs = [
{
'name': 'list_config',
'type': 'object',
'description': '完整的列表配置',
},
{
'name': 'query_field_count',
'type': 'number',
'description': '查询字段数量',
},
{
'name': 'column_count',
'type': 'number',
'description': '列数量',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'FormListDesignNode - 使用用户编辑的数据')
context.variables.pop('__user_input__', None)
return self._build_result_from_edit(edited_data)
# 获取配置
form_ui_design = resolve_object_config(
context, self.config.get('form_ui_design', '{}')
)
database_design = resolve_object_config(
context, self.config.get('database_design', '{}')
)
# 列表配置选项
auto_query_fields = self.config.get('auto_query_fields', True)
auto_columns = self.config.get('auto_columns', True)
container_type = self.config.get('container_type', 'drawer')
page_size = self.config.get('page_size', 20)
show_index = self.config.get('show_index', True)
show_selection = self.config.get('show_selection', True)
enable_export = self.config.get('enable_export', True)
enable_import = self.config.get('enable_import', False)
logger.info(f'FormListDesignNode - 开始生成列表配置')
# 解析表单UI设计
if not form_ui_design:
return NodeResult(
success=False,
error='表单UI设计配置解析失败或为空',
)
# 解析数据库设计(可选)
# 获取表单项
form_items = form_ui_design.get('items', [])
if not form_items:
return NodeResult(
success=False,
error='表单项列表为空',
)
# 提取表单字段列表(用于前端列表设计组件)
form_fields = self._extract_form_fields(form_items, database_design)
# 生成查询字段
query_fields = []
if auto_query_fields:
query_fields = self._generate_query_fields(form_items)
# 生成列表列
columns = []
if auto_columns:
columns = self._generate_columns(form_items, database_design)
# 构建列表配置
list_config = {
'queryFields': query_fields,
'columns': columns,
'containerType': container_type,
'table': {
'showPagination': True,
'pageSize': page_size,
'showIndex': show_index,
'showSelection': show_selection,
'stripe': True,
'border': True,
'size': 'default',
'height': 'auto',
'defaultSort': [],
'showSummary': False,
'summaryType': 'sum',
'summaryPrecision': 2,
},
'dialog': {
'width': '800px',
'fullscreen': False,
'draggable': True,
'closeOnClickModal': False,
'closeOnPressEscape': True,
},
'drawer': {
'size': '800px',
'direction': 'rtl',
'withHeader': True,
'closeOnClickModal': False,
'closeOnPressEscape': True,
},
'page': {
'showBackButton': True,
'openInNewTab': True,
},
'buttons': {
'showAdd': True,
'showEdit': True,
'showDelete': True,
'showView': True,
'showExport': enable_export,
'showImport': enable_import,
'showBatchDelete': True,
},
}
query_field_count = len(query_fields)
column_count = len(columns)
logger.info(f'FormListDesignNode - 列表配置生成完成: 查询字段数: {query_field_count}, 列数: {column_count}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
# 构建预览数据(包含 form_fields 供前端列表设计组件使用)
preview_data = {
'type': 'list_config',
'title': '列表UI设计',
'data': list_config,
'form_fields': form_fields,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=list_config,
output_variables={
'list_config': list_config,
'form_fields': form_fields,
'query_field_count': query_field_count,
'column_count': column_count,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'list_config',
'title': '列表UI设计',
'message': '请确认或编辑列表UI设计',
'data': list_config,
'form_fields': form_fields,
} if require_confirmation else {},
)
except Exception as e:
logger.exception(f'列表UI设计节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
if 'columns' in user_input or 'queryFields' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('columns' in data or 'queryFields' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result_from_edit(self, edited_data: Dict[str, Any]) -> NodeResult:
"""从用户编辑的数据构建节点结果"""
query_fields = edited_data.get('queryFields', [])
columns = edited_data.get('columns', [])
return NodeResult(
success=True,
output=edited_data,
output_variables={
'list_config': edited_data,
'query_field_count': len(query_fields),
'column_count': len(columns),
},
)
def _parse_config(self, config_str: Any) -> Optional[Dict]:
"""解析配置"""
if isinstance(config_str, dict):
return config_str
if isinstance(config_str, str):
config_str = config_str.strip()
if not config_str or config_str == '{}':
return None
try:
return json.loads(config_str)
except json.JSONDecodeError:
import ast
try:
return ast.literal_eval(config_str)
except (ValueError, SyntaxError):
return None
return None
def _extract_form_fields(self, form_items: List[Dict], database_design: Optional[Dict] = None) -> List[Dict]:
"""
从表单项中提取字段列表,供前端列表设计组件使用
Args:
form_items: 表单项列表
database_design: 数据库设计配置(可选,用于获取字段类型)
Returns:
字段列表,每个字段包含 label, field, component, options, props, dbType 等
"""
fields = []
# 获取数据库字段类型映射
db_field_types = {}
if database_design:
table_data = database_design.get('table', database_design)
db_fields = table_data.get('fields', [])
for db_field in db_fields:
db_field_types[db_field.get('name', '')] = db_field.get('type', '')
def extract_from_items(items: List[Dict]):
"""递归提取字段"""
for item in items:
item_type = item.get('type', '')
# 跳过布局组件,递归处理其子元素
if item_type in ['grid', 'row', 'card', 'tabs', 'collapse', 'divider', 'alert', 'timeline', 'text', 'html', 'spacer', 'title', 'steps']:
if item.get('children'):
extract_from_items(item.get('children', []))
if item.get('columns'):
for col in item.get('columns', []):
if col.get('children'):
extract_from_items(col.get('children', []))
if item.get('items'):
for sub_item in item.get('items', []):
if sub_item.get('children'):
extract_from_items(sub_item.get('children', []))
continue
# 提取有字段名的组件
field_name = item.get('field', '')
if field_name:
db_type = db_field_types.get(field_name, '')
fields.append({
'label': item.get('label', field_name),
'field': field_name,
'component': item_type,
'options': item.get('options', []),
'props': item.get('props', {}),
'dbType': db_type,
'isNumeric': db_type.lower() in ['int', 'integer', 'bigint', 'smallint', 'decimal', 'numeric', 'float', 'double', 'real'],
})
extract_from_items(form_items)
# 添加系统字段
system_fields = [
{'name': 'sys_create_datetime', 'label': '创建时间', 'component': 'date-picker', 'dbType': 'timestamp'},
{'name': 'sys_update_datetime', 'label': '更新时间', 'component': 'date-picker', 'dbType': 'timestamp'},
{'name': 'sys_creator_id', 'label': '创建人', 'component': 'user-select', 'dbType': 'varchar'},
{'name': 'sys_modifier_id', 'label': '修改人', 'component': 'user-select', 'dbType': 'varchar'},
{'name': 'sys_dept_id', 'label': '所属部门', 'component': 'dept-select', 'dbType': 'varchar'},
]
existing_field_names = [f['field'] for f in fields]
for sys_field in system_fields:
if sys_field['name'] not in existing_field_names:
fields.append({
'label': sys_field['label'],
'field': sys_field['name'],
'component': sys_field['component'],
'options': [],
'props': {},
'dbType': sys_field['dbType'],
'isNumeric': False,
'isSystemField': True,
})
return fields
def _generate_query_fields(self, form_items: List[Dict]) -> List[Dict]:
"""生成查询字段配置"""
name_fields = [] # name 相关字段(优先级最高)
other_fields = [] # 其他查询字段
for item in form_items:
field_name = item.get('field', '')
if not field_name:
continue
# 检查是否应该排除
if self._matches_patterns(field_name, self.QUERY_EXCLUDE_PATTERNS):
continue
# 检查是否为 name 相关字段
is_name_field = self._matches_patterns(field_name, self.NAME_FIELD_PATTERNS)
# 检查是否应该包含
should_include = False
if is_name_field:
should_include = True
elif self._matches_patterns(field_name, self.QUERY_INCLUDE_PATTERNS):
should_include = True
else:
# 如果不在包含列表中,检查是否是常用查询字段
component = item.get('type', 'input')
# 选择类组件通常需要查询
if component in ['select', 'radio', 'checkbox', 'user-selector', 'dept-selector', 'date']:
should_include = True
if not should_include:
continue
# 生成查询字段配置
query_field = self._build_query_field(item)
if query_field:
# name 字段放在前面,其他字段放在后面
if is_name_field:
name_fields.append(query_field)
else:
other_fields.append(query_field)
# name 字段排在最前面
return name_fields + other_fields
def _build_query_field(self, item: Dict) -> Optional[Dict]:
"""构建单个查询字段配置"""
field_name = item.get('field', '')
label = item.get('label', field_name)
component = item.get('type', 'input')
props = item.get('props', {})
options = item.get('options', [])
# 推断查询组件类型
query_component = self._infer_query_component(component)
# 推断查询类型
query_type = self._infer_query_type(component, field_name)
# 判断是否为日期时间类型
is_datetime = component in ['date', 'datetime'] or props.get('type') in ['datetime', 'datetimerange']
return {
'label': label,
'field': field_name,
'type': query_type,
'component': query_component,
'originalComponent': component,
'options': options,
'props': props,
'dbType': '',
'width': 6,
'defaultValue': '',
'hidden': False,
'multiple': props.get('multiple', False),
'showTime': is_datetime,
}
def _infer_query_component(self, component: str) -> str:
"""推断查询组件类型"""
# 用户/部门/岗位选择器保持原组件
if component in ['user-selector', 'dept-selector', 'role-selector', 'post-selector']:
return component
# 带选项的组件映射为 select
if component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']:
return 'select'
# 日期类组件保持日期选择
if component in ['date', 'datetime', 'date-picker', 'time']:
return 'date'
# 其他组件默认使用输入框
return 'input'
def _infer_query_type(self, component: str, field_name: str) -> str:
"""推断查询类型"""
# 用户/部门/岗位选择器使用 in 查询
if component in ['user-selector', 'dept-selector', 'role-selector', 'post-selector']:
return 'in'
# 选项组件默认精确匹配
if component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']:
return 'eq'
# 日期类组件默认范围查询
if component in ['date', 'datetime', 'date-picker', 'time']:
return 'range'
# 文本类默认模糊匹配
return 'like'
def _generate_columns(self, form_items: List[Dict], database_design: Optional[Dict]) -> List[Dict]:
"""生成列表列配置"""
columns = []
# 获取数据库字段信息(用于补充类型信息)
db_fields = {}
if database_design:
table_info = database_design.get('table', {})
for field in table_info.get('fields', []):
db_fields[field.get('name', '')] = field
for item in form_items:
field_name = item.get('field', '')
if not field_name:
continue
# 检查是否应该排除
if self._matches_patterns(field_name, self.COLUMN_EXCLUDE_PATTERNS):
continue
# 生成列配置
column = self._build_column(item, db_fields.get(field_name, {}))
if column:
columns.append(column)
return columns
def _build_column(self, item: Dict, db_field: Dict) -> Optional[Dict]:
"""构建单个列配置"""
field_name = item.get('field', '')
label = item.get('label', field_name)
component = item.get('type', 'input')
props = item.get('props', {})
options = item.get('options', [])
# 判断是否为数值类型
db_type = db_field.get('type', '')
is_numeric = self._is_numeric_type(db_type)
# 判断是否为选项组件
has_options = component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']
# 判断是否为关联组件
is_relation = component in ['user-selector', 'dept-selector', 'role-selector', 'post-selector']
# 判断是否固定在左侧
fixed = 'left' if self._matches_patterns(field_name, self.FIXED_LEFT_PATTERNS) else False
# 推断对齐方式
align = 'right' if is_numeric else ('center' if component in ['date', 'datetime', 'switch'] else 'left')
# 判断是否为日期时间类型
is_datetime = db_type.lower() in ['datetime', 'timestamp'] or component in ['datetime']
# 推断过滤类型
filter_type = self._infer_filter_type(component)
# 深拷贝选项并添加 tagType
cloned_options = None
if options:
cloned_options = [
{**opt, 'tagType': opt.get('tagType', '')}
for opt in options
]
return {
'label': label,
'field': field_name,
# 排序配置
'sortable': not self._is_no_sort_component(component),
'sortType': 'backend',
# 过滤配置
'filterable': False,
'filterType': filter_type,
'filterQueryType': 'range',
'filterShowTime': is_datetime,
'filterMultiple': True,
# 列显示配置
'fixed': fixed,
'align': align,
'width': '',
'minWidth': '',
'resizable': True,
'showOverflowTooltip': True,
'ellipsis': True,
'formatter': 'none',
'formatPattern': '',
'prefix': '',
'suffix': '',
# 选项显示
'originalComponent': component,
'options': cloned_options,
'showAsTag': has_options,
# 关联字段
'isRelation': is_relation,
'showDisplayName': is_relation,
'displayField': self._generate_display_field(field_name) if is_relation else '',
# 统计
'dbType': db_type,
'isNumeric': is_numeric,
'summaryEnabled': False,
}
def _infer_filter_type(self, component: str) -> str:
"""推断过滤类型"""
if component in ['date', 'datetime', 'date-picker']:
return 'date-range'
elif component in ['user-selector', 'user-select']:
return 'user-select'
elif component in ['dept-selector', 'dept-select', 'department-selector']:
return 'dept-select'
elif component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']:
return 'select'
return 'input'
def _is_numeric_type(self, db_type: str) -> bool:
"""判断是否为数值类型"""
if not db_type:
return False
db_type_lower = db_type.lower()
return any(t in db_type_lower for t in ['int', 'decimal', 'numeric', 'float', 'double', 'real', 'money'])
def _is_no_sort_component(self, component: str) -> bool:
"""判断是否为不支持排序的组件类型"""
return component in ['file-selector', 'image-selector', 'rich-text', 'textarea']
def _generate_display_field(self, field_name: str) -> str:
"""生成显示字段名"""
if field_name.endswith('_id'):
return field_name.replace('_id', '_name')
if field_name.endswith('Id'):
return field_name.replace('Id', 'Name')
return f'{field_name}_name'
def _matches_patterns(self, field_name: str, patterns: List[str]) -> bool:
"""检查字段名是否匹配任一模式"""
field_name_lower = field_name.lower()
for pattern in patterns:
if re.search(pattern, field_name_lower):
return True
return False
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'form_ui_design': {
'type': 'string',
'title': '表单UI设计配置',
'description': '来自表单UI设计节点的输出',
'x-component': 'SmartInput',
},
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '来自数据库设计节点的输出(可选,用于补充字段类型信息)',
'x-component': 'SmartInput',
},
'auto_query_fields': {
'type': 'boolean',
'title': '自动生成查询字段',
'description': '根据表单字段自动生成查询条件',
'default': True,
'x-component': 'Switch',
},
'auto_columns': {
'type': 'boolean',
'title': '自动生成列配置',
'description': '根据表单字段自动生成列表列',
'default': True,
'x-component': 'Switch',
},
'container_type': {
'type': 'string',
'title': '容器类型',
'description': '表单编辑的容器类型',
'default': 'drawer',
'enum': ['drawer', 'dialog', 'page'],
'enumNames': ['抽屉', '弹窗', '页面'],
'x-component': 'Select',
},
'page_size': {
'type': 'number',
'title': '每页条数',
'description': '列表每页显示的数据条数',
'default': 20,
'enum': [10, 20, 50, 100],
'x-component': 'Select',
},
'show_index': {
'type': 'boolean',
'title': '显示序号列',
'description': '是否显示行序号',
'default': True,
'x-component': 'Switch',
},
'show_selection': {
'type': 'boolean',
'title': '显示选择列',
'description': '是否显示多选框列',
'default': True,
'x-component': 'Switch',
},
'enable_export': {
'type': 'boolean',
'title': '启用导出',
'description': '是否显示导出按钮',
'default': True,
'x-component': 'Switch',
},
'enable_import': {
'type': 'boolean',
'title': '启用导入',
'description': '是否显示导入按钮',
'default': False,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['form_ui_design'],
}
@@ -1,313 +0,0 @@
"""
表单发布节点
将已创建的表单发布到菜单系统
"""
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormPublishNode(BaseNode):
"""
表单发布节点
将已创建的表单发布到菜单系统,生成菜单和权限
"""
node_type = 'form_publish'
node_name = '表单发布'
node_category = 'form'
node_icon = 'send'
node_description = '将已创建的表单发布到菜单系统'
inputs = [
{
'name': 'form_id',
'type': 'string',
'description': '表单ID(来自表单创建节点)',
'required': True,
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码(可选,用于生成路由)',
'required': False,
},
]
outputs = [
{
'name': 'menu_id',
'type': 'string',
'description': '创建的菜单ID',
},
{
'name': 'route_path',
'type': 'string',
'description': '表单访问路径',
},
{
'name': 'publish_result',
'type': 'object',
'description': '发布结果详情',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数
form_id = context.resolve_template(self.config.get('form_id', ''))
form_code = context.resolve_template(self.config.get('form_code', ''))
require_confirmation = self.resolve_require_confirmation(context, default=True)
logger.info(f'FormPublishNode - 开始发布表单: form_id={form_id}')
# 验证必要参数
if not form_id:
return NodeResult(
success=False,
error='表单ID不能为空',
)
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
publish_config = self._parse_user_input(user_input)
if publish_config:
logger.info(f'FormPublishNode - 使用用户编辑的数据: {publish_config}')
# 清除用户输入,防止重复处理
context.variables.pop('__user_input__', None)
return await self._do_publish(context, form_id, publish_config)
# 如果需要确认,返回等待用户输入
if require_confirmation:
return await self._wait_for_confirmation(form_id)
# 不需要确认,直接发布(使用默认配置)
return await self._do_publish(context, form_id, {})
except Exception as e:
logger.exception(f'表单发布节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
def _parse_user_input(self, user_input: Any) -> Optional[Dict]:
"""解析用户输入"""
import json
if isinstance(user_input, dict):
# 检查是否是 publishData 格式
if 'publishData' in user_input:
return user_input.get('publishData', {})
return user_input
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict):
if 'publishData' in data:
return data.get('publishData', {})
return data
except json.JSONDecodeError:
pass
return None
async def _wait_for_confirmation(self, form_id: str) -> NodeResult:
"""等待用户确认"""
from app.database import AsyncSessionLocal
from online_dev.form_manager.service import FormService
async with AsyncSessionLocal() as db:
form = await FormService.get(db, form_id)
if not form:
return NodeResult(
success=False,
error=f'表单不存在: {form_id}',
)
# 构建预览数据
preview_data = {
'type': 'form_publish',
'data': {
'publishData': {
'menu_name': form.name,
'menu_parent_id': None,
'menu_icon': 'lucide:file-text',
'menu_order': 1,
},
},
}
# 返回等待用户输入的结果
return NodeResult(
success=True,
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'form_publish',
'title': '发布表单',
'message': '请确认或编辑发布配置',
'data': {
'menu_name': form.name,
'menu_parent_id': None,
'menu_icon': 'lucide:file-text',
'menu_order': 1,
},
},
preview=preview_data,
)
async def _do_publish(self, context: NodeContext, form_id: str, publish_config: Dict) -> NodeResult:
"""执行发布"""
from app.database import AsyncSessionLocal
from online_dev.form_manager.service import FormService, FormServiceException
async with AsyncSessionLocal() as db:
try:
# 获取表单
form = await FormService.get(db, form_id)
if not form:
return NodeResult(
success=False,
error=f'表单不存在: {form_id}',
)
# 构建发布配置
menu_name = publish_config.get('menu_name', form.name)
menu_parent_id = publish_config.get('menu_parent_id')
menu_icon = publish_config.get('menu_icon', 'lucide:file-text')
menu_order = publish_config.get('menu_order', 1)
publish_data = {
'menu_name': menu_name,
'menu_parent_id': menu_parent_id if menu_parent_id else None,
'menu_icon': menu_icon,
'menu_order': menu_order,
'allow_add': True,
'allow_edit': True,
'allow_delete': True,
'allow_export': True,
'allow_import': False,
}
# 构建路由路径
route_path = f'/form-render/{form.code}'
# 检查表单是否已发布
if form.status == 'published':
# 表单已发布,更新菜单配置
logger.info(f'FormPublishNode - 表单已发布,更新菜单配置: form_id={form_id}')
await self._update_menu_config(db, form, publish_data)
else:
# 发布表单
form = await FormService.publish(db, form_id, publish_data)
# 构建输出
publish_result = {
'form_id': form.id,
'form_code': form.code,
'form_name': form.name,
'status': form.status,
'menu_name': menu_name,
'menu_icon': menu_icon,
'route_path': route_path,
}
logger.info(f'FormPublishNode - 表单发布成功: form_id={form_id}, route_path={route_path}')
return NodeResult(
success=True,
output=publish_result,
output_variables={
'menu_id': form.menu_id if hasattr(form, 'menu_id') else '',
'route_path': route_path,
'publish_result': publish_result,
},
)
except FormServiceException as e:
logger.error(f'表单服务异常: {e}')
return NodeResult(
success=False,
error=f'表单发布失败: {str(e)}',
)
except Exception as e:
logger.exception(f'表单发布异常: {e}')
return NodeResult(
success=False,
error=f'表单发布异常: {str(e)}',
)
async def _update_menu_config(self, db, form, publish_data: Dict):
"""更新已发布表单的菜单配置"""
from sqlalchemy import select
from core.menu.model import Menu
# 查找表单对应的菜单
menu_stmt = select(Menu).where(
Menu.path == f"/form-render/{form.code}"
)
menu_result = await db.execute(menu_stmt)
existing_menu = menu_result.scalar_one_or_none()
if existing_menu:
# 更新菜单配置
existing_menu.name = publish_data.get('menu_name', form.name)
existing_menu.title = publish_data.get('menu_name', form.name)
existing_menu.parent_id = publish_data.get('menu_parent_id')
existing_menu.icon = publish_data.get('menu_icon', 'lucide:file-text')
existing_menu.order = publish_data.get('menu_order', 0)
await db.commit()
logger.info(f'更新表单菜单配置: {form.code}')
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'form_id': {
'type': 'string',
'title': '表单ID',
'description': '来自表单创建节点的输出',
'x-component': 'SmartInput',
},
'form_code': {
'type': 'string',
'title': '表单编码',
'description': '用于生成访问路由(可选)',
'x-component': 'SmartInput',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '发布前是否需要用户确认配置',
'default': True,
'x-component': 'Switch',
},
},
'required': ['form_id'],
}
@@ -1,847 +0,0 @@
"""
表单UI设计节点
根据数据库表结构自动生成表单UI配置
"""
import json
import logging
import re
import uuid
from typing import Any, Dict, List, Optional, Tuple
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_object_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormUIDesignNode(BaseNode):
"""
表单UI设计节点
根据数据库表结构自动生成表单UI配置,包括:
- 字段到组件的映射
- 验证规则推断
- 组件属性配置
- 字段分组
- 布局配置
"""
node_type = 'form_ui_design'
node_name = '表单UI设计'
node_category = 'form'
node_icon = 'layout'
node_description = '根据数据库表结构自动生成表单UI配置'
# ========== 字段类型到组件的映射规则 ==========
FIELD_TYPE_MAPPING = {
# 字符串类型
'varchar': {
'default': 'input',
'long_text_threshold': 200, # 超过此长度使用 textarea
},
'char': {'default': 'input'},
'text': {'default': 'textarea'},
# 数字类型
'int': {'default': 'input-number'},
'integer': {'default': 'input-number'},
'bigint': {'default': 'input-number'},
'smallint': {'default': 'input-number'},
# 小数类型
'decimal': {'default': 'input-number'},
'numeric': {'default': 'input-number'},
'float': {'default': 'input-number'},
'double': {'default': 'input-number'},
# 日期时间类型
'datetime': {'default': 'date', 'props': {'type': 'datetime', 'format': 'YYYY-MM-DD HH:mm:ss'}},
'timestamp': {'default': 'date', 'props': {'type': 'datetime', 'format': 'YYYY-MM-DD HH:mm:ss'}},
'date': {'default': 'date', 'props': {'type': 'date', 'format': 'YYYY-MM-DD'}},
'time': {'default': 'time', 'props': {'format': 'HH:mm:ss'}},
# 布尔类型
'boolean': {'default': 'switch'},
'bool': {'default': 'switch'},
# JSON类型
'json': {'default': 'textarea', 'props': {'rows': 6}},
'jsonb': {'default': 'textarea', 'props': {'rows': 6}},
}
# ========== 字段名模式匹配规则 ==========
FIELD_NAME_PATTERNS = [
# 邮箱
(r'(email|mail)', 'input', {'type': 'email'}),
# 手机号
(r'(phone|mobile|tel)', 'input', {'type': 'tel'}),
# URL
(r'(url|link|website)', 'input', {'type': 'url'}),
# 密码
(r'(password|pwd)', 'input', {'type': 'password', 'show-password': True}),
# 颜色
(r'(color|colour)$', 'color', {}),
# 编码生成器
(r'(code|no|number)$', 'code-generator', {}),
# 长文本
(r'(remark|note|memo)', 'textarea', {'rows': 4}),
(r'(desc|description)', 'textarea', {'rows': 4}),
(r'(content)', 'textarea', {'rows': 6}),
(r'(address|addr)', 'textarea', {'rows': 3}),
# 富文本
(r'(html|rich_text|richtext)', 'rich-text', {}),
# 用户选择器
(r'(user|creator|modifier|operator)_id$', 'user-selector', {}),
# 部门选择器
(r'(dept|department)_id$', 'dept-selector', {}),
# 角色选择器
(r'(role)_id$', 'role-selector', {}),
# 岗位选择器
(r'(post|position)_id$', 'post-selector', {}),
# 通用外键 - 下拉选择
(r'_id$', 'select', {'filterable': True}),
# 评分
(r'(rate|rating|score)$', 'rate', {'max': 5}),
# 百分比
(r'(percent|percentage)$', 'slider', {'min': 0, 'max': 100}),
# 排序
(r'(sort|order|seq)$', 'input-number', {'min': 0, 'step': 1}),
# 年龄
(r'(age)$', 'input-number', {'min': 0, 'max': 150}),
# 金额
(r'(amount|price|money|fee|cost|salary|wage|budget)', 'money-input', {}),
# 图片
(r'(image|photo|picture|avatar|logo)', 'image-selector', {}),
# 文件
(r'(file|attachment|document)', 'file-selector', {}),
# Cron表达式
(r'(cron)', 'cron-selector', {}),
]
# ========== 字段分组规则 ==========
FIELD_GROUP_RULES = {
'basic_info': {
'label': '基本信息',
'patterns': [r'^(name|title|code|no|type|category|status)'],
'priority': 1,
'collapsed': False,
},
'detail_info': {
'label': '详细信息',
'patterns': [r'(desc|description|remark|note|content|detail)'],
'priority': 2,
'collapsed': False,
},
'contact_info': {
'label': '联系方式',
'patterns': [r'(phone|mobile|tel|email|address|contact|wechat|qq)'],
'priority': 3,
'collapsed': False,
},
'financial_info': {
'label': '金额信息',
'patterns': [r'(amount|price|money|fee|cost|salary|wage|budget)'],
'priority': 4,
'collapsed': False,
},
'time_info': {
'label': '时间信息',
'patterns': [r'(date|time|datetime|start|end|begin|finish|deadline)'],
'priority': 5,
'collapsed': False,
},
'attachment_info': {
'label': '附件信息',
'patterns': [r'(file|image|photo|picture|attachment|document)'],
'priority': 6,
'collapsed': False,
},
'system_info': {
'label': '系统信息',
'patterns': [r'^sys_', r'^is_', r'(creator|modifier|create_time|update_time)'],
'priority': 99,
'collapsed': True,
},
}
# 系统字段(默认隐藏)
SYSTEM_FIELDS = [
'id', 'sys_create_datetime', 'sys_update_datetime',
'sys_creator_id', 'sys_modifier_id', 'sys_dept_id',
'is_deleted', 'sort'
]
inputs = [
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置(来自数据库设计节点)',
'required': True,
},
{
'name': 'form_name',
'type': 'string',
'description': '表单名称',
'required': False,
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码',
'required': False,
},
]
outputs = [
{
'name': 'form_ui_design',
'type': 'object',
'description': '表单UI设计配置',
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码',
},
{
'name': 'field_count',
'type': 'number',
'description': '字段数量',
},
{
'name': 'group_count',
'type': 'number',
'description': '分组数量',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'FormUIDesignNode - 使用用户编辑的数据')
context.variables.pop('__user_input__', None)
return self._build_result_from_edit(edited_data)
# 获取配置
database_design = resolve_object_config(
context, self.config.get('database_design', '{}')
)
form_name = context.resolve_template(self.config.get('form_name', ''))
form_code = context.resolve_template(self.config.get('form_code', ''))
# 布局配置
layout_mode = self.config.get('layout_mode', 'auto')
label_width = self.config.get('label_width', 120)
enable_grouping = self.config.get('enable_grouping', True)
hide_system_fields = self.config.get('hide_system_fields', True)
logger.info(f'FormUIDesignNode - 开始生成表单UI配置')
# 解析数据库设计
if not database_design:
return NodeResult(
success=False,
error='数据库设计配置解析失败或为空',
)
# 获取表信息
table_info = database_design.get('table', {})
table_name = table_info.get('tableName', '')
fields = table_info.get('fields', [])
if not table_name:
return NodeResult(
success=False,
error='表名不能为空',
)
if not fields:
return NodeResult(
success=False,
error='字段列表不能为空',
)
# 使用表名作为默认表单编码
if not form_code:
form_code = table_name
if not form_name:
form_name = table_info.get('alias', table_name)
# 生成表单项
form_items = []
for field in fields:
field_name = field.get('name', '')
# 跳过系统字段
if hide_system_fields and field_name in self.SYSTEM_FIELDS:
continue
# 生成表单项配置
form_item = self._generate_form_item(field)
if form_item:
form_items.append(form_item)
# 计算布局 - 默认使用双列布局
col_span = self._calculate_col_span(len(form_items), layout_mode)
for item in form_items:
if 'colSpan' not in item or item.get('colSpan') == 12:
# 只更新默认值,保留特殊组件的全宽设置
if item.get('type') not in ['textarea', 'rich-text', 'sub-table']:
item['colSpan'] = col_span
# 将所有字段包裹在栅格布局容器中(默认两列)
grid_layout_item = {
'id': str(uuid.uuid4()).replace('-', ''),
'type': 'grid-layout',
'label': '',
'props': {
'columns': 2, # 默认两列
'gap': 16,
},
'children': form_items,
'colSpan': 24, # 栅格容器占满整行
}
# 用栅格容器替换原有的扁平列表
wrapped_items = [grid_layout_item]
# 分组处理
if enable_grouping:
grouped_items = self._group_fields(form_items)
else:
grouped_items = [{
'title': '',
'collapsed': False,
'fields': form_items,
}]
# 构建表单UI配置
form_ui_design = {
'formCode': form_code,
'formName': form_name,
'tableName': table_name,
'layout': {
'mode': layout_mode,
'labelWidth': label_width,
'labelPosition': 'right',
'size': 'default',
'colSpan': col_span,
},
'groups': grouped_items,
'items': wrapped_items, # 使用包裹了栅格布局的表单项
'tableConfigs': [], # 表单设计器需要的表配置
'dbConfig': database_design.get('dbConfig', 'default'),
}
field_count = len(form_items)
group_count = len([g for g in grouped_items if g.get('fields')])
logger.info(f'FormUIDesignNode - 表单UI配置生成完成: {form_code}, 字段数: {field_count}, 分组数: {group_count}')
# 构建 table_configs(供前端表单设计组件使用)
table_configs = [{
'id': 'main-table',
'type': 'main',
'tableName': table_name,
'alias': table_info.get('alias', table_name),
'fields': fields,
'meta': {
'schema': table_info.get('schema', 'public'),
'database': table_info.get('database', ''),
},
}]
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
# 构建预览数据(包含 table_configs 供前端表单设计组件使用)
preview_data = {
'type': 'form_ui_design',
'title': '表单UI设计',
'data': form_ui_design,
'table_configs': table_configs,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=form_ui_design,
output_variables={
'form_ui_design': form_ui_design,
'form_code': form_code,
'field_count': field_count,
'group_count': group_count,
'table_configs': table_configs,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'form_ui_design',
'title': '表单UI设计',
'message': '请确认或编辑表单UI设计',
'data': form_ui_design,
'table_configs': table_configs,
} if require_confirmation else {},
)
except Exception as e:
logger.exception(f'表单UI设计节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
if 'items' in user_input or 'groups' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('items' in data or 'groups' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result_from_edit(self, edited_data: Dict[str, Any]) -> NodeResult:
"""从用户编辑的数据构建节点结果"""
form_code = edited_data.get('formCode', '')
items = edited_data.get('items', [])
groups = edited_data.get('groups', [])
return NodeResult(
success=True,
output=edited_data,
output_variables={
'form_ui_design': edited_data,
'form_code': form_code,
'field_count': len(items),
'group_count': len([g for g in groups if g.get('fields')]),
},
)
def _parse_database_design(self, design_str: Any) -> Optional[Dict]:
"""解析数据库设计配置"""
if isinstance(design_str, dict):
return design_str
if isinstance(design_str, str):
design_str = design_str.strip()
if not design_str or design_str == '{}':
return None
try:
return json.loads(design_str)
except json.JSONDecodeError:
import ast
try:
return ast.literal_eval(design_str)
except (ValueError, SyntaxError):
return None
return None
def _generate_form_item(self, field: Dict) -> Optional[Dict]:
"""生成单个表单项配置"""
field_name = field.get('name', '')
field_type = field.get('type', 'varchar').lower()
comment = field.get('comment', field_name)
nullable = field.get('nullable', True)
max_length = field.get('maxLength')
if not field_name:
return None
# 确定组件类型和属性
component, props = self._infer_component(field_name, field_type, field)
# 生成验证规则
rules = self._infer_validation_rules(field, component)
# 生成布局配置
layout = self._infer_layout(field_name, component)
# 构建表单项
form_item = {
'id': str(uuid.uuid4()).replace('-', ''),
'type': component,
'field': field_name,
'label': comment,
'props': props,
'rules': rules,
'colSpan': layout.get('colSpan', 12),
'order': layout.get('order', 0),
}
# 添加选项配置(如果需要)
if component in ['select', 'radio', 'checkbox']:
form_item['options'] = []
form_item['dataSource'] = self._infer_data_source(field_name)
return form_item
def _infer_component(self, field_name: str, field_type: str, field: Dict) -> Tuple[str, Dict]:
"""推断组件类型和属性"""
props = {}
component = 'input' # 默认组件
# 1. 先根据字段名模式匹配
field_name_lower = field_name.lower()
for pattern, comp, pattern_props in self.FIELD_NAME_PATTERNS:
if re.search(pattern, field_name_lower):
component = comp
props.update(pattern_props)
break
else:
# 2. 如果没有匹配到模式,根据字段类型映射
type_config = self.FIELD_TYPE_MAPPING.get(field_type, {'default': 'input'})
component = type_config.get('default', 'input')
# 添加类型默认属性
if 'props' in type_config:
props.update(type_config['props'])
# 特殊处理:varchar 长度判断
if field_type == 'varchar':
max_length = field.get('maxLength', 255)
# 确保 max_length 是整数
try:
max_length = int(max_length) if max_length else 255
except (ValueError, TypeError):
max_length = 255
threshold = type_config.get('long_text_threshold', 200)
if max_length > threshold:
component = 'textarea'
props['rows'] = 4
# 3. 添加通用属性
props = self._add_common_props(props, field, component)
return component, props
def _add_common_props(self, props: Dict, field: Dict, component: str) -> Dict:
"""添加通用组件属性"""
field_name = field.get('name', '')
comment = field.get('comment', field_name)
max_length = field.get('maxLength')
# 确保 max_length 是整数
if max_length:
try:
max_length = int(max_length)
except (ValueError, TypeError):
max_length = None
# 占位符
if component in ['input', 'textarea']:
props.setdefault('placeholder', f'请输入{comment}')
elif component in ['select', 'cascader', 'tree-select', 'user-selector', 'dept-selector', 'role-selector', 'post-selector']:
props.setdefault('placeholder', f'请选择{comment}')
elif component == 'date':
props.setdefault('placeholder', f'请选择{comment}')
# 可清空
props.setdefault('clearable', True)
# 字符串长度限制
if max_length and component in ['input', 'textarea']:
props['maxlength'] = max_length
props['show-word-limit'] = True
# textarea 行数
if component == 'textarea':
props.setdefault('rows', 4)
# 数字输入框
if component == 'input-number':
props.setdefault('controls-position', 'right')
# 小数精度
if field.get('type') in ['decimal', 'numeric', 'float', 'double']:
scale = field.get('scale', 2)
props.setdefault('precision', scale)
props.setdefault('step', 10 ** (-scale))
else:
props.setdefault('step', 1)
# 下拉选择
if component == 'select':
props.setdefault('filterable', True)
# 开关
if component == 'switch':
props.setdefault('active-text', '')
props.setdefault('inactive-text', '')
return props
def _infer_validation_rules(self, field: Dict, component: str) -> List[Dict]:
"""推断验证规则"""
rules = []
field_name = field.get('name', '').lower()
field_type = field.get('type', '')
nullable = field.get('nullable', True)
max_length = field.get('maxLength')
# 确保 max_length 是整数
if max_length:
try:
max_length = int(max_length)
except (ValueError, TypeError):
max_length = None
comment = field.get('comment', field_name)
# 必填规则
if not nullable:
rules.append({
'required': True,
'message': f'{comment}不能为空',
'trigger': 'blur' if component in ['input', 'textarea'] else 'change',
})
# 长度规则
if max_length and field_type in ['varchar', 'char']:
rules.append({
'max': max_length,
'message': f'最多{max_length}个字符',
'trigger': 'blur',
})
# 格式规则
if 'email' in field_name or 'mail' in field_name:
rules.append({
'type': 'email',
'message': '请输入正确的邮箱格式',
'trigger': 'blur',
})
if 'phone' in field_name or 'mobile' in field_name:
rules.append({
'pattern': r'^1[3-9]\d{9}$',
'message': '请输入正确的手机号码',
'trigger': 'blur',
})
if 'url' in field_name or 'link' in field_name:
rules.append({
'type': 'url',
'message': '请输入正确的URL格式',
'trigger': 'blur',
})
return rules
def _infer_layout(self, field_name: str, component: str) -> Dict:
"""推断布局配置"""
layout = {
'colSpan': 12, # 默认占一半宽度
'order': 0,
}
field_name_lower = field_name.lower()
# 全宽组件
if component in ['textarea', 'rich-text', 'sub-table', 'divider']:
layout['colSpan'] = 24
# 小组件
elif component in ['switch', 'rate']:
layout['colSpan'] = 8
# 根据字段名调整
if any(kw in field_name_lower for kw in ['remark', 'note', 'description', 'content', 'address']):
layout['colSpan'] = 24
return layout
def _calculate_col_span(self, field_count: int, layout_mode: str) -> int:
"""
根据字段数量计算列宽
规则:
- 字段数 <= 6: 单列布局 (colSpan=24)
- 字段数 7-12: 双列布局 (colSpan=12)
- 字段数 > 12: 三列布局 (colSpan=8)
- 最多三列
"""
if layout_mode == 'single':
return 24
elif layout_mode == 'double':
return 12
elif layout_mode == 'triple':
return 8
else: # auto
if field_count <= 6:
return 24 # 单列
elif field_count <= 12:
return 12 # 双列
else:
return 8 # 三列(最多)
def _infer_data_source(self, field_name: str) -> Optional[Dict]:
"""推断数据来源配置"""
field_name_lower = field_name.lower()
# 外键字段
if field_name_lower.endswith('_id'):
table_name = field_name_lower[:-3]
# 特殊业务字段
if 'user' in table_name:
return {
'type': 'api',
'apiUrl': '/api/v1/system/users',
'labelField': 'name',
'valueField': 'id',
}
elif 'dept' in table_name or 'department' in table_name:
return {
'type': 'api',
'apiUrl': '/api/v1/system/departments',
'labelField': 'name',
'valueField': 'id',
}
elif 'role' in table_name:
return {
'type': 'api',
'apiUrl': '/api/v1/system/roles',
'labelField': 'name',
'valueField': 'id',
}
# 枚举类型字段
if 'status' in field_name_lower or 'type' in field_name_lower or 'category' in field_name_lower:
return {
'type': 'dict',
'dictCode': f'{field_name_lower}_dict',
}
return None
def _group_fields(self, form_items: List[Dict]) -> List[Dict]:
"""将表单项分组"""
groups = {}
ungrouped = []
for item in form_items:
field_name = item.get('field', '').lower()
group_key = self._match_group(field_name)
if group_key:
if group_key not in groups:
group_config = self.FIELD_GROUP_RULES.get(group_key, {})
groups[group_key] = {
'key': group_key,
'title': group_config.get('label', group_key),
'priority': group_config.get('priority', 50),
'collapsed': group_config.get('collapsed', False),
'fields': [],
}
groups[group_key]['fields'].append(item)
else:
ungrouped.append(item)
# 将未分组的字段放入"其他信息"组
if ungrouped:
groups['other_info'] = {
'key': 'other_info',
'title': '其他信息',
'priority': 50,
'collapsed': False,
'fields': ungrouped,
}
# 按优先级排序
sorted_groups = sorted(groups.values(), key=lambda g: g.get('priority', 50))
# 移除空组
return [g for g in sorted_groups if g.get('fields')]
def _match_group(self, field_name: str) -> Optional[str]:
"""匹配字段所属分组"""
for group_key, group_config in self.FIELD_GROUP_RULES.items():
patterns = group_config.get('patterns', [])
for pattern in patterns:
if re.search(pattern, field_name):
return group_key
return None
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '来自数据库设计节点的输出',
'x-component': 'SmartInput',
},
'form_name': {
'type': 'string',
'title': '表单名称',
'description': '表单显示名称,不填则使用表别名',
'x-component': 'SmartInput',
},
'form_code': {
'type': 'string',
'title': '表单编码',
'description': '表单唯一编码,不填则使用表名',
'x-component': 'SmartInput',
},
'layout_mode': {
'type': 'string',
'title': '布局模式',
'description': '表单布局模式',
'default': 'auto',
'enum': ['auto', 'single', 'double', 'triple'],
'enumNames': ['自动', '单列', '双列', '三列'],
'x-component': 'Select',
},
'label_width': {
'type': 'number',
'title': '标签宽度',
'description': '表单标签宽度(像素)',
'default': 120,
'x-component': 'InputNumber',
},
'enable_grouping': {
'type': 'boolean',
'title': '启用分组',
'description': '是否按字段类型自动分组',
'default': True,
'x-component': 'Switch',
},
'hide_system_fields': {
'type': 'boolean',
'title': '隐藏系统字段',
'description': '是否隐藏系统字段(id, sys_*等)',
'default': True,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['database_design'],
}
@@ -1,188 +0,0 @@
"""
HTTP 请求节点
"""
import logging
import time
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class HttpNode(BaseNode):
"""
HTTP 请求节点
发送 HTTP 请求并获取响应
"""
node_type = 'http'
node_name = 'HTTP 请求'
node_category = 'data'
node_icon = 'globe'
node_description = '发送 HTTP 请求并获取响应'
inputs = [
{
'name': 'url',
'type': 'string',
'description': '请求 URL',
},
{
'name': 'body',
'type': 'object',
'description': '请求体',
},
]
outputs = [
{
'name': 'response',
'type': 'object',
'description': '响应数据',
},
{
'name': 'status_code',
'type': 'number',
'description': '状态码',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行 HTTP 请求"""
start_time = time.time()
try:
import httpx
method = self.config.get('method', 'GET').upper()
url = self.config.get('url', '')
headers = self.config.get('headers', {})
params = self.config.get('params', {})
body = self.config.get('body', {})
timeout = self.config.get('timeout', 30)
output_variable = self.config.get('output_variable', 'http_response')
# 解析模板变量
url = context.resolve_template(url)
# 解析 headers 中的变量
resolved_headers = {}
for key, value in headers.items():
resolved_headers[key] = context.resolve_template(str(value))
# 解析 params 中的变量
resolved_params = {}
for key, value in params.items():
resolved_params[key] = context.resolve_template(str(value))
# 解析 body 中的变量
resolved_body = self._resolve_body(body, context)
# 发送请求
with httpx.Client(timeout=timeout) as client:
if method in ('GET', 'DELETE'):
response = client.request(
method=method,
url=url,
headers=resolved_headers,
params=resolved_params,
)
else:
response = client.request(
method=method,
url=url,
headers=resolved_headers,
params=resolved_params,
json=resolved_body,
)
# 解析响应
try:
response_data = response.json()
except Exception:
response_data = response.text
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=response.is_success,
output=response_data,
output_variables={
output_variable: response_data,
f'{output_variable}_status': response.status_code,
},
elapsed_time=elapsed_time,
metadata={
'status_code': response.status_code,
'headers': dict(response.headers),
},
)
except Exception as e:
logger.exception(f'HTTP 节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=str(e),
elapsed_time=elapsed_time,
)
def _resolve_body(self, body: Any, context: NodeContext) -> Any:
"""递归解析 body 中的变量"""
if isinstance(body, str):
return context.resolve_template(body)
elif isinstance(body, dict):
return {k: self._resolve_body(v, context) for k, v in body.items()}
elif isinstance(body, list):
return [self._resolve_body(item, context) for item in body]
return body
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'method': {
'type': 'string',
'title': '请求方法',
'enum': ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
'default': 'GET',
},
'url': {
'type': 'string',
'title': 'URL',
'description': '支持变量引用,如 {{api_url}}',
},
'headers': {
'type': 'object',
'title': '请求头',
'additionalProperties': {'type': 'string'},
},
'params': {
'type': 'object',
'title': 'URL 参数',
'additionalProperties': {'type': 'string'},
},
'body': {
'type': 'object',
'title': '请求体',
'description': 'POST/PUT/PATCH 请求的 JSON 数据',
},
'timeout': {
'type': 'integer',
'title': '超时时间(秒)',
'default': 30,
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'http_response',
},
},
'required': ['url'],
}
@@ -1,439 +0,0 @@
"""
意图识别节点
使用 LLM 进行智能意图分类,根据用户输入自动路由到对应分支
支持原生 Function Calling(更准确)和文本解析(回退方案)
"""
import json
import logging
import time
from typing import Any, Dict, List
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class IntentNode(BaseNode):
"""
意图识别节点
使用 LLM 分析用户输入,识别用户意图,并路由到对应分支
支持定义多个意图,每个意图可配置名称、描述和示例
"""
node_type = 'intent'
node_name = '意图识别'
node_category = 'logic'
node_icon = 'brain'
node_description = '使用 AI 识别用户意图,自动路由到对应分支'
supports_branches = True
inputs = [
{
'name': 'user_input',
'type': 'string',
'description': '用户输入文本',
},
]
outputs = [
{
'name': 'intent',
'type': 'string',
'description': '识别到的意图名称',
},
{
'name': 'confidence',
'type': 'number',
'description': '置信度(0-1',
},
]
# 默认的意图识别 prompt 模板
DEFAULT_SYSTEM_PROMPT = """你是一个意图分类器。根据用户输入,判断用户的意图属于以下哪个类别。
可选的意图类别:
{intents_description}
请严格按照以下 JSON 格式输出,不要输出其他任何内容:
{{"intent": "意图名称", "confidence": 0.95}}
注意:
1. intent 必须是上述意图类别中的一个名称,如果都不匹配则输出 "other"
2. confidence 是你对这个分类的置信度,范围 0-1
3. 只输出 JSON,不要有任何解释"""
def execute(self, context: NodeContext) -> NodeResult:
"""执行意图识别(同步方法,通过运行异步方法实现)"""
import asyncio
# 在同步方法中运行异步代码
loop = asyncio.get_event_loop()
if loop.is_running():
# 如果已有事件循环在运行,创建新任务
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行意图识别"""
start_time = time.time()
try:
from ai_platform.services.llm_service import LLMService
from ai_platform.models import LLMModel
# 获取配置
model_id = self.config.get('model_id', '')
intents = self.config.get('intents', [])
input_variable = self.config.get('input_variable', 'user_input')
confidence_threshold = self.config.get('confidence_threshold', 0.6)
# 获取用户输入
user_input = context.get_variable(input_variable)
if not user_input:
# 尝试从 __user_input__ 获取
user_input = context.get_variable('__user_input__')
if not user_input:
user_input = context.user_input
if not user_input:
return NodeResult(
success=False,
error='未获取到用户输入',
)
if not intents:
return NodeResult(
success=False,
error='请至少配置一个意图',
)
llm_service = LLMService(context.db_session)
model_id = await llm_service.resolve_chat_model_id(model_id)
# 检查模型是否支持 Function Calling
supports_fc = self._check_model_supports_function_call(model_id)
if supports_fc:
# 使用 Function Calling 方式(更准确)
intent_name, confidence, total_tokens = await self._execute_with_function_calling_async(
llm_service, model_id, user_input, intents
)
else:
# 回退到文本解析方式
intent_name, confidence, total_tokens = await self._execute_with_text_parsing_async(
llm_service, model_id, user_input, intents
)
logger.info(f'意图识别结果: intent_name={intent_name}, confidence={confidence}, use_fc={supports_fc}')
elapsed_time = int((time.time() - start_time) * 1000)
# 判断是否达到置信度阈值
if confidence < confidence_threshold:
logger.info(f'置信度 {confidence} 低于阈值 {confidence_threshold},走 other 分支')
intent_name = 'other'
# 查找对应的分支 ID
next_node_id = self._get_branch_id(intent_name, intents)
logger.info(f'意图识别结果: intent={intent_name}, next_node_id={next_node_id}')
# 设置输出变量
output_var = self.config.get('output_variable', 'intent_result')
return NodeResult(
success=True,
output=intent_name,
next_node_id=next_node_id,
output_variables={
output_var: intent_name,
f'{output_var}_confidence': confidence,
f'{output_var}_input': user_input,
},
tokens_used=total_tokens,
elapsed_time=elapsed_time,
metadata={
'intent': intent_name,
'confidence': confidence,
'matched_branch': next_node_id,
'model_id': model_id,
},
)
except Exception as e:
logger.exception(f'意图识别节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=str(e),
elapsed_time=elapsed_time,
)
def _check_model_supports_function_call(self, model_id) -> bool:
"""检查模型是否支持 Function Calling
TODO: 此方法需要改造为异步版本,当前使用同步查询作为临时方案
"""
# 临时方案:默认返回True,让调用方尝试使用Function Calling
# 实际使用时应通过上下文传入模型信息
return True
async def _execute_with_function_calling_async(
self,
llm_service,
model_id: str,
user_input: str,
intents: List[Dict],
) -> tuple:
"""使用 Function Calling 执行意图识别(更准确,异步版本)"""
# 构建意图名称列表(用于 enum
intent_names = [intent.get('name') for intent in intents] + ['other']
# 构建意图描述(用于 LLM 理解)
intents_description = self._build_intents_description(intents)
# 构建 Function Calling 工具定义
tools = [{
'name': 'classify_intent',
'description': f'根据用户输入对意图进行分类。可选的意图类别:\n{intents_description}',
'parameters': {
'type': 'object',
'properties': {
'intent': {
'type': 'string',
'enum': intent_names,
'description': '识别到的意图名称',
},
'confidence': {
'type': 'number',
'description': '置信度,范围 0-1',
},
},
'required': ['intent', 'confidence'],
},
}]
# 简化的系统提示词
system_prompt = "你是一个意图分类器。分析用户输入,调用 classify_intent 函数返回分类结果。"
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_input},
]
# 调用 LLM(带 Function Calling,异步)
response = await llm_service.chat_async(
model_id=model_id,
messages=messages,
temperature=0.1,
max_tokens=100,
tools=tools,
tool_choice='required', # 强制使用 Function Calling
)
# 从 tool_calls 中提取结果
if response.has_tool_calls and response.tool_calls:
tool_call = response.tool_calls[0]
args = tool_call.arguments
intent_name = args.get('intent', 'other')
confidence = float(args.get('confidence', 0.8))
# 验证意图名称
if intent_name not in intent_names:
intent_name = 'other'
confidence = 0.5
return intent_name, confidence, response.total_tokens
else:
# Function Calling 失败,返回默认值
logger.warning('Function Calling 未返回 tool_calls,回退到 other')
return 'other', 0.5, response.total_tokens
async def _execute_with_text_parsing_async(
self,
llm_service,
model_id: str,
user_input: str,
intents: List[Dict],
) -> tuple:
"""使用文本解析执行意图识别(回退方案,异步版本)"""
# 构建意图描述
intents_description = self._build_intents_description(intents)
# 构建 prompt
system_prompt = self.DEFAULT_SYSTEM_PROMPT.format(
intents_description=intents_description
)
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_input},
]
# 调用 LLM(异步)
response = await llm_service.chat_async(
model_id=model_id,
messages=messages,
temperature=0.1,
max_tokens=100,
)
# 解析响应
intent_name, confidence = self._parse_response(response.content, intents)
return intent_name, confidence, response.total_tokens
def _build_intents_description(self, intents: List[Dict]) -> str:
"""构建意图描述文本"""
lines = []
for i, intent in enumerate(intents, 1):
name = intent.get('name', '')
description = intent.get('description', '')
examples = intent.get('examples', [])
line = f"{i}. {name}"
if description:
line += f" - {description}"
lines.append(line)
# 添加示例
if examples:
for example in examples[:3]: # 最多3个示例
lines.append(f" 示例: \"{example}\"")
# 添加 other 选项
lines.append(f"{len(intents) + 1}. other - 以上都不匹配时选择此项")
return '\n'.join(lines)
def _parse_response(self, response: str, intents: List[Dict]) -> tuple:
"""解析 LLM 响应"""
logger.info(f'开始解析 LLM 响应: {response}')
try:
# 尝试提取 JSON
response = response.strip()
# 处理可能的 markdown 代码块
if response.startswith('```'):
lines = response.split('\n')
json_lines = []
in_json = False
for line in lines:
if line.startswith('```') and not in_json:
in_json = True
continue
elif line.startswith('```') and in_json:
break
elif in_json:
json_lines.append(line)
response = '\n'.join(json_lines)
# 解析 JSON
result = json.loads(response)
intent_name = result.get('intent', 'other')
confidence = float(result.get('confidence', 0.5))
# 验证意图名称是否有效
valid_names = [intent.get('name') for intent in intents] + ['other']
if intent_name not in valid_names:
# 尝试模糊匹配
intent_name_lower = intent_name.lower()
for valid_name in valid_names:
if valid_name.lower() == intent_name_lower:
intent_name = valid_name
break
else:
intent_name = 'other'
confidence = 0.5
return intent_name, confidence
except (json.JSONDecodeError, ValueError, KeyError) as e:
logger.warning(f'解析意图识别响应失败: {e}, response: {response}')
return 'other', 0.5
def _get_branch_id(self, intent_name: str, intents: List[Dict]) -> str:
"""获取意图对应的分支 ID"""
logger.info(f'查找意图分支: intent_name={intent_name}, intents={intents}')
for intent in intents:
if intent.get('name') == intent_name:
branch_id = intent.get('branch_id', intent_name)
logger.info(f'找到匹配意图: {intent}, 返回 branch_id={branch_id}')
return branch_id
logger.info(f'未找到匹配意图,返回 other')
return 'other'
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'model_id': {
'type': 'string',
'title': '模型',
'description': '选择用于意图识别的 LLM 模型',
},
'input_variable': {
'type': 'string',
'title': '输入变量',
'description': '包含用户输入的变量名',
'default': 'user_input',
},
'intents': {
'type': 'array',
'title': '意图列表',
'description': '定义要识别的意图',
'items': {
'type': 'object',
'properties': {
'name': {
'type': 'string',
'title': '意图名称',
'description': '唯一标识,如 consult, complaint',
},
'description': {
'type': 'string',
'title': '意图描述',
'description': '描述这个意图的含义',
},
'examples': {
'type': 'array',
'title': '示例',
'description': '用户可能的输入示例',
'items': {'type': 'string'},
},
'branch_id': {
'type': 'string',
'title': '分支 ID',
'description': '匹配时跳转的分支',
},
},
'required': ['name'],
},
},
'confidence_threshold': {
'type': 'number',
'title': '置信度阈值',
'description': '低于此阈值将走 other 分支',
'default': 0.6,
'minimum': 0,
'maximum': 1,
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'intent_result',
},
},
'required': ['intents'],
}
@@ -1,238 +0,0 @@
"""
知识库检索节点
在 AI 工作流中检索知识库,返回与查询最相关的文档分段
支持向量检索、全文检索和混合检索
"""
import logging
import time
from typing import Any, Dict, List
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class KnowledgeRetrievalNode(BaseNode):
"""
知识库检索节点
从指定知识库中检索与查询文本最相关的文档分段,
输出可直接作为 LLM 节点的上下文使用
"""
node_type = 'knowledge_retrieval'
node_name = '知识库检索'
node_category = 'knowledge'
node_icon = 'BookOpen'
node_description = '从知识库中检索相关文档,为 LLM 提供上下文'
inputs = [
{
'name': 'query',
'type': 'string',
'description': '检索查询文本',
},
]
outputs = [
{
'name': 'results',
'type': 'array',
'description': '检索结果列表',
},
{
'name': 'context',
'type': 'string',
'description': '拼接后的上下文文本',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""同步执行(不支持,需要异步)"""
return NodeResult(
success=False,
error='知识库检索节点必须异步执行',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行知识库检索"""
start_time = time.time()
try:
# 读取配置
knowledge_base_ids = self.config.get('knowledge_base_ids', [])
query_template = self.config.get('query', '')
top_k = self.config.get('top_k', 5)
score_threshold = self.config.get('score_threshold', 0.5)
retrieval_mode = self.config.get('retrieval_mode', None)
rerank_enabled = self.config.get('rerank_enabled', None)
rerank_model_id = self.config.get('rerank_model_id', None)
output_variable = self.config.get('output_variable', 'knowledge_results')
context_variable = self.config.get('context_variable', 'knowledge_context')
context_template = self.config.get('context_template', '')
if not knowledge_base_ids:
return NodeResult(
success=False,
error='未配置知识库',
elapsed_time=int((time.time() - start_time) * 1000),
)
# 解析查询模板中的变量
query = context.resolve_template(query_template) if query_template else context.user_input
if not query or not query.strip():
return NodeResult(
success=False,
error='检索查询文本为空',
elapsed_time=int((time.time() - start_time) * 1000),
)
# 调用检索服务
db = context.db_session
if not db:
return NodeResult(
success=False,
error='数据库会话不可用',
elapsed_time=int((time.time() - start_time) * 1000),
)
from ai_platform.knowledge.services.retrieval_service import RetrievalService
service = RetrievalService(db)
results = await service.retrieve(
query=query,
knowledge_base_ids=knowledge_base_ids,
top_k=top_k,
score_threshold=score_threshold,
retrieval_mode=retrieval_mode,
rerank_enabled=rerank_enabled,
rerank_model_id=rerank_model_id,
)
# 构建结果列表
result_list = []
for r in results:
result_list.append({
'segment_id': r.segment_id,
'document_id': r.document_id,
'document_name': r.document_name,
'knowledge_base_id': r.knowledge_base_id,
'knowledge_base_name': r.knowledge_base_name,
'content': r.content,
'score': r.score,
'token_count': r.token_count,
})
# 构建上下文文本
if context_template:
# 自定义模板
context_text = context.resolve_template(context_template)
else:
# 默认:拼接所有检索结果内容
context_parts = []
for i, r in enumerate(result_list, 1):
context_parts.append(
f"[{i}] (来源: {r['document_name']}, 相似度: {r['score']:.2f})\n{r['content']}"
)
context_text = '\n\n'.join(context_parts)
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=True,
output={
'results': result_list,
'context': context_text,
'total': len(result_list),
'query': query,
},
output_variables={
output_variable: result_list,
context_variable: context_text,
f'{output_variable}_total': len(result_list),
},
elapsed_time=elapsed_time,
metadata={
'query': query,
'knowledge_base_ids': knowledge_base_ids,
'top_k': top_k,
'score_threshold': score_threshold,
'retrieval_mode': retrieval_mode,
'rerank_enabled': rerank_enabled,
'result_count': len(result_list),
},
)
except Exception as e:
logger.exception(f'知识库检索节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=f'检索失败: {str(e)}',
elapsed_time=elapsed_time,
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'knowledge_base_ids': {
'type': 'array',
'title': '知识库',
'description': '选择要检索的知识库',
'items': {'type': 'string'},
},
'query': {
'type': 'string',
'title': '查询文本',
'description': '支持变量引用,如 {{user_input}},留空则使用用户输入',
},
'top_k': {
'type': 'integer',
'title': '返回数量',
'default': 5,
'minimum': 1,
'maximum': 20,
},
'score_threshold': {
'type': 'number',
'title': '相似度阈值',
'default': 0.5,
'minimum': 0,
'maximum': 1,
},
'retrieval_mode': {
'type': 'string',
'title': '检索模式',
'description': '留空则使用知识库默认配置',
'enum': ['vector', 'fulltext', 'hybrid'],
},
'rerank_enabled': {
'type': 'boolean',
'title': '启用重排序',
'description': '不设置则使用知识库默认配置',
'default': None,
},
'rerank_model_id': {
'type': 'string',
'title': '重排序模型',
'description': '不设置则使用知识库默认配置',
},
'output_variable': {
'type': 'string',
'title': '结果变量名',
'default': 'knowledge_results',
},
'context_variable': {
'type': 'string',
'title': '上下文变量名',
'default': 'knowledge_context',
},
},
'required': ['knowledge_base_ids'],
}
File diff suppressed because it is too large Load Diff
@@ -1,405 +0,0 @@
"""
循环节点
支持两种循环模式
1. for_each - 遍历数组对每个元素执行循环体
2. while - 条件循环满足条件时持续执行
"""
import ast
import json
import logging
import operator
from typing import Any, Dict, List
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class LoopNode(BaseNode):
"""
循环节点
支持 for_each while 两种循环模式
"""
node_type = 'loop'
node_name = '循环'
node_category = 'logic'
node_icon = 'repeat'
node_description = '循环执行一组节点,支持遍历数组或条件循环'
supports_branches = True
inputs = [
{
'name': 'items',
'type': 'array',
'description': '要遍历的数组(for_each 模式)',
},
]
outputs = [
{
'name': 'results',
'type': 'array',
'description': '每次循环的结果数组',
},
{
'name': 'current_item',
'type': 'any',
'description': '当前循环项(循环体内可用)',
},
{
'name': 'current_index',
'type': 'number',
'description': '当前循环索引(从 0 开始)',
},
]
# 支持的操作符(用于 while 条件判断)
OPERATORS = {
'eq': operator.eq, # 等于
'ne': operator.ne, # 不等于
'gt': operator.gt, # 大于
'gte': operator.ge, # 大于等于
'lt': operator.lt, # 小于
'lte': operator.le, # 小于等于
'is_empty': lambda a, _: not a, # 为空
'is_not_empty': lambda a, _: bool(a), # 不为空
'is_true': lambda a, _: bool(a), # 为真
'is_false': lambda a, _: not bool(a), # 为假
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行循环节点
循环节点本身只负责初始化循环状态实际的循环执行由 WorkflowService 处理
"""
try:
loop_mode = self.config.get('loop_mode', 'for_each')
max_iterations = self.config.get('max_iterations', 100)
if loop_mode == 'for_each':
return self._init_for_each(context, max_iterations)
elif loop_mode == 'while':
return self._init_while(context, max_iterations)
else:
return NodeResult(
success=False,
error=f'不支持的循环模式: {loop_mode}',
)
except Exception as e:
logger.exception(f'循环节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
)
def _init_for_each(self, context: NodeContext, max_iterations: int) -> NodeResult:
"""初始化 for_each 循环"""
items_var = self.config.get('items_variable', '')
logger.info(f'[LoopNode] 初始化 for_each 循环,items_variable={items_var}')
# 解析数组变量
items = self._resolve_variable(items_var, context)
logger.info(f'[LoopNode] 解析后的 items 类型: {type(items).__name__}, 值: {items}')
# 如果是字符串,尝试解析为 JSON 或 Python 字面量
if isinstance(items, str):
try:
items = json.loads(items)
logger.info(f'[LoopNode] JSON 解析成功,items={items}')
except json.JSONDecodeError:
# 尝试使用 ast.literal_eval 解析 Python 字面量(如 str() 输出的列表)
try:
items = ast.literal_eval(items)
logger.info(f'[LoopNode] Python 字面量解析成功,items={items}')
except (ValueError, SyntaxError):
# 尝试按逗号分割(仅当不是列表/字典格式时)
if not (items.strip().startswith('[') or items.strip().startswith('{')):
items = [item.strip() for item in items.split(',') if item.strip()]
logger.info(f'[LoopNode] 按逗号分割,items={items}')
else:
logger.error(f'[LoopNode] 无法解析 items 字符串: {items[:200]}...')
return NodeResult(
success=False,
error=f'无法解析 items 变量,格式不正确',
)
if not isinstance(items, (list, tuple)):
error_msg = f'items 必须是数组,当前类型: {type(items).__name__}, 值: {items}, items_variable={items_var}'
logger.error(f'[LoopNode] {error_msg}')
return NodeResult(
success=False,
error=error_msg,
)
# 检查数组是否为空
if len(items) == 0:
logger.warning(f'[LoopNode] items 数组为空,items_variable={items_var}')
# 返回成功但不执行循环体
# 限制最大迭代次数
if len(items) > max_iterations:
logger.warning(f'数组长度 {len(items)} 超过最大迭代次数 {max_iterations},将被截断')
items = list(items)[:max_iterations]
return NodeResult(
success=True,
output={
'items': list(items),
'total_count': len(items),
},
output_variables={
'_loop_items': list(items),
'_loop_index': 0,
'_loop_total': len(items),
'_loop_mode': 'for_each',
'_loop_results': [],
},
metadata={
'is_loop': True,
'loop_mode': 'for_each',
'total_iterations': len(items),
'max_iterations': max_iterations,
},
)
def _init_while(self, context: NodeContext, max_iterations: int) -> NodeResult:
"""初始化 while 循环"""
# 检查初始条件
condition_met = self._evaluate_condition(context)
return NodeResult(
success=True,
output={
'condition_met': condition_met,
},
output_variables={
'_loop_index': 0,
'_loop_mode': 'while',
'_loop_condition_met': condition_met,
'_loop_results': [],
'_loop_max_iterations': max_iterations,
},
metadata={
'is_loop': True,
'loop_mode': 'while',
'condition_met': condition_met,
'max_iterations': max_iterations,
},
)
def check_continue(self, context: NodeContext) -> bool:
"""
检查是否继续循环
WorkflowService 在每次循环迭代后调用
"""
loop_mode = context.get_variable('_loop_mode')
current_index = context.get_variable('_loop_index', 0)
max_iterations = self.config.get('max_iterations', 100)
# 检查最大迭代次数
if current_index >= max_iterations:
logger.warning(f'达到最大迭代次数 {max_iterations},停止循环')
return False
if loop_mode == 'for_each':
total = context.get_variable('_loop_total', 0)
return current_index < total
elif loop_mode == 'while':
return self._evaluate_condition(context)
return False
def get_current_item(self, context: NodeContext) -> Any:
"""获取当前循环项(for_each 模式)"""
items = context.get_variable('_loop_items', [])
index = context.get_variable('_loop_index', 0)
if 0 <= index < len(items):
return items[index]
return None
def increment_index(self, context: NodeContext) -> int:
"""增加循环索引"""
current_index = context.get_variable('_loop_index', 0)
new_index = current_index + 1
context.set_variable('_loop_index', new_index)
return new_index
def add_result(self, context: NodeContext, result: Any) -> None:
"""添加循环结果"""
results = context.get_variable('_loop_results', [])
results.append(result)
context.set_variable('_loop_results', results)
def _resolve_variable(self, variable: str, context: NodeContext) -> Any:
"""解析变量引用,支持多层路径访问"""
if not isinstance(variable, str):
return variable
# 如果是空字符串,返回 None
if not variable.strip():
return None
# 检查是否是 {{...}} 格式的变量引用
if not (variable.startswith('{{') and variable.endswith('}}')):
# 不是变量引用,直接返回原始字符串值(可能是硬编码的 JSON 数组或逗号分隔的值)
return variable
# 使用 resolve_template 解析变量,它支持多层路径访问(如 node.key.subkey
resolved = context.resolve_template(variable)
# 如果解析结果与原始变量相同,说明变量不存在或解析失败
if resolved == variable:
return None
# 如果解析结果是字符串,尝试解析为 JSON
if isinstance(resolved, str):
try:
import json
return json.loads(resolved)
except (json.JSONDecodeError, TypeError):
return resolved
return resolved
def _evaluate_condition(self, context: NodeContext) -> bool:
"""评估 while 条件"""
conditions = self.config.get('conditions', [])
logic = self.config.get('condition_logic', 'and') # and 或 or
if not conditions:
return False # 无条件默认不继续
results = []
for condition in conditions:
result = self._evaluate_single_condition(condition, context)
results.append(result)
if logic == 'or':
return any(results)
else: # and
return all(results)
def _evaluate_single_condition(self, condition: Dict, context: NodeContext) -> bool:
"""评估单个条件"""
variable = condition.get('variable', '')
op_name = condition.get('operator', 'eq')
value = condition.get('value', '')
# 解析变量和值
left_val = self._resolve_variable(variable, context)
right_val = self._resolve_variable(value, context) if value else None
# 获取操作符函数
op_func = self.OPERATORS.get(op_name, operator.eq)
try:
# 特殊处理不需要右值的操作符
if op_name in ['is_empty', 'is_not_empty', 'is_true', 'is_false']:
return op_func(left_val, None)
# 尝试类型转换
if isinstance(left_val, (int, float)) and isinstance(right_val, str):
try:
if '.' in right_val:
right_val = float(right_val)
else:
right_val = int(right_val)
except ValueError:
pass
return op_func(left_val, right_val)
except Exception as e:
logger.warning(f'条件评估失败: {e}')
return False
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'loop_mode': {
'type': 'string',
'title': '循环模式',
'enum': ['for_each', 'while'],
'enumNames': ['遍历数组 (For Each)', '条件循环 (While)'],
'default': 'for_each',
},
'items_variable': {
'type': 'string',
'title': '数组变量',
'description': '要遍历的数组变量名(for_each 模式)',
},
'item_variable_name': {
'type': 'string',
'title': '循环项变量名',
'description': '当前循环项的变量名,默认为 item',
'default': 'item',
},
'index_variable_name': {
'type': 'string',
'title': '索引变量名',
'description': '当前索引的变量名,默认为 index',
'default': 'index',
},
'conditions': {
'type': 'array',
'title': '循环条件',
'description': 'while 模式的循环条件',
'items': {
'type': 'object',
'properties': {
'variable': {
'type': 'string',
'title': '变量',
},
'operator': {
'type': 'string',
'title': '操作符',
'enum': list(cls.OPERATORS.keys()),
'default': 'eq',
},
'value': {
'type': 'string',
'title': '比较值',
},
},
},
},
'condition_logic': {
'type': 'string',
'title': '条件逻辑',
'enum': ['and', 'or'],
'enumNames': ['全部满足 (AND)', '任一满足 (OR)'],
'default': 'and',
},
'max_iterations': {
'type': 'integer',
'title': '最大迭代次数',
'description': '防止无限循环,默认 100 次',
'default': 100,
'minimum': 1,
'maximum': 10000,
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'description': '存储所有循环结果的变量名',
'default': 'loop_results',
},
},
}
@@ -1,122 +0,0 @@
"""
合并节点
"""
import logging
from typing import Any, Dict, List
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class MergeNode(BaseNode):
"""
合并节点
等待所有并行分支执行完成后合并结果继续执行
"""
node_type = 'merge'
node_name = '合并'
node_category = 'logic'
node_icon = 'git-merge'
node_description = '等待所有并行分支完成后合并结果'
inputs = [
{
'name': 'branch_results',
'type': 'array',
'description': '各分支的执行结果',
},
]
outputs = [
{
'name': 'merged_result',
'type': 'object',
'description': '合并后的结果',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""
执行合并节点
从上下文中获取所有并行分支的结果并合并
"""
try:
# 获取合并模式
merge_mode = self.config.get('merge_mode', 'object')
output_variable = self.config.get('output_variable', 'merged_result')
# 从上下文获取并行分支结果
# 并行执行时,每个分支的结果会存储在 _parallel_results 中
parallel_results = context.get_variable('_parallel_results', {})
if merge_mode == 'object':
# 对象模式:将各分支结果合并为一个对象
merged = {}
for branch_id, result in parallel_results.items():
merged[branch_id] = result
elif merge_mode == 'array':
# 数组模式:将各分支结果合并为数组
merged = list(parallel_results.values())
elif merge_mode == 'first':
# 取第一个完成的结果
merged = list(parallel_results.values())[0] if parallel_results else None
elif merge_mode == 'concat':
# 字符串拼接模式
separator = self.config.get('separator', '\n')
merged = separator.join(str(v) for v in parallel_results.values())
else:
merged = parallel_results
return NodeResult(
success=True,
output=merged,
output_variables={
output_variable: merged,
'branch_count': len(parallel_results),
},
metadata={
'merge_mode': merge_mode,
'branch_ids': list(parallel_results.keys()),
},
)
except Exception as e:
logger.exception(f'合并节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'merge_mode': {
'type': 'string',
'title': '合并模式',
'description': '如何合并各分支的结果',
'enum': ['object', 'array', 'first', 'concat'],
'enumNames': ['对象(按分支ID', '数组', '取第一个', '字符串拼接'],
'default': 'object',
},
'separator': {
'type': 'string',
'title': '分隔符',
'description': '字符串拼接模式的分隔符',
'default': '\n',
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'merged_result',
},
},
}
@@ -1,114 +0,0 @@
"""
并行分支节点
"""
import logging
from typing import Any, Dict, List
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class ParallelNode(BaseNode):
"""
并行分支节点
将工作流分成多个并行分支同时执行
"""
node_type = 'parallel'
node_name = '并行分支'
node_category = 'logic'
node_icon = 'git-fork'
node_description = '将工作流分成多个并行分支同时执行'
supports_branches = True
inputs = [
{
'name': 'input',
'type': 'any',
'description': '输入数据',
},
]
outputs = [
{
'name': 'branches',
'type': 'array',
'description': '并行分支 ID 列表',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""
执行并行分支节点
返回所有需要并行执行的分支 ID 列表
"""
try:
# 获取配置的分支
branches = self.config.get('branches', [])
if not branches:
# 如果没有配置分支,返回默认的两个分支
branches = [
{'id': 'branch_1', 'name': '分支 1'},
{'id': 'branch_2', 'name': '分支 2'},
]
branch_ids = [b.get('id') for b in branches if b.get('id')]
return NodeResult(
success=True,
output=branch_ids,
output_variables={
'parallel_branches': branch_ids,
},
metadata={
'is_parallel': True,
'branch_count': len(branch_ids),
'branches': branches,
},
)
except Exception as e:
logger.exception(f'并行分支节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'branches': {
'type': 'array',
'title': '并行分支',
'description': '定义并行执行的分支',
'items': {
'type': 'object',
'properties': {
'id': {
'type': 'string',
'title': '分支 ID',
},
'name': {
'type': 'string',
'title': '分支名称',
},
},
'required': ['id'],
},
'default': [
{'id': 'branch_1', 'name': '分支 1'},
{'id': 'branch_2', 'name': '分支 2'},
],
},
},
}
@@ -1,834 +0,0 @@
"""
Snowflake Cortex AI 节点
包含两个节点
1. SnowflakeCortexLLMNode - Cortex LLM Functions (COMPLETE, SUMMARIZE, TRANSLATE )
2. SnowflakeCortexAnalystNode - Cortex Analyst (自然语言查询数据)
"""
import json
import logging
import time
from typing import Any, Dict, List, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
class SnowflakeConnectionMixin:
"""Snowflake 连接混入类"""
# 支持的认证方式
AUTH_TYPES = [
('password', '用户名/密码'),
('key_pair', '密钥对 (Key Pair)'),
('externalbrowser', '外部浏览器 SSO'),
]
def _get_connection(self, config: Dict, context: NodeContext):
"""
获取 Snowflake 连接
支持多种认证方式
- password: 用户名密码认证
- key_pair: RSA 密钥对认证推荐生产环境使用
- externalbrowser: 外部浏览器 SSO 认证
Args:
config: 节点配置
context: 执行上下文
Returns:
snowflake.connector.connection
"""
try:
import snowflake.connector
except ImportError:
raise ImportError('请安装 snowflake-connector-python: pip install snowflake-connector-python')
# 从配置获取连接信息
connection_config = config.get('connection', {})
# 认证方式,默认为密码认证
auth_type = connection_config.get('auth_type', 'password')
# 支持变量引用 - 基础参数
account = context.resolve_template(connection_config.get('account', ''))
user = context.resolve_template(connection_config.get('user', ''))
warehouse = context.resolve_template(connection_config.get('warehouse', ''))
database = context.resolve_template(connection_config.get('database', ''))
schema = context.resolve_template(connection_config.get('schema', 'PUBLIC'))
role = context.resolve_template(connection_config.get('role', ''))
# 验证必填参数
if not account:
raise ValueError('Snowflake 连接缺少 account 配置')
if not user:
raise ValueError('Snowflake 连接缺少 user 配置')
if not warehouse:
raise ValueError('Snowflake 连接缺少 warehouse 配置')
if not database:
raise ValueError('Snowflake 连接缺少 database 配置')
conn_params = {
'account': account,
'user': user,
'warehouse': warehouse,
'database': database,
'schema': schema,
}
if role:
conn_params['role'] = role
# 根据认证方式设置认证参数
if auth_type == 'password':
password = context.resolve_template(connection_config.get('password', ''))
if not password:
raise ValueError('密码认证方式需要配置 password')
conn_params['password'] = password
elif auth_type == 'key_pair':
# 密钥对认证
private_key = context.resolve_template(connection_config.get('private_key', ''))
private_key_path = context.resolve_template(connection_config.get('private_key_path', ''))
private_key_passphrase = context.resolve_template(connection_config.get('private_key_passphrase', ''))
if private_key:
# 直接使用私钥内容
conn_params['private_key'] = self._load_private_key_from_string(
private_key, private_key_passphrase
)
elif private_key_path:
# 从文件路径加载私钥
conn_params['private_key'] = self._load_private_key_from_file(
private_key_path, private_key_passphrase
)
else:
raise ValueError('密钥对认证需要配置 private_key 或 private_key_path')
elif auth_type == 'externalbrowser':
# 外部浏览器 SSO 认证
conn_params['authenticator'] = 'externalbrowser'
else:
raise ValueError(f'不支持的认证方式: {auth_type}')
logger.info(f'Snowflake 连接: account={account}, user={user}, auth_type={auth_type}')
return snowflake.connector.connect(**conn_params)
def _load_private_key_from_string(self, private_key_str: str, passphrase: str = '') -> bytes:
"""从字符串加载 RSA 私钥"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
# 处理可能的转义换行符
private_key_str = private_key_str.replace('\\n', '\n')
p_key = serialization.load_pem_private_key(
private_key_str.encode('utf-8'),
password=passphrase.encode('utf-8') if passphrase else None,
backend=default_backend()
)
return p_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
def _load_private_key_from_file(self, file_path: str, passphrase: str = '') -> bytes:
"""从文件加载 RSA 私钥"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
with open(file_path, 'rb') as key_file:
p_key = serialization.load_pem_private_key(
key_file.read(),
password=passphrase.encode('utf-8') if passphrase else None,
backend=default_backend()
)
return p_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
@NodeRegistry.register
class SnowflakeCortexLLMNode(BaseNode, SnowflakeConnectionMixin):
"""
Snowflake Cortex LLM Functions 节点
支持的功能
- COMPLETE: LLM 文本生成
- SUMMARIZE: 文本摘要
- TRANSLATE: 翻译
- SENTIMENT: 情感分析
- EXTRACT_ANSWER: 问答提取
"""
node_type = 'snowflake_cortex_llm'
node_name = 'Snowflake Cortex LLM'
node_category = 'ai'
node_icon = 'snowflake'
node_description = 'Snowflake Cortex AI 函数(LLM 生成、摘要、翻译、情感分析等)'
# 支持的 Cortex 模型
CORTEX_MODELS = [
'mistral-large',
'mistral-large2',
'mistral-7b',
'mixtral-8x7b',
'llama3-8b',
'llama3-70b',
'llama3.1-8b',
'llama3.1-70b',
'llama3.1-405b',
'llama3.2-1b',
'llama3.2-3b',
'snowflake-arctic',
'reka-core',
'reka-flash',
'jamba-instruct',
'jamba-1.5-mini',
'jamba-1.5-large',
'gemma-7b',
]
# 支持的功能类型
FUNCTION_TYPES = [
('complete', 'LLM 生成 (COMPLETE)'),
('summarize', '文本摘要 (SUMMARIZE)'),
('translate', '翻译 (TRANSLATE)'),
('sentiment', '情感分析 (SENTIMENT)'),
('extract_answer', '问答提取 (EXTRACT_ANSWER)'),
]
inputs = [
{
'name': 'text',
'type': 'string',
'description': '输入文本',
},
]
outputs = [
{
'name': 'result',
'type': 'string',
'description': 'Cortex 输出结果',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行 Cortex LLM 函数"""
start_time = time.time()
try:
function_type = self.config.get('function', 'complete')
# 根据功能类型执行
if function_type == 'complete':
result = self._execute_complete(context)
elif function_type == 'summarize':
result = self._execute_summarize(context)
elif function_type == 'translate':
result = self._execute_translate(context)
elif function_type == 'sentiment':
result = self._execute_sentiment(context)
elif function_type == 'extract_answer':
result = self._execute_extract_answer(context)
else:
return NodeResult(
success=False,
error=f'不支持的功能类型: {function_type}',
)
elapsed_time = int((time.time() - start_time) * 1000)
output_var = self.config.get('output_variable', 'cortex_result')
return NodeResult(
success=True,
output=result,
output_variables={output_var: result},
elapsed_time=elapsed_time,
metadata={'function': function_type},
)
except Exception as e:
logger.exception(f'Snowflake Cortex LLM 节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=str(e),
elapsed_time=elapsed_time,
)
def _execute_complete(self, context: NodeContext) -> str:
"""
执行 COMPLETE 函数 - 使用 REST API 方式
API 端点: /api/v2/cortex/inference:complete
"""
import requests
config = self.config.get('complete', {})
model = config.get('model', 'mistral-large')
prompt = context.resolve_template(config.get('prompt', ''))
system_prompt = context.resolve_template(config.get('system_prompt', ''))
temperature = config.get('temperature', 0.7)
max_tokens = config.get('max_tokens', 1024)
if not prompt:
raise ValueError('COMPLETE 功能需要配置 prompt')
connection_config = self.config.get('connection', {})
account = context.resolve_template(connection_config.get('account', ''))
# 获取连接以获取 session token
conn = self._get_connection(self.config, context)
try:
# 从连接中获取 REST session token
rest_token = conn.rest.token
# 构建 API URL
if '.snowflakecomputing.com' in account:
host = account
else:
host = conn.host if hasattr(conn, 'host') else f'{account}.snowflakecomputing.com'
api_url = f"https://{host}/api/v2/cortex/inference:complete"
# 构建请求头
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': f'Snowflake Token="{rest_token}"',
}
# 构建消息
messages = []
if system_prompt:
messages.append({'role': 'system', 'content': system_prompt})
messages.append({'role': 'user', 'content': prompt})
# 构建请求体
payload = {
'model': model,
'messages': messages,
'temperature': temperature,
'max_tokens': max_tokens,
}
logger.info(f'调用 Cortex LLM API: {api_url}')
logger.info(f'Model: {model}, Temperature: {temperature}, Max Tokens: {max_tokens}')
response = requests.post(
api_url,
json=payload,
headers=headers,
timeout=120,
)
logger.info(f'Cortex LLM 响应状态: {response.status_code}')
if response.status_code != 200:
error_text = response.text
logger.error(f'Cortex LLM API 错误: {error_text}')
raise ValueError(f'Cortex LLM API 调用失败: {response.status_code} - {error_text}')
# 解析响应 - REST API 返回 SSE 格式
result_content = ''
response_text = response.text
# 解析 SSE 格式的响应
for line in response_text.split('\n'):
if line.startswith('data:'):
data_str = line[5:].strip()
if data_str:
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
choice = data['choices'][0]
if 'delta' in choice and 'content' in choice['delta']:
result_content += choice['delta']['content']
except json.JSONDecodeError:
pass
return result_content if result_content else response_text
finally:
conn.close()
def _execute_summarize(self, context: NodeContext) -> str:
"""执行 SUMMARIZE 函数"""
config = self.config.get('summarize', {})
text = context.resolve_template(config.get('text', ''))
if not text:
raise ValueError('SUMMARIZE 功能需要配置 text')
conn = self._get_connection(self.config, context)
try:
cursor = conn.cursor()
sql = "SELECT SNOWFLAKE.CORTEX.SUMMARIZE(%s)"
cursor.execute(sql, (text,))
row = cursor.fetchone()
return row[0] if row else ''
finally:
conn.close()
def _execute_translate(self, context: NodeContext) -> str:
"""执行 TRANSLATE 函数"""
config = self.config.get('translate', {})
text = context.resolve_template(config.get('text', ''))
source_language = config.get('source_language', 'en')
target_language = config.get('target_language', 'zh')
if not text:
raise ValueError('TRANSLATE 功能需要配置 text')
conn = self._get_connection(self.config, context)
try:
cursor = conn.cursor()
sql = "SELECT SNOWFLAKE.CORTEX.TRANSLATE(%s, %s, %s)"
cursor.execute(sql, (text, source_language, target_language))
row = cursor.fetchone()
return row[0] if row else ''
finally:
conn.close()
def _execute_sentiment(self, context: NodeContext) -> str:
"""执行 SENTIMENT 函数"""
config = self.config.get('sentiment', {})
text = context.resolve_template(config.get('text', ''))
if not text:
raise ValueError('SENTIMENT 功能需要配置 text')
conn = self._get_connection(self.config, context)
try:
cursor = conn.cursor()
sql = "SELECT SNOWFLAKE.CORTEX.SENTIMENT(%s)"
cursor.execute(sql, (text,))
row = cursor.fetchone()
# SENTIMENT 返回 -1 到 1 的数值
result = row[0] if row else 0
return str(result)
finally:
conn.close()
def _execute_extract_answer(self, context: NodeContext) -> str:
"""执行 EXTRACT_ANSWER 函数"""
config = self.config.get('extract_answer', {})
document = context.resolve_template(config.get('document', ''))
question = context.resolve_template(config.get('question', ''))
if not document or not question:
raise ValueError('EXTRACT_ANSWER 功能需要配置 document 和 question')
conn = self._get_connection(self.config, context)
try:
cursor = conn.cursor()
sql = "SELECT SNOWFLAKE.CORTEX.EXTRACT_ANSWER(%s, %s)"
cursor.execute(sql, (document, question))
row = cursor.fetchone()
result = row[0] if row else ''
# 解析 JSON 结果
if isinstance(result, str):
try:
parsed = json.loads(result)
if isinstance(parsed, list) and len(parsed) > 0:
# 返回第一个答案
return parsed[0].get('answer', result)
except json.JSONDecodeError:
pass
return result
finally:
conn.close()
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'connection': {
'type': 'object',
'title': 'Snowflake 连接',
'properties': {
'account': {'type': 'string', 'title': 'Account'},
'user': {'type': 'string', 'title': 'User'},
'password': {'type': 'string', 'title': 'Password', 'format': 'password'},
'warehouse': {'type': 'string', 'title': 'Warehouse'},
'database': {'type': 'string', 'title': 'Database'},
'schema': {'type': 'string', 'title': 'Schema', 'default': 'PUBLIC'},
'role': {'type': 'string', 'title': 'Role'},
},
'required': ['account', 'user', 'password', 'warehouse', 'database'],
},
'function': {
'type': 'string',
'title': '功能类型',
'enum': ['complete', 'summarize', 'translate', 'sentiment', 'extract_answer'],
'default': 'complete',
},
'complete': {
'type': 'object',
'title': 'COMPLETE 配置',
'properties': {
'model': {'type': 'string', 'title': '模型', 'default': 'mistral-large'},
'prompt': {'type': 'string', 'title': '提示词', 'format': 'textarea'},
'system_prompt': {'type': 'string', 'title': '系统提示词', 'format': 'textarea'},
'temperature': {'type': 'number', 'title': '温度', 'default': 0.7},
'max_tokens': {'type': 'integer', 'title': '最大 Token', 'default': 1024},
},
},
'summarize': {
'type': 'object',
'title': 'SUMMARIZE 配置',
'properties': {
'text': {'type': 'string', 'title': '待摘要文本', 'format': 'textarea'},
},
},
'translate': {
'type': 'object',
'title': 'TRANSLATE 配置',
'properties': {
'text': {'type': 'string', 'title': '待翻译文本', 'format': 'textarea'},
'source_language': {'type': 'string', 'title': '源语言', 'default': 'en'},
'target_language': {'type': 'string', 'title': '目标语言', 'default': 'zh'},
},
},
'sentiment': {
'type': 'object',
'title': 'SENTIMENT 配置',
'properties': {
'text': {'type': 'string', 'title': '待分析文本', 'format': 'textarea'},
},
},
'extract_answer': {
'type': 'object',
'title': 'EXTRACT_ANSWER 配置',
'properties': {
'document': {'type': 'string', 'title': '文档内容', 'format': 'textarea'},
'question': {'type': 'string', 'title': '问题'},
},
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'cortex_result',
},
},
'required': ['connection', 'function'],
}
@NodeRegistry.register
class SnowflakeCortexAnalystNode(BaseNode, SnowflakeConnectionMixin):
"""
Snowflake Cortex Analyst 节点
使用自然语言查询数据自动生成 SQL 并返回结果
"""
node_type = 'snowflake_cortex_analyst'
node_name = 'Snowflake Cortex Analyst'
node_category = 'ai'
node_icon = 'snowflake'
node_description = 'Snowflake Cortex Analyst - 自然语言查询数据'
inputs = [
{
'name': 'question',
'type': 'string',
'description': '自然语言问题',
},
]
outputs = [
{
'name': 'result',
'type': 'object',
'description': '查询结果',
},
{
'name': 'sql',
'type': 'string',
'description': '生成的 SQL',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行 Cortex Analyst 查询"""
start_time = time.time()
try:
# 获取配置
question = context.resolve_template(self.config.get('question', ''))
semantic_model_file = self.config.get('semantic_model_file', '')
if not question:
return NodeResult(
success=False,
error='请配置查询问题',
)
if not semantic_model_file:
return NodeResult(
success=False,
error='请配置语义模型文件路径',
)
# 调用 Cortex Analyst API
result = self._call_cortex_analyst(context, question, semantic_model_file)
elapsed_time = int((time.time() - start_time) * 1000)
output_var = self.config.get('output_variable', 'analyst_result')
sql_var = self.config.get('sql_variable', 'analyst_sql')
return NodeResult(
success=True,
output=result,
output_variables={
output_var: result.get('data', []),
sql_var: result.get('sql', ''),
f'{output_var}_raw': result,
},
elapsed_time=elapsed_time,
metadata={'question': question},
)
except Exception as e:
logger.exception(f'Snowflake Cortex Analyst 节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=str(e),
elapsed_time=elapsed_time,
)
def _call_cortex_analyst(
self,
context: NodeContext,
question: str,
semantic_model: str,
) -> Dict[str, Any]:
"""
调用 Cortex Analyst API
Cortex Analyst 必须通过 REST API 调用不支持 SQL 函数方式
支持两种语义模型来源
1. Semantic View: database.schema.semantic_view_name ( AI_TEST.PUBLIC.CAR_AI_TEST)
2. Stage 文件: @database.schema.stage/file.yaml
Args:
context: 节点上下文
question: 用户问题
semantic_model: 语义模型路径Semantic View Stage 文件
"""
import requests
connection_config = self.config.get('connection', {})
account = context.resolve_template(connection_config.get('account', ''))
database = context.resolve_template(connection_config.get('database', ''))
schema = context.resolve_template(connection_config.get('schema', 'PUBLIC'))
# 获取连接以获取 session token
conn = self._get_connection(self.config, context)
try:
# 从连接中获取 REST session token
# Snowflake Python Connector 可以提供 REST session token
rest_token = conn.rest.token
master_token = conn.rest.master_token
# 构建 API URL
# 处理 account 格式:可能是 xxx.snowflakecomputing.com 或 account_identifier
if '.snowflakecomputing.com' in account:
host = account
else:
# 从连接获取实际 host
host = conn.host if hasattr(conn, 'host') else f'{account}.snowflakecomputing.com'
base_url = f"https://{host}"
api_url = f"{base_url}/api/v2/cortex/analyst/message"
# 构建请求头
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': f'Snowflake Token="{rest_token}"',
}
# 判断是 Semantic View 还是 Stage 文件
is_stage_file = semantic_model.startswith('@')
# 构建请求体
messages = [
{
'role': 'user',
'content': [{'type': 'text', 'text': question}]
}
]
if is_stage_file:
# Stage 文件格式: @database.schema.stage/file.yaml
payload = {
'messages': messages,
'semantic_model_file': semantic_model,
}
else:
# Semantic View 格式: database.schema.view_name
# 使用 semantic_models 数组包含 semantic_view
payload = {
'messages': messages,
'semantic_models': [
{'semantic_view': semantic_model}
],
}
logger.info(f'调用 Cortex Analyst API: {api_url}')
logger.info(f'Payload: {payload}')
response = requests.post(
api_url,
json=payload,
headers=headers,
timeout=60,
)
logger.info(f'Cortex Analyst 响应状态: {response.status_code}')
if response.status_code != 200:
error_text = response.text
logger.error(f'Cortex Analyst API 错误: {error_text}')
raise ValueError(f'Cortex Analyst API 调用失败: {response.status_code} - {error_text}')
result = response.json()
logger.info(f'Cortex Analyst 返回: {result}')
# 解析响应,提取 SQL 和执行结果
return self._parse_analyst_response(conn, result)
except requests.RequestException as e:
logger.exception(f'Cortex Analyst REST API 调用失败: {e}')
raise ValueError(f'Cortex Analyst API 调用失败: {e}')
finally:
conn.close()
def _parse_analyst_response(self, conn, result: Dict[str, Any]) -> Dict[str, Any]:
"""解析 Cortex Analyst 响应并执行生成的 SQL"""
generated_sql = None
answer_text = None
# 尝试从响应中提取 SQL
if isinstance(result, dict):
# 格式1: message.content 数组
if 'message' in result and 'content' in result['message']:
content = result['message']['content']
if isinstance(content, list):
for item in content:
if isinstance(item, dict):
if item.get('type') == 'sql':
generated_sql = item.get('statement', item.get('text', ''))
elif item.get('type') == 'text':
answer_text = item.get('text', '')
elif isinstance(content, str):
answer_text = content
# 格式2: 直接 sql 字段
elif 'sql' in result:
generated_sql = result['sql']
# 格式3: choices 数组
elif 'choices' in result and len(result['choices']) > 0:
choice = result['choices'][0]
if 'message' in choice and 'content' in choice['message']:
content = choice['message']['content']
if isinstance(content, list):
for item in content:
if isinstance(item, dict):
if item.get('type') == 'sql':
generated_sql = item.get('statement', '')
elif item.get('type') == 'text':
answer_text = item.get('text', '')
# 如果有生成的 SQL,执行它获取数据
if generated_sql:
logger.info(f'执行生成的 SQL: {generated_sql}')
cursor = conn.cursor()
try:
cursor.execute(generated_sql)
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
data = [dict(zip(columns, r)) for r in rows]
return {
'sql': generated_sql,
'data': data,
'columns': columns,
'row_count': len(data),
'answer': answer_text,
}
finally:
cursor.close()
# 没有 SQL,返回文本回答
return {
'sql': '',
'data': [],
'columns': [],
'row_count': 0,
'answer': answer_text or str(result),
'raw_response': result,
}
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'connection': {
'type': 'object',
'title': 'Snowflake 连接',
'properties': {
'account': {'type': 'string', 'title': 'Account'},
'user': {'type': 'string', 'title': 'User'},
'password': {'type': 'string', 'title': 'Password', 'format': 'password'},
'warehouse': {'type': 'string', 'title': 'Warehouse'},
'database': {'type': 'string', 'title': 'Database'},
'schema': {'type': 'string', 'title': 'Schema', 'default': 'PUBLIC'},
'role': {'type': 'string', 'title': 'Role'},
},
'required': ['account', 'user', 'password', 'warehouse', 'database'],
},
'question': {
'type': 'string',
'title': '查询问题',
'description': '用自然语言描述你想查询的数据',
'format': 'textarea',
},
'semantic_model_file': {
'type': 'string',
'title': '语义模型文件',
'description': '语义模型文件路径,如 @my_db.my_schema.my_stage/semantic_model.yaml',
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'analyst_result',
},
},
'required': ['connection', 'question', 'semantic_model_file'],
}
@@ -1,155 +0,0 @@
"""
开始节点
"""
import logging
from typing import Any, Dict, List
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class StartNode(BaseNode):
"""
开始节点
工作流的入口节点接收用户输入
"""
node_type = 'start'
node_name = '开始'
node_category = 'basic'
node_icon = 'play-circle'
node_description = '工作流的开始节点,接收用户输入'
inputs = []
outputs = [
{
'name': 'user_input',
'type': 'string',
'description': '用户输入',
},
{
'name': 'application_id',
'type': 'string',
'description': '子应用ID(在子应用模式下自动注入)',
},
{
'name': 'application_code',
'type': 'string',
'description': '子应用编码(在子应用模式下自动注入)',
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码(从表单列表调用时自动注入)',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行开始节点"""
output_variables = {}
# 调试日志
logger.info(f"StartNode execute - context.variables: {list(context.variables.keys())}")
logger.info(f"StartNode execute - application_id in variables: {'application_id' in context.variables}")
if 'application_id' in context.variables:
logger.info(f"StartNode execute - application_id value: {context.variables['application_id']}")
# 首先将 user_input 作为开始节点的输出变量
# 这样下游节点可以通过 {{start-xxx.user_input}} 引用
if context.user_input:
output_variables['user_input'] = context.user_input
elif 'user_input' in context.variables:
output_variables['user_input'] = context.variables['user_input']
# 将 application_id 作为开始节点的输出变量
# 这样下游节点可以通过 {{start-xxx.application_id}} 引用
# 主应用模式下为空字符串,子应用模式下为实际的应用ID
output_variables['application_id'] = context.variables.get('application_id', 'main')
# 将 application_code 作为开始节点的输出变量
# 这样下游节点可以通过 {{start-xxx.application_code}} 引用
# 主应用模式下为空字符串,子应用模式下为实际的应用编码
output_variables['application_code'] = context.variables.get('application_code', '')
# 将 form_code 作为开始节点的输出变量
# 这样下游节点可以通过 {{start-xxx.form_code}} 引用
# 从表单列表调用时会自动注入,否则为空字符串
output_variables['form_code'] = context.variables.get('form_code', '')
logger.info(f"StartNode execute - output_variables: {output_variables}")
# 处理前端定义的变量(variables 数组)
# 格式: [{ variable: 'name', type: 'string', label: '名称', required: true, default_value: '' }]
variables = self.config.get('variables', [])
for var in variables:
var_name = var.get('variable', '')
if not var_name:
continue
default_value = var.get('default_value', '')
var_type = var.get('type', 'string')
# 如果 context 中已有该变量(从 inputs 传入),使用传入的值
# 否则使用默认值
if var_name in context.variables and context.variables[var_name]:
value = context.variables[var_name]
else:
value = default_value
# 类型转换
if var_type == 'number' and value:
try:
value = float(value) if '.' in str(value) else int(value)
except (ValueError, TypeError):
pass
elif var_type == 'boolean':
if isinstance(value, str):
value = value.lower() in ('true', '1', 'yes')
context.set_variable(var_name, value)
output_variables[var_name] = value
# 处理自定义输入变量(兼容旧格式 input_variables
input_variables = self.config.get('input_variables', [])
for var in input_variables:
var_name = var.get('name', '')
var_value = var.get('default', '')
if var_name:
# 如果 context 中没有该变量,使用默认值
if var_name not in context.variables or not context.variables[var_name]:
context.set_variable(var_name, var_value)
output_variables[var_name] = context.get_variable(var_name)
return NodeResult(
success=True,
output=output_variables,
output_variables=output_variables,
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'input_variables': {
'type': 'array',
'title': '输入变量',
'items': {
'type': 'object',
'properties': {
'name': {'type': 'string', 'title': '变量名'},
'type': {'type': 'string', 'title': '类型', 'enum': ['string', 'number', 'boolean']},
'description': {'type': 'string', 'title': '描述'},
'default': {'type': 'string', 'title': '默认值'},
'required': {'type': 'boolean', 'title': '是否必填'},
},
},
},
},
}
File diff suppressed because it is too large Load Diff
@@ -1,501 +0,0 @@
"""
系统总结节点
收集和展示AI创建的完整系统信息包括应用表单模块和仪表盘
"""
import ast
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class SystemSummaryNode(BaseNode):
"""
系统总结节点
收集和展示AI创建的完整系统信息支持以下场景
1. 完整应用创建应用 + 多个表单模块 + 仪表盘
2. 子应用内创建模块仅多个表单模块
3. 单个表单创建单个表单模块
"""
node_type = 'system_summary'
node_name = '系统总结'
node_category = 'application'
node_icon = 'clipboard-check'
node_description = '收集和展示AI创建的完整系统信息'
inputs = [
{
'name': 'app_info',
'type': 'object',
'description': '应用信息(来自AppCreate节点,可选)',
'required': False,
},
{
'name': 'form_results',
'type': 'array',
'description': '表单结果列表(来自循环节点或单个表单发布节点)',
'required': True,
},
{
'name': 'dashboard_info',
'type': 'object',
'description': '仪表盘信息(来自DashboardPublish节点,可选)',
'required': False,
},
{
'name': 'summary_title',
'type': 'string',
'description': '总结标题',
'required': False,
},
]
outputs = [
{
'name': 'summary',
'type': 'object',
'description': '完整的总结数据',
},
{
'name': 'total_forms',
'type': 'number',
'description': '表单总数',
},
{
'name': 'has_app',
'type': 'boolean',
'description': '是否包含应用',
},
{
'name': 'has_dashboard',
'type': 'boolean',
'description': '是否包含仪表盘',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户确认(点击完成按钮)
user_input = context.variables.get('__user_input__')
if user_input:
# 用户已确认,直接返回成功
logger.info('SystemSummaryNode - 用户已确认,完成展示')
context.variables.pop('__user_input__', None)
# 从用户输入中获取之前构建的总结数据
summary_data = self._parse_user_input(user_input)
if summary_data:
return self._build_final_result(summary_data)
# 获取输入参数
app_info_raw = self.config.get('app_info', '')
form_results_raw = self.config.get('form_results', '')
dashboard_info_raw = self.config.get('dashboard_info', '')
summary_title = context.resolve_template(self.config.get('summary_title', ''))
show_statistics = self.config.get('show_statistics', True)
base_url = context.resolve_template(self.config.get('base_url', ''))
# 解析变量引用
app_info = self._resolve_variable_value(context, app_info_raw)
form_results = self._resolve_variable_value(context, form_results_raw)
dashboard_info = self._resolve_variable_value(context, dashboard_info_raw)
logger.info(f'SystemSummaryNode - 开始构建系统总结')
logger.info(f'SystemSummaryNode - app_info: {bool(app_info)}, form_results: {type(form_results)}, dashboard_info: {bool(dashboard_info)}')
# 处理表单结果
forms = self._process_form_results(form_results)
# 处理应用信息
app = self._process_app_info(app_info) if app_info else None
# 处理仪表盘信息
dashboard = self._process_dashboard_info(dashboard_info) if dashboard_info else None
# 构建总结数据
summary = self._build_summary(
title=summary_title or '系统创建完成',
app=app,
forms=forms,
dashboard=dashboard,
show_statistics=show_statistics,
base_url=base_url,
)
logger.info(f'SystemSummaryNode - 总结构建完成: {len(forms)} 个表单')
# 返回等待用户确认的结果
return NodeResult(
success=True,
output=summary,
output_variables={
'summary': summary,
'total_forms': len(forms),
'has_app': app is not None,
'has_dashboard': dashboard is not None,
},
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'system_summary',
'title': summary.get('title', '系统创建完成'),
'data': summary,
'editable': False,
},
)
except Exception as e:
logger.exception(f'SystemSummaryNode - 执行失败: {e}')
return NodeResult(
success=False,
error=f'系统总结节点执行失败: {str(e)}',
)
def _parse_user_input(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户输入"""
if isinstance(user_input, dict):
return user_input
if isinstance(user_input, str):
try:
return json.loads(user_input)
except (json.JSONDecodeError, TypeError):
pass
return None
def _resolve_variable_value(self, context: NodeContext, value: Any) -> Any:
"""解析变量引用,保持原始类型"""
import re
# 如果已经是字典或列表,直接返回
if isinstance(value, (dict, list)):
return value
# 如果不是字符串,返回原值
if not isinstance(value, str):
return value
value = value.strip()
if not value or value in ['{}', '[]', '']:
return None
# 检查是否是单个变量引用 {{node_id.variable_name}}
single_var_pattern = r'^\{\{([^}]+)\}\}$'
match = re.match(single_var_pattern, value)
if match:
var_ref = match.group(1).strip()
# 解析 node_id.variable_name 格式
if '.' in var_ref:
parts = var_ref.split('.', 1)
node_id = parts[0]
key = parts[1]
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict) and key in node_outputs:
return node_outputs[key]
# 直接从变量中获取
if key in context.variables:
return context.variables[key]
logger.warning(f'变量引用未找到: {var_ref}')
else:
# 直接变量引用
if var_ref in context.variables:
return context.variables[var_ref]
logger.warning(f'变量引用未找到: {var_ref}')
# 不是变量引用,尝试解析为 JSON
try:
return json.loads(value)
except json.JSONDecodeError:
pass
# 尝试 Python literal_eval
try:
result = ast.literal_eval(value)
return result
except (ValueError, SyntaxError):
pass
return value
def _process_form_results(self, form_results: Any) -> List[Dict[str, Any]]:
"""处理表单结果列表"""
forms = []
if not form_results:
return forms
# 如果是单个表单结果(字典),包装成列表
if isinstance(form_results, dict):
form_results = [form_results]
if not isinstance(form_results, list):
logger.warning(f'form_results 类型不正确: {type(form_results)}')
return forms
for i, result in enumerate(form_results):
if not isinstance(result, dict):
continue
# 尝试从不同的数据结构中提取表单信息
form_info = self._extract_form_info(result, i)
if form_info:
forms.append(form_info)
return forms
def _extract_form_info(self, result: Dict[str, Any], index: int) -> Optional[Dict[str, Any]]:
"""从结果中提取表单信息"""
# 直接是表单发布节点的输出
if 'form_id' in result or 'menu_id' in result:
return {
'id': result.get('form_id', ''),
'name': result.get('form_name', result.get('menu_name', f'表单{index + 1}')),
'code': result.get('form_code', ''),
'description': result.get('description', ''),
'link': result.get('route_path', ''),
'menu_id': result.get('menu_id', ''),
'icon': result.get('menu_icon', 'lucide:file-text'),
}
# 循环节点的迭代结果(包含多个节点的输出)
# 尝试从 form_publish 或 form_create 节点输出中提取
for key in ['form_publish', 'form_create']:
if key in result and isinstance(result[key], dict):
sub_result = result[key]
return {
'id': sub_result.get('form_id', ''),
'name': sub_result.get('form_name', sub_result.get('menu_name', f'表单{index + 1}')),
'code': sub_result.get('form_code', ''),
'description': sub_result.get('description', ''),
'link': sub_result.get('route_path', ''),
'menu_id': sub_result.get('menu_id', ''),
'icon': sub_result.get('menu_icon', 'lucide:file-text'),
}
# 尝试从嵌套的 publish_result 中提取
if 'publish_result' in result and isinstance(result['publish_result'], dict):
pub = result['publish_result']
return {
'id': pub.get('form_id', result.get('form_id', '')),
'name': pub.get('form_name', pub.get('menu_name', f'表单{index + 1}')),
'code': pub.get('form_code', result.get('form_code', '')),
'description': pub.get('description', ''),
'link': pub.get('route_path', ''),
'menu_id': pub.get('menu_id', ''),
'icon': pub.get('menu_icon', 'lucide:file-text'),
}
# 尝试从 _node_xxx 格式的输出中提取
for key, value in result.items():
if key.startswith('_node_') and isinstance(value, dict):
if 'form_id' in value or 'route_path' in value:
return {
'id': value.get('form_id', ''),
'name': value.get('form_name', value.get('menu_name', f'表单{index + 1}')),
'code': value.get('form_code', ''),
'description': value.get('description', ''),
'link': value.get('route_path', ''),
'menu_id': value.get('menu_id', ''),
'icon': value.get('menu_icon', 'lucide:file-text'),
}
return None
def _process_app_info(self, app_info: Any) -> Optional[Dict[str, Any]]:
"""处理应用信息 - 只需要名称"""
if not app_info:
return None
# 如果是字符串,直接作为名称
if isinstance(app_info, str):
app_name = app_info.strip()
if not app_name:
return None
return {
'name': app_name,
'icon': 'lucide:app-window',
'link': '/', # 应用的基础URL
}
# 如果是字典,尝试提取名称
if isinstance(app_info, dict):
app_name = app_info.get('app_name') or app_info.get('name', '')
if not app_name:
return None
return {
'name': app_name,
'icon': app_info.get('icon', 'lucide:app-window'),
'link': '/',
}
return None
def _process_dashboard_info(self, dashboard_info: Any) -> Optional[Dict[str, Any]]:
"""处理仪表盘信息 - 只需要名称和路径"""
if not dashboard_info:
return None
# 如果是字符串,直接作为名称
if isinstance(dashboard_info, str):
dashboard_name = dashboard_info.strip()
if not dashboard_name:
return None
return {
'name': dashboard_name,
'icon': 'lucide:layout-dashboard',
'link': '', # 没有路径信息
}
# 如果是字典,提取名称和路径
if isinstance(dashboard_info, dict):
dashboard_name = (
dashboard_info.get('dashboard_name') or
dashboard_info.get('menu_name') or
dashboard_info.get('name', '')
)
if not dashboard_name:
return None
return {
'name': dashboard_name,
'icon': dashboard_info.get('menu_icon', 'lucide:layout-dashboard'),
'link': dashboard_info.get('route_path', ''),
}
return None
def _build_summary(
self,
title: str,
app: Optional[Dict[str, Any]],
forms: List[Dict[str, Any]],
dashboard: Optional[Dict[str, Any]],
show_statistics: bool,
base_url: str,
) -> Dict[str, Any]:
"""构建总结数据"""
# 计算统计信息
total_modules = len(forms)
if app:
total_modules += 1
if dashboard:
total_modules += 1
summary = {
'title': title,
'created_at': datetime.now().isoformat(),
'statistics': {
'total_modules': total_modules,
'forms_count': len(forms),
'has_app': app is not None,
'has_dashboard': dashboard is not None,
} if show_statistics else None,
'app': app,
'forms': forms,
'dashboard': dashboard,
'base_url': base_url,
}
return summary
def _build_final_result(self, summary_data: Dict[str, Any]) -> NodeResult:
"""构建最终结果(用户确认后)"""
forms = summary_data.get('forms', [])
app = summary_data.get('app')
dashboard = summary_data.get('dashboard')
return NodeResult(
success=True,
output=summary_data,
output_variables={
'summary': summary_data,
'total_forms': len(forms),
'has_app': app is not None,
'has_dashboard': dashboard is not None,
},
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'app_info': {
'type': 'string',
'title': '应用信息',
'description': '应用信息(来自AppCreate节点),支持变量引用如 {{app_create.app_id}}',
'required': False,
'x-component': 'SmartInput',
'x-component-props': {
'placeholder': '留空则不显示应用信息',
},
},
'form_results': {
'type': 'string',
'title': '表单结果列表',
'description': '表单结果列表(来自循环节点或单个表单发布节点),支持变量引用如 {{loop.results}}',
'required': True,
'x-component': 'SmartInput',
'x-component-props': {
'placeholder': '{{loop.results}} 或 {{form_publish.publish_result}}',
},
},
'dashboard_info': {
'type': 'string',
'title': '仪表盘信息',
'description': '仪表盘信息(来自DashboardPublish节点),支持变量引用',
'required': False,
'x-component': 'SmartInput',
'x-component-props': {
'placeholder': '留空则不显示仪表盘信息',
},
},
'summary_title': {
'type': 'string',
'title': '总结标题',
'description': '总结页面的标题',
'default': '系统创建完成',
'x-component': 'Input',
'x-component-props': {
'placeholder': '系统创建完成',
},
},
'show_statistics': {
'type': 'boolean',
'title': '显示统计信息',
'description': '是否显示模块数量等统计信息',
'default': True,
'x-component': 'Switch',
},
'base_url': {
'type': 'string',
'title': '基础URL',
'description': '用于生成完整链接的基础URL(可选)',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '留空则使用相对路径',
},
},
},
'required': ['form_results'],
}
@@ -1,253 +0,0 @@
"""
模板渲染节点
"""
import logging
import time
from typing import Any, Dict
from sqlalchemy import select
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class TemplateNode(BaseNode):
"""
模板渲染节点
使用变量渲染模板字符串
"""
node_type = 'template'
node_name = '模板'
node_category = 'data'
node_icon = 'file-text'
node_description = '使用变量渲染模板字符串'
inputs = [
{
'name': 'template',
'type': 'string',
'description': '模板字符串',
},
]
outputs = [
{
'name': 'result',
'type': 'string',
'description': '渲染结果',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行模板渲染"""
try:
template = self.config.get('template', '')
output_variable = self.config.get('output_variable', 'template_result')
# 渲染模板
result = context.resolve_template(template)
return NodeResult(
success=True,
output=result,
output_variables={output_variable: result},
)
except Exception as e:
logger.exception(f'模板节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""配置 agent_code 时,以智能体身份执行当前模板任务。"""
agent_code = (self.config.get('agent_code') or '').strip()
if not agent_code:
return self.execute(context)
start_time = time.time()
try:
if not context.db_session:
return NodeResult(success=False, error='智能体模板节点需要数据库会话')
from ai_platform.models import Agent
from ai_platform.services.llm_service import LLMService
result = await context.db_session.execute(
select(Agent).where(
Agent.code == agent_code,
Agent.is_deleted == False,
Agent.status == 'published',
)
)
agent = result.scalar_one_or_none()
if not agent:
return NodeResult(success=False, error=f'智能体不存在或未发布: {agent_code}')
model_id = self.config.get('model_id') or agent.model_id
if not model_id:
model_id = await self._resolve_default_chat_model_id(context)
if not model_id:
return NodeResult(success=False, error=f'智能体未配置模型: {agent.name}({agent.code})')
template = self.config.get('template', '')
rendered_prompt = context.resolve_template(template)
if not rendered_prompt.strip():
return NodeResult(success=False, error='智能体模板节点缺少任务内容')
messages = [
{'role': 'system', 'content': self._build_agent_system_prompt(agent)},
{'role': 'user', 'content': self._build_agent_user_prompt(context, rendered_prompt)},
]
response = await LLMService(context.db_session).chat_async(
model_id=str(model_id),
messages=messages,
temperature=self.config.get(
'temperature',
agent.temperature if agent.temperature is not None else 0.7,
),
max_tokens=self.config.get('max_tokens', agent.max_tokens or 2048),
)
elapsed_time = int((time.time() - start_time) * 1000)
output_variable = self.config.get('output_variable') or f'{agent.code}_result'
output_variables = {
output_variable: response.content,
f'{output_variable}_agent_code': agent.code,
f'{output_variable}_agent_name': agent.name,
f'{output_variable}_tokens': response.total_tokens,
}
return NodeResult(
success=True,
output=response.content,
output_variables=output_variables,
tokens_used=response.total_tokens,
elapsed_time=elapsed_time,
metadata={
'agent_code': agent.code,
'agent_name': agent.name,
'model_id': str(model_id),
'model': response.model,
'prompt_tokens': response.prompt_tokens,
'completion_tokens': response.completion_tokens,
'output_variable': output_variable,
'frontend_output_variables': output_variables,
},
)
except Exception as e:
logger.exception(f'智能体模板节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
elapsed_time=int((time.time() - start_time) * 1000),
)
@staticmethod
async def _resolve_default_chat_model_id(context: NodeContext) -> str:
from ai_platform.models import LLMModel
result = await context.db_session.execute(
select(LLMModel)
.where(
LLMModel.is_deleted == False,
LLMModel.is_active == True,
LLMModel.model_type == 'chat',
)
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
)
model = result.scalars().first()
return str(model.id) if model else ''
@staticmethod
def _build_agent_system_prompt(agent: Any) -> str:
base_prompt = agent.system_prompt or ''
persona = agent.persona or {}
if not base_prompt and isinstance(persona, dict):
parts = []
if persona.get('role'):
parts.append(persona['role'])
if persona.get('skills'):
parts.append('你擅长:' + ''.join(str(item) for item in persona['skills']) + '')
if persona.get('constraints'):
parts.append('注意事项:\n' + '\n'.join(f'- {item}' for item in persona['constraints']))
if persona.get('background'):
parts.append(str(persona['background']))
base_prompt = '\n\n'.join(parts)
if not base_prompt:
base_prompt = '你是一个智能体,请用中文完成当前任务。'
return (
f'{base_prompt}\n\n'
'你正在作为流程编排中的协作智能体执行当前步骤。'
'请只输出本步骤的结论、交付证据、风险和下一步,避免空泛说明。'
)
@staticmethod
def _format_context_value(value: Any, max_length: int = 1200) -> str:
if isinstance(value, str):
text = value
else:
import json
try:
text = json.dumps(value, ensure_ascii=False, indent=2)
except TypeError:
text = str(value)
return text if len(text) <= max_length else f'{text[:max_length]}...'
def _build_agent_user_prompt(self, context: NodeContext, rendered_prompt: str) -> str:
context_lines = []
for key, value in context.variables.items():
if key.startswith('_') or key.endswith('_tokens') or key.endswith('_agent_code') or key.endswith('_agent_name'):
continue
context_lines.append(f'- {key}: {self._format_context_value(value)}')
if len('\n'.join(context_lines)) > 6000:
context_lines.append('- 其余上下文因长度限制已省略')
break
sections = [f'当前步骤任务:\n{rendered_prompt}']
if context.previous_output:
sections.append(f'上一节点输出:\n{self._format_context_value(context.previous_output, 2000)}')
if context_lines:
sections.append('可用工作流上下文:\n' + '\n'.join(context_lines))
return '\n\n'.join(sections)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'template': {
'type': 'string',
'title': '模板',
'description': '支持变量引用,如 {{variable_name}}',
'format': 'textarea',
},
'output_variable': {
'type': 'string',
'title': '输出变量名',
'default': 'template_result',
},
'agent_code': {
'type': 'string',
'title': '智能体编码',
'description': '配置后会以该智能体身份调用模型执行模板任务',
},
'model_id': {
'type': 'string',
'title': '覆盖模型 ID',
'description': '为空时使用智能体默认模型',
},
},
'required': ['template'],
}
@@ -1,947 +0,0 @@
"""
Text-to-SQL 节点
将自然语言转换为 SQL 查询并执行支持流式输出和图表推荐
"""
import json
import logging
import re
import time
from dataclasses import dataclass
from typing import Any, Dict, Generator, List, Optional, Tuple
import sqlparse
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@dataclass
class TextToSqlStreamEvent:
"""Text-to-SQL 流式输出事件"""
event_type: str = '' # thought, sql_chunk, sql_complete, executing, result
content: str = ''
is_finished: bool = False
data: Any = None
# Text-to-SQL Function Calling 工具定义
TEXT_TO_SQL_TOOL = {
'name': 'generate_sql',
'description': '根据用户的自然语言问题生成 SQL 查询语句',
'parameters': {
'type': 'object',
'properties': {
'thought': {
'type': 'string',
'description': '分析思路,简要描述如何理解用户问题并设计 SQL',
},
'sql': {
'type': 'string',
'description': '生成的 SQL 查询语句,必须是有效的 SELECT 语句,必须格式化,如果表结构中包含 schema 信息,SQL 中的表名必须使用完整格式:schema.table_name',
},
},
'required': ['thought', 'sql'],
},
}
# Text-to-SQL Function Calling 版 System Prompt(简化版)
TEXT_TO_SQL_SYSTEM_PROMPT_FC = """你是一个专业的数据分析助手,擅长将自然语言转换为 SQL 查询。
## 数据库信息
数据库类型: {db_type}
当前日期: {current_date}
## 可用的表结构
{schema_context}
## 任务要求
1. 根据用户问题生成准确的 SQL 查询
2. 只生成 SELECT 语句禁止生成 INSERT/UPDATE/DELETE/DROP 等修改语句
3. 使用表的注释理解业务含义
4. 添加必要的 WHERE 条件和 ORDER BY
5. 如果表结构中包含 schema 信息SQL 中的表名必须使用完整格式schema.table_name
## 注意事项
- SQL 必须是有效的 {db_type} 语法
- 避免使用 SELECT *明确指定需要的字段
- 对于大数据量查询添加 LIMIT 限制
- 使用 DISTINCT ORDER BY 的列必须出现在 SELECT 列表中
请使用 generate_sql 函数返回结果
"""
# Text-to-SQL 专用 System Prompt(原有版本,作为 fallback
TEXT_TO_SQL_SYSTEM_PROMPT = """你是一个专业的数据分析助手,擅长将自然语言转换为 SQL 查询。
## 数据库信息
数据库类型: {db_type}
当前日期: {current_date}
## 可用的表结构
{schema_context}
## 任务要求
1. 根据用户问题生成准确的 SQL 查询
2. 只生成 SELECT 语句禁止生成 INSERT/UPDATE/DELETE/DROP 等修改语句
3. 使用表的注释理解业务含义
4. 添加必要的 WHERE 条件和 ORDER BY
5. 如果表结构中包含 schema 信息SQL 中的表名必须使用完整格式schema.table_name
6. 如果需要推荐最合适的图表类型
## 输出格式
严格按照以下 JSON 格式输出不要输出其他内容
{{
"thought": "你的分析思路(简洁描述)",
"sql": "生成的 SQL 语句",
"chart_type": "bar|line|pie|scatter|radar|table|none",
"chart_config": {{
"x_field": "X轴字段名(用于 bar/line/scatter",
"y_field": "Y轴字段名(用于 scatter",
"series_fields": ["系列字段1", "系列字段2"],
"name_field": "名称字段(用于 pie",
"value_field": "数值字段(用于 pie/gauge",
"title": "图表标题"
}}
}}
## 图表类型选择指南
- bar柱状图: 分类对比如各部门销售额
- line折线图: 时间趋势如每日销售额变化
- pie饼图: 占比分析如各类别占比
- scatter散点图: 分布分析如价格与销量关系
- radar雷达图: 多维对比如产品多指标评分
- table表格: 详细数据展示
- none无图表: 不适合可视化的数据
## 注意事项
- SQL 必须是有效的 {db_type} 语法
- 避免使用 SELECT *明确指定需要的字段
- 对于大数据量查询添加 LIMIT 限制
- 时间字段使用标准格式
- 使用 DISTINCT ORDER BY 的列必须出现在 SELECT 列表中
- 如需去重并排序考虑使用子查询或窗口函数
"""
def serialize_db_value(value: Any) -> Any:
"""将数据库值转换为可 JSON 序列化的格式"""
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, bytes):
return value.decode('utf-8', errors='replace')
if isinstance(value, (list, tuple)):
return [serialize_db_value(v) for v in value]
if isinstance(value, dict):
return {k: serialize_db_value(v) for k, v in value.items()}
return value
@NodeRegistry.register
class TextToSqlNode(BaseNode):
"""
Text-to-SQL 节点
将自然语言转换为 SQL 查询并执行
"""
node_type = 'text_to_sql'
node_name = 'Text-to-SQL'
node_category = 'data'
node_icon = 'database-zap'
node_description = '将自然语言转换为 SQL 查询并执行,支持图表推荐'
inputs = [
{
'name': 'user_question',
'type': 'string',
'description': '用户的自然语言问题',
},
]
outputs = [
{
'name': 'text_to_sql_result_sql',
'type': 'string',
'description': '生成的 SQL 语句',
},
{
'name': 'text_to_sql_result_thought',
'type': 'string',
'description': 'SQL 生成思路',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行 Text-to-SQL(同步方法)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行 Text-to-SQL"""
start_time = time.time()
try:
# 获取配置
user_question = self.config.get('user_question', '')
db_config = self.config.get('db_config') or {}
db_connection = db_config.get('dbName', 'default')
schema_name = db_config.get('schema', 'public')
model_id = self.config.get('model_id', '')
selected_tables = self.config.get('selected_tables', [])
table_relations = self.config.get('table_relations', []) # 手动指定的表关系
output_variable = self.config.get('output_variable', 'text_to_sql_result')
include_relations = self.config.get('include_table_relations', True)
# 解析变量
if user_question:
user_question = context.resolve_template(user_question)
if not user_question:
return NodeResult(
success=False,
error='请输入要查询的问题',
)
if not model_id:
return NodeResult(
success=False,
error='请选择 LLM 模型',
)
# Step 1: 获取数据库 Schema
schema_context = await self._get_schema_context(
db_connection,
schema_name,
selected_tables,
include_relations,
table_relations
)
if not schema_context:
return NodeResult(
success=False,
error=f'无法获取数据库 {db_connection} 的表结构信息',
)
# Step 2: 调用 LLM 生成 SQL
from datetime import datetime
from ai_platform.services.llm_service import LLMService
db_type = await self._get_db_type(db_connection)
llm_service = LLMService(context.db_session)
llm_result = None
use_function_calling = self.config.get('use_function_calling', True)
# 优先尝试 Function Calling
if use_function_calling:
try:
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT_FC.format(
db_type=db_type,
current_date=datetime.now().strftime('%Y-%m-%d'),
schema_context=schema_context,
)
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': f'用户问题: {user_question}'},
]
response = await llm_service.chat_async(
model_id=model_id,
messages=messages,
temperature=0.1,
max_tokens=2048,
tools=[TEXT_TO_SQL_TOOL],
tool_choice='required',
)
# 解析 Function Calling 响应
if response.tool_calls and len(response.tool_calls) > 0:
tool_call = response.tool_calls[0]
if tool_call.name == 'generate_sql':
import json
arguments = tool_call.arguments
if isinstance(arguments, str):
llm_result = json.loads(arguments)
else:
llm_result = arguments
logger.info(f'Function Calling 成功生成 SQL')
except Exception as fc_error:
logger.warning(f'Function Calling 失败,回退到 JSON 解析模式: {fc_error}')
llm_result = None
# Fallback: 使用 JSON 解析方式
if not llm_result:
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT.format(
db_type=db_type,
current_date=datetime.now().strftime('%Y-%m-%d'),
schema_context=schema_context,
)
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': f'用户问题: {user_question}'},
]
response = await llm_service.chat_async(
model_id=model_id,
messages=messages,
temperature=0.1,
max_tokens=2048,
)
# 解析 LLM 响应
llm_result = self._parse_llm_response(response.content)
if not llm_result:
return NodeResult(
success=False,
error='LLM 响应解析失败,请重试',
metadata={'raw_response': response.content},
)
sql = llm_result.get('sql', '')
thought = llm_result.get('thought', '')
# Step 3: 验证 SQL 安全性
if not self._validate_sql(sql):
return NodeResult(
success=False,
error='生成的 SQL 不安全或不是 SELECT 语句',
metadata={'sql': sql},
)
# Step 4: 格式化 SQL
sql = self._format_sql(sql)
elapsed_time = int((time.time() - start_time) * 1000)
# 构建输出
result_data = {
'sql': sql,
'thought': thought,
}
return NodeResult(
success=True,
output=result_data,
output_variables={
f'{output_variable}_sql': sql,
f'{output_variable}_thought': thought,
},
tokens_used=response.total_tokens,
elapsed_time=elapsed_time,
metadata={
'model': response.model,
'thought': thought,
'suggested_next_node': 'db_sql',
},
)
except Exception as e:
logger.exception(f'Text-to-SQL 节点执行失败: {e}')
elapsed_time = int((time.time() - start_time) * 1000)
return NodeResult(
success=False,
error=str(e),
elapsed_time=elapsed_time,
)
def execute_stream(self, context: NodeContext) -> Generator[TextToSqlStreamEvent, None, NodeResult]:
"""
流式执行 Text-to-SQL
Yields:
TextToSqlStreamEvent: 流式输出事件
Returns:
NodeResult: 最终执行结果
"""
start_time = time.time()
try:
# 获取配置
user_question = self.config.get('user_question', '')
db_config = self.config.get('db_config') or {}
db_connection = db_config.get('dbName', 'default')
schema_name = db_config.get('schema', 'public')
model_id = self.config.get('model_id', '')
selected_tables = self.config.get('selected_tables', [])
output_variable = self.config.get('output_variable', 'text_to_sql_result')
include_relations = self.config.get('include_table_relations', True)
# 解析变量
if user_question:
user_question = context.resolve_template(user_question)
if not user_question:
yield TextToSqlStreamEvent(
event_type='error',
content='请输入要查询的问题',
is_finished=True,
)
return NodeResult(success=False, error='请输入要查询的问题')
if not model_id:
yield TextToSqlStreamEvent(
event_type='error',
content='请选择 LLM 模型',
is_finished=True,
)
return NodeResult(success=False, error='请选择 LLM 模型')
# Step 1: 获取数据库 Schema
yield TextToSqlStreamEvent(
event_type='thought',
content='正在获取数据库表结构...',
)
table_relations = self.config.get('table_relations', []) # 手动指定的表关系
import asyncio
schema_context = asyncio.get_event_loop().run_until_complete(
self._get_schema_context(
db_connection,
schema_name,
selected_tables,
include_relations,
table_relations
)
)
if not schema_context:
yield TextToSqlStreamEvent(
event_type='error',
content=f'无法获取数据库 {db_connection} 的表结构信息',
is_finished=True,
)
return NodeResult(
success=False,
error=f'无法获取数据库 {db_connection} 的表结构信息',
)
# Step 2: 调用 LLM 生成 SQL(流式)
yield TextToSqlStreamEvent(
event_type='thought',
content='正在分析问题并生成 SQL...',
)
from datetime import datetime
from ai_platform.services.llm_service import LLMService
db_type = asyncio.get_event_loop().run_until_complete(
self._get_db_type(db_connection)
)
llm_service = LLMService()
accumulated_content = ''
total_tokens = 0
llm_result = None
use_function_calling = self.config.get('use_function_calling', True)
# 优先尝试 Function Calling
if use_function_calling:
try:
llm_result, total_tokens = yield from self._call_llm_with_function_calling(
llm_service, model_id, db_type, schema_context, user_question
)
logger.info(f'Function Calling 成功: {llm_result}')
except Exception as fc_error:
logger.warning(f'Function Calling 失败,回退到 JSON 解析模式: {fc_error}')
llm_result = None
# Fallback: 使用原有的 JSON 解析方式
if not llm_result:
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT.format(
db_type=db_type,
current_date=datetime.now().strftime('%Y-%m-%d'),
schema_context=schema_context,
)
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': f'用户问题: {user_question}'},
]
for chunk in llm_service.chat_stream_sync(
model_id=model_id,
messages=messages,
temperature=0.1,
max_tokens=2048,
):
accumulated_content += chunk.content
if chunk.is_finished and chunk.total_tokens > 0:
total_tokens = chunk.total_tokens
yield TextToSqlStreamEvent(
event_type='sql_chunk',
content=chunk.content,
is_finished=chunk.is_finished,
)
# 解析 LLM 响应
llm_result = self._parse_llm_response(accumulated_content)
if not llm_result:
yield TextToSqlStreamEvent(
event_type='error',
content='LLM 响应解析失败',
is_finished=True,
)
return NodeResult(
success=False,
error='LLM 响应解析失败',
metadata={'raw_response': accumulated_content},
)
sql = llm_result.get('sql', '')
thought = llm_result.get('thought', '')
yield TextToSqlStreamEvent(
event_type='sql_complete',
content=sql,
data={'thought': thought},
)
# Step 3: 验证 SQL 安全性
if not self._validate_sql(sql):
yield TextToSqlStreamEvent(
event_type='error',
content='生成的 SQL 不安全或不是 SELECT 语句',
is_finished=True,
)
return NodeResult(
success=False,
error='生成的 SQL 不安全或不是 SELECT 语句',
metadata={'sql': sql},
)
# Step 4: 格式化 SQL
sql = self._format_sql(sql)
elapsed_time = int((time.time() - start_time) * 1000)
# 构建输出
result_data = {
'sql': sql,
'thought': thought,
}
yield TextToSqlStreamEvent(
event_type='result',
content='SQL 生成完成',
is_finished=True,
data=result_data,
)
return NodeResult(
success=True,
output=result_data,
output_variables={
f'{output_variable}_sql': sql,
f'{output_variable}_thought': thought,
},
tokens_used=total_tokens,
elapsed_time=elapsed_time,
metadata={
'thought': thought,
'suggested_next_node': 'db_sql',
},
)
except Exception as e:
logger.exception(f'Text-to-SQL 流式执行失败: {e}')
yield TextToSqlStreamEvent(
event_type='error',
content=str(e),
is_finished=True,
)
return NodeResult(
success=False,
error=str(e),
elapsed_time=int((time.time() - start_time) * 1000),
)
async def _get_schema_context(
self,
db_connection: str,
schema_name: str = 'public',
selected_tables: List[str] = None,
include_relations: bool = True,
manual_relations: List[Dict[str, Any]] = None
) -> str:
"""获取数据库 Schema 上下文"""
try:
from core.database_manager.service import AsyncDatabaseManagerService
# 创建数据库服务实例
db_service = await AsyncDatabaseManagerService.create(db_connection)
schema_name = self._resolve_schema_name(db_service, schema_name)
# 获取表列表
if selected_tables and len(selected_tables) > 0:
tables = selected_tables
else:
# 获取所有表
tables_info = await db_service.get_tables(schema_name=schema_name)
tables = [
t.get('table_name') or t.get('name')
for t in tables_info[:20]
if t.get('table_name') or t.get('name')
] # 限制最多 20 个表
schema_parts = []
table_relations = []
for table_name in tables:
try:
columns = await db_service.get_table_columns(table_name, schema_name)
col_desc = []
for col in columns:
col_name = col.get('column_name') or col.get('name', '')
col_type = col.get('data_type') or col.get('type', '')
col_str = f" - {col_name} ({col_type})"
if col.get('description') or col.get('comment'):
col_str += f" -- {col.get('description') or col.get('comment')}"
if col.get('is_primary_key'):
col_str += " [PK]"
if col.get('is_foreign_key'):
col_str += " [FK]"
col_desc.append(col_str)
full_table_name = self._format_schema_table_name(
db_service, schema_name, table_name
)
table_schema = f"表: {full_table_name}\n" + "\n".join(col_desc)
schema_parts.append(table_schema)
# 获取外键关系(从数据库)
if include_relations:
try:
constraints = await db_service.get_table_constraints(table_name, schema_name)
for constraint in constraints:
constraint_type = (
constraint.get('constraint_type')
or constraint.get('type', '')
)
if constraint_type != 'FOREIGN KEY':
continue
source_full_name = self._format_schema_table_name(
db_service, schema_name, table_name
)
foreign_table = (
constraint.get('referenced_table')
or constraint.get('foreign_table')
or ''
)
target_full_name = self._format_schema_table_name(
db_service, schema_name, foreign_table
)
source_columns = (
constraint.get('columns')
or constraint.get('column')
or ''
)
foreign_columns = (
constraint.get('referenced_columns')
or constraint.get('foreign_column')
or ''
)
relation = (
f" {source_full_name}.{source_columns}"
f"{target_full_name}.{foreign_columns}"
)
table_relations.append(relation)
except Exception as e:
logger.debug(f'获取表 {table_name} 外键关系失败: {e}')
except Exception as e:
logger.warning(f'获取表 {table_name} 结构失败: {e}')
# 添加手动指定的表关系(逻辑外键)
if manual_relations:
for rel in manual_relations:
source_table = rel.get('sourceTable', '')
source_field = rel.get('sourceField', '')
target_table = rel.get('targetTable', '')
target_field = rel.get('targetField', '')
if source_table and source_field and target_table and target_field:
relation = f" {source_table}.{source_field}{target_table}.{target_field} (逻辑外键)"
table_relations.append(relation)
# 组装最终的 Schema 上下文
result = "\n\n".join(schema_parts)
if table_relations:
result += "\n\n## 表关系(外键)\n" + "\n".join(table_relations)
return result
except Exception as e:
logger.exception(f'获取 Schema 上下文失败: {e}')
return ''
@staticmethod
def _resolve_schema_name(db_service, schema_name: str) -> str:
"""按连接类型解析有效 schema,避免非 PG 库误用 public。"""
if db_service._uses_schema_layer():
if not schema_name or (
schema_name == 'public' and db_service.db_type != 'postgresql'
):
return db_service._default_schema()
return schema_name
return schema_name or ''
@staticmethod
def _format_schema_table_name(db_service, schema_name: str, table_name: str) -> str:
if not table_name:
return table_name
if db_service._uses_schema_layer() and schema_name:
return f"{schema_name}.{table_name}"
return table_name
async def _get_db_type(self, db_connection: str) -> str:
"""获取数据库类型"""
try:
from core.database_manager.service import AsyncDatabaseManagerService
db_service = await AsyncDatabaseManagerService.create(db_connection)
return db_service.db_type.upper()
except Exception:
return 'PostgreSQL'
def _call_llm_with_function_calling(
self,
llm_service,
model_id: str,
db_type: str,
schema_context: str,
user_question: str
) -> Generator[TextToSqlStreamEvent, None, Tuple[Dict[str, Any], int]]:
"""
使用 Function Calling 调用 LLM 生成 SQL
Args:
llm_service: LLM 服务实例
model_id: 模型 ID
db_type: 数据库类型
schema_context: Schema 上下文
user_question: 用户问题
Yields:
TextToSqlStreamEvent: 流式输出事件
Returns:
tuple: (llm_result, total_tokens)
"""
from datetime import datetime
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT_FC.format(
db_type=db_type,
current_date=datetime.now().strftime('%Y-%m-%d'),
schema_context=schema_context,
)
messages = [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': f'用户问题: {user_question}'},
]
# 定义工具
tools = [TEXT_TO_SQL_TOOL]
# 累积工具调用参数
accumulated_arguments = ''
total_tokens = 0
tool_call_received = False
for chunk in llm_service.chat_stream_sync(
model_id=model_id,
messages=messages,
temperature=0.1,
max_tokens=2048,
tools=tools,
tool_choice='required', # 强制使用工具
):
# 处理工具调用增量
if chunk.tool_call_delta:
delta_args = chunk.tool_call_delta.get('arguments', '')
if delta_args:
accumulated_arguments += delta_args
# 流式输出工具调用参数
yield TextToSqlStreamEvent(
event_type='sql_chunk',
content=delta_args,
is_finished=False,
)
# 处理完整的工具调用
if chunk.tool_calls:
tool_call_received = True
for tool_call in chunk.tool_calls:
if tool_call.name == 'generate_sql':
# 解析工具调用参数
arguments = tool_call.arguments
if isinstance(arguments, str):
arguments = json.loads(arguments)
yield TextToSqlStreamEvent(
event_type='sql_chunk',
content='',
is_finished=True,
)
return (arguments, total_tokens)
# 记录 token 使用
if chunk.is_finished and chunk.total_tokens > 0:
total_tokens = chunk.total_tokens
# 如果没有收到工具调用,尝试从累积的参数中解析
if accumulated_arguments and not tool_call_received:
try:
arguments = json.loads(accumulated_arguments)
yield TextToSqlStreamEvent(
event_type='sql_chunk',
content='',
is_finished=True,
)
return (arguments, total_tokens)
except json.JSONDecodeError:
pass
# Function Calling 失败
raise ValueError('Function Calling 未返回有效的工具调用')
def _parse_llm_response(self, content: str) -> Optional[Dict[str, Any]]:
"""解析 LLM 响应(Fallback 方式)"""
try:
# 尝试直接解析 JSON
return json.loads(content)
except json.JSONDecodeError:
pass
# 尝试从 markdown 代码块中提取 JSON
import re
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', content)
if json_match:
try:
return json.loads(json_match.group(1))
except json.JSONDecodeError:
pass
# 尝试提取 {...} 部分
brace_match = re.search(r'\{[\s\S]*\}', content)
if brace_match:
try:
return json.loads(brace_match.group(0))
except json.JSONDecodeError:
pass
logger.warning(f'无法解析 LLM 响应: {content[:500]}')
return None
def _validate_sql(self, sql: str) -> bool:
"""验证 SQL 安全性"""
if not sql:
logger.warning("SQL 验证失败: SQL 为空")
return False
# 去除前后空白和可能的 Markdown 代码块标记
sql_cleaned = sql.strip()
if sql_cleaned.startswith('```'):
# 移除 Markdown 代码块
lines = sql_cleaned.split('\n')
# 移除第一行(```sql 或 ```
if lines:
lines = lines[1:]
# 移除最后一行(```
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
sql_cleaned = '\n'.join(lines).strip()
sql_upper = sql_cleaned.upper().strip()
# 只允许 SELECT 语句(也允许 WITH ... SELECT 即 CTE
if not sql_upper.startswith('SELECT') and not sql_upper.startswith('WITH'):
logger.warning(f"SQL 验证失败: 不是 SELECT/WITH 语句, SQL 开头: {sql_upper[:50]}")
return False
# 禁止危险关键字(作为独立语句,不在子查询或 CTE 中)
dangerous_keywords = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE',
'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE',
]
for keyword in dangerous_keywords:
# 检查是否作为独立关键字出现(前后有空格或在开头/结尾)
if re.search(rf'\b{keyword}\b', sql_upper):
logger.warning(f"SQL 验证失败: 包含危险关键字 {keyword}, SQL: {sql_cleaned[:200]}")
return False
# 禁止多语句(分号后还有内容)
if ';' in sql_cleaned:
parts = sql_cleaned.split(';')
if any(p.strip() for p in parts[1:]):
logger.warning(f"SQL 验证失败: 包含多条语句")
return False
return True
def _format_sql(self, sql: str) -> str:
"""
格式化 SQL 语句
Args:
sql: 原始 SQL 语句
Returns:
格式化后的 SQL 语句
"""
try:
# 去除可能的 Markdown 代码块标记
sql_cleaned = sql.strip()
if sql_cleaned.startswith('```'):
lines = sql_cleaned.split('\n')
if lines:
lines = lines[1:]
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
sql_cleaned = '\n'.join(lines).strip()
# 使用 sqlparse 格式化
formatted_sql = sqlparse.format(
sql_cleaned,
reindent=True, # 重新缩进
keyword_case='upper', # 关键字大写
identifier_case='lower', # 标识符小写
strip_comments=False, # 保留注释
use_space_around_operators=True, # 操作符周围加空格
)
return formatted_sql.strip()
except Exception as e:
logger.warning(f"SQL 格式化失败: {e}, 返回原始 SQL")
return sql
@@ -1,151 +0,0 @@
"""
变量赋值节点
"""
import logging
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class VariableNode(BaseNode):
"""
变量赋值节点
设置或修改变量的值
"""
node_type = 'variable'
node_name = '变量'
node_category = 'data'
node_icon = 'variable'
node_description = '设置或修改变量的值'
inputs = [
{
'name': 'value',
'type': 'any',
'description': '要设置的值',
},
]
outputs = [
{
'name': 'value',
'type': 'any',
'description': '设置后的值',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行变量赋值"""
try:
assignments = self.config.get('assignments', [])
output_variables = {}
for assignment in assignments:
var_name = assignment.get('name', '')
value_type = assignment.get('type', 'static')
value = assignment.get('value', '')
if not var_name:
continue
# 根据类型处理值
if value_type == 'static':
# 静态值
final_value = value
elif value_type == 'variable':
# 从其他变量获取
final_value = context.get_variable(value, '')
elif value_type == 'template':
# 模板渲染
final_value = context.resolve_template(value)
elif value_type == 'json':
# JSON 解析
import json
final_value = json.loads(value)
elif value_type == 'expression':
# 简单表达式(仅支持基本运算)
final_value = self._evaluate_expression(value, context)
else:
final_value = value
# 设置变量
context.set_variable(var_name, final_value)
output_variables[var_name] = final_value
return NodeResult(
success=True,
output=output_variables,
output_variables=output_variables,
)
except Exception as e:
logger.exception(f'变量节点执行失败: {e}')
return NodeResult(
success=False,
error=str(e),
)
def _evaluate_expression(self, expression: str, context: NodeContext) -> Any:
"""
评估简单表达式
仅支持基本的数学运算和字符串操作
"""
# 替换变量引用
resolved = context.resolve_template(expression)
# 安全的评估环境
safe_dict = {
'abs': abs,
'int': int,
'float': float,
'str': str,
'len': len,
'min': min,
'max': max,
'sum': sum,
'round': round,
}
try:
return eval(resolved, {"__builtins__": {}}, safe_dict)
except Exception:
return resolved
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取配置 Schema"""
return {
'type': 'object',
'properties': {
'assignments': {
'type': 'array',
'title': '变量赋值',
'items': {
'type': 'object',
'properties': {
'name': {
'type': 'string',
'title': '变量名',
},
'type': {
'type': 'string',
'title': '值类型',
'enum': ['static', 'variable', 'template', 'json', 'expression'],
'default': 'static',
},
'value': {
'type': 'string',
'title': '',
},
},
},
},
},
}

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