Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aba475726 | |||
| 0a857986f9 | |||
| 5217af6fe7 | |||
| 0e485492cf |
@@ -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
|
|
||||||
@@ -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:
|
|
||||||
@@ -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
@@ -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
|
node_modules
|
||||||
|
dist
|
||||||
# ai-agent-admin: exclude generated bundles and unused product entries.
|
.venv
|
||||||
# Keep design/runtime source used by AI workflow panels tracked; lightness is
|
__pycache__
|
||||||
# enforced by route/menu filtering and lazy imports, not by missing source.
|
*.pyc
|
||||||
.ops-backups/
|
.env
|
||||||
docker/uniapp/
|
.DS_Store
|
||||||
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/
|
|
||||||
|
|||||||
-443
@@ -1,443 +0,0 @@
|
|||||||
# ZQ Platform
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](backend/)
|
|
||||||
[](backend/)
|
|
||||||
[](web/)
|
|
||||||
[](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)
|
|
||||||
|
|
||||||
@@ -1,580 +1,8 @@
|
|||||||
# ZQ Platform
|
# ai-agent-admin
|
||||||
|
|
||||||
<div align="center">
|
轻量化 AI Agent 管理后台,保留基础 Admin 能力,并提供 Provider、Model、Agent、Workflow、Knowledge Base 和 Agent Team 协作模块。
|
||||||
|
|
||||||
[](LICENSE)
|
默认管理员:
|
||||||
[](backend/)
|
|
||||||
[](backend/)
|
|
||||||
[](web/)
|
|
||||||
[](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 | FastAPI(Uvicorn 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
@@ -1,339 +0,0 @@
|
|||||||
# zq-platform(芷青开发平台)
|
|
||||||
|
|
||||||
[English](./README.md) | 简体中文
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
一个现代化的企业级后台管理系统,基于 Django + Vue3 + Element Plus 构建
|
|
||||||
|
|
||||||
[](https://www.djangoproject.com/)
|
|
||||||
[](https://vuejs.org/)
|
|
||||||
[](https://element-plus.org/)
|
|
||||||
[](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>
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用 dnf(CentOS 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)
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""
|
|
||||||
AI 平台模块
|
|
||||||
|
|
||||||
提供类似 Dify 的 LLM 应用开发能力:
|
|
||||||
- 模型配置管理
|
|
||||||
- 对话功能
|
|
||||||
- 工作流编排
|
|
||||||
- 智能体
|
|
||||||
- 工具系统
|
|
||||||
"""
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
"""
|
|
||||||
AI 平台 API
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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="提供商不存在")
|
|
||||||
|
|
||||||
# 构建 kwargs(Ollama 需要 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 存储 question,metadata 中存储 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(' ', ' ')
|
|
||||||
text = text.replace('<', '<')
|
|
||||||
text = text.replace('>', '>')
|
|
||||||
text = text.replace('&', '&')
|
|
||||||
text = text.replace('"', '"')
|
|
||||||
text = text.replace(''', "'")
|
|
||||||
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 API(Jina/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 返回 answer,segment_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):
|
|
||||||
"""
|
|
||||||
向量存储抽象基类
|
|
||||||
|
|
||||||
每个知识库对应一个 collection,collection 名称格式: 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 确定性转换为 UUID5(Qdrant 要求 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"),
|
|
||||||
)
|
|
||||||
@@ -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',
|
|
||||||
]
|
|
||||||
@@ -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
Reference in New Issue
Block a user